diff --git a/plugins/module_utils/ansible_freeipa_module.py b/plugins/module_utils/ansible_freeipa_module.py index c3242bdccb2389f80523002ef1319fc86e14d9bc..ba2544f2d8c86b12f8ede004adbe9fc73df0aeb3 100644 --- a/plugins/module_utils/ansible_freeipa_module.py +++ b/plugins/module_utils/ansible_freeipa_module.py @@ -566,10 +566,76 @@ else: print(jsonify(kwargs)) sys.exit(0) - class AnsibleFreeIPAParams(Mapping): - def __init__(self, ansible_module): + class IPAParamMapping(Mapping): + """ + Provides IPA API mapping to playbook parameters or computed values. + + It can be used to define a mapping of playbook parameters + or methods that provide computed values to IPA API arguments. + + Playbook parameters can be retrieved as properties, + and the set of IPA arguments for a command can be + retrived with ``get_ipa_command_args()``. The keys for + ``param_mapping`` are also the keys of the argument set. + + The values of ``param_mapping`` can be either: + * a str representing a key of ``AnsibleModule.params``. + * a callable. + + In case of an ``AnsibleModule.param`` the value of the playbook + param will be used for that argument. If it is a ``callable``, + the value returned by the execution of it will be used. + + Example: + ------- + def check_params(ipa_params): + # Module parameters can be accessed as properties. + if len(ipa_params.name) == 0: + ipa_params.ansible_module.fail_json(msg="No given name.") + + + def define_ipa_commands(self): + # Create the argument dict from the defined mapping. + args = self.get_ipa_command_args() + + _commands = [("obj-name", "some_ipa_command", args)] + return _commands + + + def a_method_for_a_computed_param(): + return "Some computed value" + + + def main(): + ansible_module = SomeIPAModule(argument_spec=dict( + name=dict(type="list", aliases=["cn"], required=True), + state=dict(type="str", default="present", + choices=["present", "absent"]), + module_param=(type="str", required=False), + ) + ) + + # Define the playbook to IPA API mapping + ipa_param_mapping = { + "arg_to_be_passed_to_ipa_command": "module_param", + "another_arg": a_method_for_a_computed_param, + } + ipa_params = IPAParamMapping( + ansible_module, + param_mapping=ipa_param_mapping + ) + + check_params(ipa_params) + comands = define_ipa_commands(ipa_params) + + ansible_module.execute_ipa_commands(commands) + + """ + + def __init__(self, ansible_module, param_mapping=None): self.mapping = ansible_module.params self.ansible_module = ansible_module + self.param_mapping = param_mapping or {} def __getitem__(self, key): param = self.mapping[key] @@ -590,6 +656,36 @@ else: def __getattr__(self, name): return self.get(name) + def get_ipa_command_args(self, **kwargs): + """Return a dict to be passed to an IPA command.""" + args = {} + for ipa_param_name, param_name in self.param_mapping.items(): + + # Check if param_name is actually a param + if param_name in self.ansible_module.params: + value = self.ansible_module.params_get(param_name) + if isinstance(value, bool): + value = "TRUE" if value else "FALSE" + + # Since param wasn't a param check if it's a method name + elif callable(param_name): + value = param_name(**kwargs) + + # We don't have a way to guess the value so fail. + else: + self.ansible_module.fail_json( + msg=( + "Couldn't get a value for '%s'. Option '%s' is " + "not a module argument neither a defined method." + ) + % (ipa_param_name, param_name) + ) + + if value is not None: + args[ipa_param_name] = value + + return args + class IPAAnsibleModule(AnsibleModule): """ IPA Ansible Module. @@ -1034,6 +1130,10 @@ else: # pylint: disable=super-with-arguments super(FreeIPABaseModule, self).__init__(*args, **kwargs) + self.deprecate( + msg="FreeIPABaseModule is deprecated. Use IPAAnsibleModule.", + ) + # Status of an execution. Will be changed to True # if something is actually peformed. self.changed = False @@ -1049,11 +1149,6 @@ else: # Module exit arguments. self.exit_args = {} - # Wrapper around the AnsibleModule.params. - # Return the actual params but performing transformations - # when needed. - self.ipa_params = AnsibleFreeIPAParams(self) - def get_ipa_command_args(self, **kwargs): """ Return a dict to be passed to an IPA command. @@ -1074,97 +1169,73 @@ else: server). """ - args = {} - for ipa_param_name, param_name in self.ipa_param_mapping.items(): - - # Check if param_name is actually a param - if param_name in self.ipa_params: - value = self.ipa_params.get(param_name) - if isinstance(value, bool): - value = "TRUE" if value else "FALSE" - - # Since param wasn't a param check if it's a method name - elif hasattr(self, param_name): - method = getattr(self, param_name) - if callable(method): - value = method(**kwargs) - - # We don't have a way to guess the value so fail. - else: - self.fail_json( - msg=( - "Couldn't get a value for '%s'. Option '%s' is " - "not a module argument neither a defined method." - ) - % (ipa_param_name, param_name) - ) - - if value is not None: - args[ipa_param_name] = value - - return args + self.deprecate( + msg=( + "FreeIPABaseModule is deprecated. Use IPAAnsibleModule. " + "Use 'AnsibleFreeIPAParams.get_ipa_command_args()', " + "Instantiate it using the class 'ipa_params_mapping'." + ) + ) + mapping = IPAParamMapping(self, self.ipa_param_mapping) + return mapping.get_ipa_command_args(**kwargs) def check_ipa_params(self): """Validate ipa_params before command is called.""" + self.deprecate( + msg=( + "FreeIPABaseModule is deprecated. Use IPAAnsibleModule. " + ), + ) pass # pylint: disable=unnecessary-pass def define_ipa_commands(self): """Define commands that will be run in IPA server.""" raise NotImplementedError - def get_command_errors(self, command, result): - """Look for erros into command results.""" - # Get all errors - # All "already a member" and "not a member" failures in the - # result are ignored. All others are reported. - errors = [] - for item in result.get("failed", tuple()): - failed_item = result["failed"][item] - for member_type in failed_item: - for member, failure in failed_item[member_type]: - if ( - "already a member" in failure - or "not a member" in failure - ): - continue - errors.append( - "%s: %s %s: %s" - % (command, member_type, member, failure) - ) - - if len(errors) > 0: - self.fail_json(", ".join("errors")) # pylint: disable=E1121 - def add_ipa_command(self, command, name=None, args=None): """Add a command to the list of commands to be executed.""" self.ipa_commands.append((name, command, args or {})) def _run_ipa_commands(self): """Execute commands in self.ipa_commands.""" - if self.check_mode: - self.changed = len(self.ipa_commands) > 0 - return + self.changed = self.execute_ipa_commands( + self.ipa_commands, + result_handler=self.process_results.__func__, + exit_args=self.exit_args + ) - result = None + def process_results( + self, result, command, name, args, exit_args + ): # pylint: disable=unused-argument + """ + Process an API command result. - for name, command, args in self.ipa_commands: - try: - result = self.ipa_command(command, name, args) - except Exception as excpt: - self.fail_json(msg="%s: %s: %s" % (command, name, - str(excpt))) - else: - self.process_command_result(name, command, args, result) - self.get_command_errors(command, result) + This method must be overriden in subclasses if 'exit_args' + is to be modified. + """ + self.deprecate( + msg=( + "FreeIPABaseModule is deprecated. Use IPAAnsibleModule. " + ), + ) + self.process_command_result(name, command, args, result) def process_command_result(self, _name, _command, _args, result): """ Process an API command result. This method can be overriden in subclasses, and - change self.exit_values - to return data in the result for the controller. + change self.exit_values to return data in the + result for the controller. """ + self.deprecate( + msg=( + "FreeIPABaseModule is deprecated. Use IPAAnsibleModule. " + "To aid in porting to IPAAnsibleModule, change to " + "'FreeIPABaseModule.process_results'." + ), + ) + if "completed" in result: if result["completed"] > 0: self.changed = True @@ -1178,12 +1249,24 @@ else: Returns True in case current IPA object attributes differ from args passed to the module. """ + self.deprecate( + msg=( + "FreeIPABaseModule is deprecated. Use IPAAnsibleModule. " + "FreeIPABaseModule require_ipa_attrs_change() is " + "deprecated. Use ansible_freeipa_module.compare_args()." + ), + ) equal = compare_args_ipa(self, command_args, ipa_attrs) return not equal def ipa_run(self): """Execute module actions.""" - ipaapi_context = self.ipa_params.get("ipaapi_context") + self.deprecate( + msg=( + "FreeIPABaseModule is deprecated. Use IPAAnsibleModule." + ), + ) + ipaapi_context = self.params_get("ipaapi_context") with self.ipa_connect(context=ipaapi_context): self.check_ipa_params() self.define_ipa_commands() diff --git a/plugins/modules/README.md b/plugins/modules/README.md index 4e06fe10f514ef97634d530b58e789f7b2bfa0fd..30f41f1842d247e2a38da247e2bf3590c1818363 100644 --- a/plugins/modules/README.md +++ b/plugins/modules/README.md @@ -1,6 +1,5 @@ # Writing a new Ansible FreeIPA module -## Minimum requirements A ansible-freeipa module should have: * Code: @@ -13,68 +12,4 @@ A ansible-freeipa module should have: * Tests: * Test cases (also playbooks) defined in `tests/<module_name>/test_<something>.yml`. It's ok to have multiple files in this directory. -## Code - -The module file have to start with the python shebang line, license header and definition of the constants `ANSIBLE_METADATA`, `DOCUMENTATION`, `EXAMPLES` and `RETURNS`. Those constants need to be defined before the code (even imports). See https://docs.ansible.com/ansible/latest/dev_guide/developing_modules_general.html#starting-a-new-module for more information. - - -Although it's use is not yet required, ansible-freeipa provides `FreeIPABaseModule` as a helper class for the implementation of new modules. See the example bellow: - -```python - -from ansible.module_utils.ansible_freeipa_module import FreeIPABaseModule - - -class SomeIPAModule(FreeIPABaseModule): - ipa_param_mapping = { - "arg_to_be_passed_to_ipa_command": "module_param", - "another_arg": "get_another_module_param", - } - - def get_another_module_param(self): - another_module_param = self.ipa_params.another_module_param - - # Validate or modify another_module_param ... - - return another_module_param - - def check_ipa_params(self): - - # Validate your params here ... - - # Example: - if not self.ipa_params.module_param in VALID_OPTIONS: - self.fail_json(msg="Invalid value for argument module_param") - - def define_ipa_commands(self): - args = self.get_ipa_command_args() - - self.add_ipa_command("some_ipa_command", name="obj-name", args=args) - - -def main(): - ipa_module = SomeIPAModule(argument_spec=dict( - module_param=dict(type="str", default=None, required=False), - another_module_param=dict(type="str", default=None, required=False), - )) - ipa_module.ipa_run() - - -if __name__ == "__main__": - main() -``` - -In the example above, the module will call the command `some_ipa_command`, using "obj-name" as name and, `arg_to_be_passed_to_ipa_command` and `another_arg` as arguments. - -The values of the arguments will be determined by the class attribute `ipa_param_mapping`. - -In the case of `arg_to_be_passed_to_ipa_command` the key (`module_param`) is defined in the module `argument_specs` so the value of the argument is actually used. - -On the other hand, `another_arg` as mapped to something else: a callable method. In this case the method will be called and it's result used as value for `another_arg`. - -**NOTE**: Keep mind that to take advantage of the parameters mapping defined in `ipa_param_mapping` you will have to call `args = self.get_ipa_command_args()` and use `args` in your command. There is no implicit call of this method. - - -## Disclaimer - -The `FreeIPABaseModule` is new and might not be suitable to all cases and every module yet. In case you need to extend it's functionality for a new module please open an issue or PR and we'll be happy to discuss it. +Use the script `utils/new_module` to create the stub files for a new module. diff --git a/plugins/modules/ipaautomountlocation.py b/plugins/modules/ipaautomountlocation.py index 8f0b77b7d91f5d873d71bc9e2a2db3fd2cb9d546..15c731ca0f61174800ce60da6f5a396731cadef0 100644 --- a/plugins/modules/ipaautomountlocation.py +++ b/plugins/modules/ipaautomountlocation.py @@ -68,13 +68,16 @@ RETURN = ''' ''' from ansible.module_utils.ansible_freeipa_module import ( - FreeIPABaseModule, ipalib_errors + IPAAnsibleModule, ipalib_errors ) -class AutomountLocation(FreeIPABaseModule): +class AutomountLocation(IPAAnsibleModule): - ipa_param_mapping = {} + def __init__(self, *args, **kwargs): + # pylint: disable=super-with-arguments + super(AutomountLocation, self).__init__(*args, **kwargs) + self.commands = [] def get_location(self, location): try: @@ -87,40 +90,28 @@ class AutomountLocation(FreeIPABaseModule): return response.get("result", None) def check_ipa_params(self): - if len(self.ipa_params.name) == 0: + if len(self.params_get("name")) == 0: self.fail_json(msg="At least one location must be provided.") def define_ipa_commands(self): + state = self.params_get("state") - for location_name in self.ipa_params.name: + for location_name in self.params_get("name"): location = self.get_location(location_name) - if not location and self.ipa_params.state == "present": + if not location and state == "present": # does not exist and is wanted - self.add_ipa_command( - "automountlocation_add", - name=location_name, - args=None, - ) - elif location and self.ipa_params.state == "absent": + self.commands.append( + (location_name, "automountlocation_add", {})) + elif location and state == "absent": # exists and is not wanted - self.add_ipa_command( - "automountlocation_del", - name=location_name, - args=None, - ) + self.commands.append( + (location_name, "automountlocation_del", {})) def main(): ipa_module = AutomountLocation( argument_spec=dict( - ipaadmin_principal=dict(type="str", - default="admin" - ), - ipaadmin_password=dict(type="str", - required=False, - no_log=True - ), state=dict(type='str', default='present', choices=['present', 'absent'] @@ -132,7 +123,12 @@ def main(): ), ), ) - ipa_module.ipa_run() + ipaapi_context = ipa_module.params_get("ipaapi_context") + with ipa_module.ipa_connect(context=ipaapi_context): + ipa_module.check_ipa_params() + ipa_module.define_ipa_commands() + changed = ipa_module.execute_ipa_commands(ipa_module.commands) + ipa_module.exit_json(changed=changed) if __name__ == "__main__": diff --git a/plugins/modules/ipadnszone.py b/plugins/modules/ipadnszone.py index f4dbc8eeca5d2683518fff4ce03cb2cc98923232..38fdcefa46a4a09f7e70fc224008b8b202011156 100644 --- a/plugins/modules/ipadnszone.py +++ b/plugins/modules/ipadnszone.py @@ -203,11 +203,13 @@ dnszone: from ipapython.dnsutil import DNSName # noqa: E402 from ansible.module_utils.ansible_freeipa_module import ( - FreeIPABaseModule, + IPAAnsibleModule, is_ip_address, is_ip_network_address, is_valid_port, - ipalib_errors + ipalib_errors, + compare_args_ipa, + IPAParamMapping, ) # noqa: E402 import netaddr from ansible.module_utils import six @@ -217,31 +219,39 @@ if six.PY3: unicode = str -class DNSZoneModule(FreeIPABaseModule): - - ipa_param_mapping = { - # Direct Mapping - "idnsforwardpolicy": "forward_policy", - "idnssoarefresh": "refresh", - "idnssoaretry": "retry", - "idnssoaexpire": "expire", - "idnssoaminimum": "minimum", - "dnsttl": "ttl", - "dnsdefaultttl": "default_ttl", - "idnsallowsyncptr": "allow_sync_ptr", - "idnsallowdynupdate": "dynamic_update", - "idnssecinlinesigning": "dnssec", - "idnsupdatepolicy": "update_policy", - # Mapping by method - "idnsforwarders": "get_ipa_idnsforwarders", - "idnsallowtransfer": "get_ipa_idnsallowtransfer", - "idnsallowquery": "get_ipa_idnsallowquery", - "idnssoamname": "get_ipa_idnssoamname", - "idnssoarname": "get_ipa_idnssoarname", - "skip_nameserver_check": "get_ipa_skip_nameserver_check", - "skip_overlap_check": "get_ipa_skip_overlap_check", - "nsec3paramrecord": "get_ipa_nsec3paramrecord", - } +class DNSZoneModule(IPAAnsibleModule): + + def __init__(self, *args, **kwargs): + # pylint: disable=super-with-arguments + super(DNSZoneModule, self).__init__(*args, **kwargs) + + ipa_param_mapping = { + # Direct Mapping + "idnsforwardpolicy": "forward_policy", + "idnssoarefresh": "refresh", + "idnssoaretry": "retry", + "idnssoaexpire": "expire", + "idnssoaminimum": "minimum", + "dnsttl": "ttl", + "dnsdefaultttl": "default_ttl", + "idnsallowsyncptr": "allow_sync_ptr", + "idnsallowdynupdate": "dynamic_update", + "idnssecinlinesigning": "dnssec", + "idnsupdatepolicy": "update_policy", + # Mapping by method + "idnsforwarders": self.get_ipa_idnsforwarders, + "idnsallowtransfer": self.get_ipa_idnsallowtransfer, + "idnsallowquery": self.get_ipa_idnsallowquery, + "idnssoamname": self.get_ipa_idnssoamname, + "idnssoarname": self.get_ipa_idnssoarname, + "skip_nameserver_check": self.get_ipa_skip_nameserver_check, + "skip_overlap_check": self.get_ipa_skip_overlap_check, + "nsec3paramrecord": self.get_ipa_nsec3paramrecord, + } + + self.commands = [] + self.ipa_params = IPAParamMapping(self, ipa_param_mapping) + self.exit_args = {} def validate_ips(self, ips, error_msg): invalid_ips = [ @@ -441,39 +451,34 @@ class DNSZoneModule(FreeIPABaseModule): for zone_name in self.get_zone_names(): # Look for existing zone in IPA zone, is_zone_active = self.get_zone(zone_name) - args = self.get_ipa_command_args(zone=zone) + args = self.ipa_params.get_ipa_command_args(zone=zone) if self.ipa_params.state in ["present", "enabled", "disabled"]: if not zone: # Since the zone doesn't exist we just create it # with given args - self.add_ipa_command("dnszone_add", zone_name, args) + self.commands.append((zone_name, "dnszone_add", args)) is_zone_active = True # just_added = True else: # Zone already exist so we need to verify if given args # matches the current config. If not we updated it. - if self.require_ipa_attrs_change(args, zone): - self.add_ipa_command("dnszone_mod", zone_name, args) + if not compare_args_ipa(self, args, zone): + self.commands.append((zone_name, "dnszone_mod", args)) if self.ipa_params.state == "enabled" and not is_zone_active: - self.add_ipa_command("dnszone_enable", zone_name) + self.commands.append((zone_name, "dnszone_enable", {})) if self.ipa_params.state == "disabled" and is_zone_active: - self.add_ipa_command("dnszone_disable", zone_name) + self.commands.append((zone_name, "dnszone_disable", {})) if self.ipa_params.state == "absent" and zone is not None: - self.add_ipa_command("dnszone_del", zone_name) + self.commands.append((zone_name, "dnszone_del", {})) - def process_command_result(self, name, command, args, result): - # pylint: disable=super-with-arguments - super(DNSZoneModule, self).process_command_result( - name, command, args, result - ) + def process_results(self, _result, command, name, _args, exit_args): if command == "dnszone_add" and self.ipa_params.name_from_ip: - dnszone_exit_args = self.exit_args.setdefault('dnszone', {}) - dnszone_exit_args['name'] = name + exit_args.setdefault('dnszone', {})["name"] = name def get_argument_spec(): @@ -532,12 +537,24 @@ def get_argument_spec(): def main(): - DNSZoneModule( + ansible_module = DNSZoneModule( argument_spec=get_argument_spec(), mutually_exclusive=[["name", "name_from_ip"]], required_one_of=[["name", "name_from_ip"]], supports_check_mode=True, - ).ipa_run() + ) + + exit_args = {} + ipaapi_context = ansible_module.params_get("ipaapi_context") + with ansible_module.ipa_connect(context=ipaapi_context): + ansible_module.check_ipa_params() + ansible_module.define_ipa_commands() + changed = ansible_module.execute_ipa_commands( + ansible_module.commands, + result_handler=DNSZoneModule.process_results, + exit_args=exit_args + ) + ansible_module.exit_json(changed=changed, **exit_args) if __name__ == "__main__":