diff --git a/library/saltbox_facts.py b/library/saltbox_facts.py index 853aa0dc2b..54cf6b76b4 100644 --- a/library/saltbox_facts.py +++ b/library/saltbox_facts.py @@ -1,68 +1,271 @@ #!/usr/bin/python +""" +Ansible module for managing Saltbox configuration facts. + +This module provides functionality to load, save, and delete configuration facts +for Saltbox roles. It handles configuration file operations with atomic writes +and proper error handling. + +Example Usage: + - name: Save facts for role + saltbox_facts: + role: myapp + instance: instance1 + method: save + keys: + key1: value1 + key2: value2 + owner: user1 + group: group1 + mode: '0644' + + - name: Delete instance + saltbox_facts: + role: myapp + instance: instance1 + method: delete + delete_type: instance + + - name: Load facts + saltbox_facts: + role: myapp + instance: instance1 + method: load +""" from ansible.module_utils.basic import AnsibleModule import configparser import os import pwd import grp +import tempfile +import shutil + +def validate_instance_name(instance): + """ + Validate that the instance name is a string. + + Args: + instance: Value to validate as instance name + + Raises: + ValueError: If instance is not a string + """ + if not isinstance(instance, str): + raise ValueError("Instance name must be a string") + +def validate_keys(keys): + """ + Validate configuration keys and values. + + Args: + keys (dict): Dictionary of configuration keys and values to validate + + Raises: + ValueError: If keys is not a dictionary or if any key/value is invalid + """ + if not isinstance(keys, dict): + raise ValueError("Keys must be a dictionary") + + for key, value in keys.items(): + if not isinstance(key, str): + raise ValueError(f"Invalid key '{key}': must be a string") + if not isinstance(value, (str, int, float, bool)): + raise ValueError( + f"Invalid value type for key '{key}': must be string, number, or boolean" + ) def get_file_path(role): + """ + Get the configuration file path for a role. + + Args: + role (str): Name of the role + + Returns: + str: Full path to the configuration file + + Raises: + ValueError: If role is not a string + """ + if not isinstance(role, str): + raise ValueError("Role name must be a string") return f"/opt/saltbox/{role}.ini" +def atomic_write(file_path, content, mode, owner, group): + """ + Write content to file atomically with proper permissions. + + Args: + file_path (str): Path to the target file + content (str): Content to write to the file + mode (int): File permissions mode in octal + owner (str): Username of the file owner + group (str): Group name for the file + + Raises: + OSError: If file operations fail + IOError: If file operations fail + """ + directory = os.path.dirname(file_path) + os.makedirs(directory, exist_ok=True) + + temp_fd, temp_path = tempfile.mkstemp(dir=directory) + try: + with os.fdopen(temp_fd, 'w') as temp_file: + temp_file.write(content) + + os.chmod(temp_path, mode) + os.chown(temp_path, + pwd.getpwnam(owner).pw_uid, + grp.getgrnam(group).gr_gid) + + shutil.move(temp_path, file_path) + except Exception: + if os.path.exists(temp_path): + os.unlink(temp_path) + raise + def load_and_save_facts(file_path, instance, keys, owner, group, mode): - config = configparser.ConfigParser() - config.read(file_path) - - facts = {} - changed = False - if not config.has_section(instance): - config.add_section(instance) - changed = True - - for key, default_value in keys.items(): - if config.has_option(instance, key): - facts[key] = config[instance].get(key) - else: - facts[key] = default_value - config.set(instance, key, default_value) - changed = True + """ + Load and save facts to configuration file. - if changed: - # Ensure directory exists - os.makedirs(os.path.dirname(file_path), exist_ok=True) + Args: + file_path (str): Path to the configuration file + instance (str): Name of the instance + keys (dict): Dictionary of keys and their default values + owner (str): Username of the file owner + group (str): Group name for the file + mode (int): File permissions mode in octal + + Returns: + tuple: (dict of facts, bool indicating if changes were made) + + Raises: + Exception: With detailed error message for various failure scenarios + """ + try: + validate_instance_name(instance) + validate_keys(keys) + + config = configparser.ConfigParser( + interpolation=None, + comment_prefixes=('#',), + inline_comment_prefixes=('#',), + default_section='DEFAULT', + delimiters=('=',), + empty_lines_in_values=False + ) - with open(file_path, 'w') as configfile: - config.write(configfile) + config.optionxform = str - os.chmod(file_path, mode) - os.chown(file_path, pwd.getpwnam(owner).pw_uid, grp.getgrnam(group).gr_gid) + if os.path.exists(file_path): + config.read(file_path) - return facts, changed + facts = {} + changed = False + + if not config.has_section(instance): + config.add_section(instance) + changed = True -def delete_facts(file_path, delete_type, instance, keys): - config = configparser.ConfigParser() - config.read(file_path) - changed = False - - if delete_type == 'role' and os.path.exists(file_path): - os.remove(file_path) - changed = True - elif delete_type == 'instance' and config.has_section(instance): - config.remove_section(instance) - changed = True - elif delete_type == 'key' and config.has_section(instance): - for key in keys: + for key, default_value in keys.items(): if config.has_option(instance, key): - config.remove_option(instance, key) + facts[key] = config[instance].get(key) + else: + facts[key] = str(default_value) + config.set(instance, key, str(default_value)) changed = True - if changed and delete_type != 'role': - with open(file_path, 'w') as configfile: - config.write(configfile) + if changed: + with tempfile.StringIO() as string_buffer: + config.write(string_buffer) + config_str = string_buffer.getvalue() + + atomic_write(file_path, config_str, mode, owner, group) - return changed + return facts, changed + + except (OSError, IOError) as e: + raise Exception(f"File operation error: {str(e)}") + except configparser.Error as e: + raise Exception(f"Configuration parsing error: {str(e)}") + except Exception as e: + raise Exception(f"Unexpected error: {str(e)}") + +def delete_facts(file_path, delete_type, instance, keys): + """ + Delete facts from configuration file. + + Args: + file_path (str): Path to the configuration file + delete_type (str): Type of deletion ('role', 'instance', or 'key') + instance (str): Name of the instance + keys (dict): Dictionary of keys to delete (used only for delete_type='key') + + Returns: + bool: True if changes were made, False otherwise + + Raises: + Exception: With detailed error message for various failure scenarios + """ + try: + if delete_type == 'role': + if os.path.exists(file_path): + os.remove(file_path) + return True + return False + + if not os.path.exists(file_path): + return False + + config = configparser.ConfigParser(interpolation=None) + config.optionxform = str + config.read(file_path) + changed = False + + if delete_type == 'instance': + if config.has_section(instance): + config.remove_section(instance) + changed = True + elif delete_type == 'key' and config.has_section(instance): + for key in keys: + if config.has_option(instance, key): + config.remove_option(instance, key) + changed = True + + if changed: + with tempfile.StringIO() as string_buffer: + config.write(string_buffer) + config_str = string_buffer.getvalue() + + stat = os.stat(file_path) + atomic_write(file_path, config_str, stat.st_mode, + pwd.getpwuid(stat.st_uid).pw_name, + grp.getgrgid(stat.st_gid).gr_name) + + return changed + + except (OSError, IOError) as e: + raise Exception(f"File operation error: {str(e)}") + except configparser.Error as e: + raise Exception(f"Configuration parsing error: {str(e)}") + except Exception as e: + raise Exception(f"Unexpected error: {str(e)}") def parse_mode(mode): + """ + Parse and validate file mode. + + Args: + mode (str): File mode in octal string format (e.g., '0644') + + Returns: + int: Parsed mode as integer + + Raises: + ValueError: If mode is invalid or improperly formatted + """ if not isinstance(mode, str): raise ValueError("Mode must be a quoted string to comply with YAML best practices.") mode = mode.strip() @@ -75,9 +278,32 @@ def parse_mode(mode): raise ValueError("Mode must be a quoted octal number starting with '0' (e.g., '0644').") def get_current_user(): + """ + Get current user name. + + Returns: + str: Name of the current user + """ return pwd.getpwuid(os.getuid()).pw_name def run_module(): + """ + Main module execution. + + This function handles the module's argument parsing, execution flow, + and return value preparation. It uses AnsibleModule for proper Ansible + integration. + + The function processes the following parameters: + - role (str): The role name (required) + - instance (str): The instance name (required) + - method (str): Operation to perform ('load', 'save', 'delete') (default: 'save') + - keys (dict): Configuration keys and values (default: {}) + - delete_type (str): Type of deletion ('role', 'instance', 'key') + - owner (str): File owner (default: current user) + - group (str): File group (default: current user) + - mode (str): File mode in octal string format (default: '0644') + """ module_args = dict( role=dict(type='str', required=True), instance=dict(type='str', required=True), @@ -92,7 +318,8 @@ def run_module(): result = dict( changed=False, message='', - facts={} + facts={}, + warnings=[] ) module = AnsibleModule( @@ -100,34 +327,38 @@ def run_module(): supports_check_mode=True ) - role = module.params['role'] - instance = module.params['instance'] - method = module.params['method'] - keys = module.params['keys'] - delete_type = module.params.get('delete_type') - - # Use current user as default if owner/group not specified - current_user = get_current_user() - owner = module.params.get('owner') or current_user - group = module.params.get('group') or current_user - try: - mode = parse_mode(module.params['mode']) - except ValueError as e: - module.fail_json(msg=str(e)) + role = module.params['role'] + instance = module.params['instance'] + method = module.params['method'] + keys = module.params['keys'] + delete_type = module.params.get('delete_type') + + current_user = get_current_user() + owner = module.params.get('owner') or current_user + group = module.params.get('group') or current_user - file_path = get_file_path(role) + mode = parse_mode(module.params['mode']) + file_path = get_file_path(role) - if method == 'delete': - if not delete_type: - module.fail_json(msg="delete_type is required for delete method.") - result['changed'] = delete_facts(file_path, delete_type, instance, keys) - else: - result['facts'], result['changed'] = load_and_save_facts(file_path, instance, keys, owner, group, mode) + if method == 'delete': + if not delete_type: + module.fail_json(msg="delete_type is required for delete method.") + result['changed'] = delete_facts(file_path, delete_type, instance, keys) + else: + result['facts'], result['changed'] = load_and_save_facts( + file_path, instance, keys, owner, group, mode + ) - module.exit_json(**result) + module.exit_json(**result) + + except Exception as e: + module.fail_json(msg=str(e)) def main(): + """ + Module entry point. + """ run_module() if __name__ == '__main__':