diff --git a/plugins/module_utils/ansible_freeipa_module.py b/plugins/module_utils/ansible_freeipa_module.py index b8df38d4f6bc6fe21dc4af8bedab93cd728cbdd2..9c10135f4c86a842e098f23393a0adbbcbd27b97 100644 --- a/plugins/module_utils/ansible_freeipa_module.py +++ b/plugins/module_utils/ansible_freeipa_module.py @@ -2,6 +2,7 @@ # -*- coding: utf-8 -*- # Authors: +# Sergio Oliveira Campos <seocam@redhat.com> # Thomas Woerner <twoerner@redhat.com> # # Copyright (C) 2019 Red Hat @@ -27,10 +28,12 @@ import tempfile import shutil import gssapi from datetime import datetime +from pprint import pformat from ipalib import api -from ipalib import errors as ipalib_errors +from ipalib import errors as ipalib_errors # noqa from ipalib.config import Env from ipalib.constants import DEFAULT_CONFIG, LDAP_GENERALIZED_TIME_FORMAT + try: from ipalib.install.kinit import kinit_password, kinit_keytab except ImportError: @@ -38,7 +41,9 @@ except ImportError: from ipapython.ipautil import run from ipaplatform.paths import paths from ipalib.krb_utils import get_credentials_if_valid +from ansible.module_utils.basic import AnsibleModule from ansible.module_utils._text import to_text + try: from ipalib.x509 import Encoding except ImportError: @@ -52,7 +57,7 @@ if six.PY3: unicode = str -def valid_creds(module, principal): +def valid_creds(module, principal): # noqa """ Get valid credintials matching the princial, try GSSAPI first """ @@ -205,9 +210,24 @@ def date_format(value): raise ValueError("Invalid date '%s'" % value) -def compare_args_ipa(module, args, ipa): +def compare_args_ipa(module, args, ipa): # noqa + """Compare IPA obj attrs with the command args. + + This function compares IPA objects attributes with the args the + module is intending to use to call a command. This is useful to know + if call to IPA server will be needed or not. + In other to compare we have to prepare the perform slight changes in + data formats. + + Returns True if they are the same and False otherwise. + """ + base_debug_msg = "Ansible arguments and IPA commands differed. " + for key in args.keys(): if key not in ipa: + module.debug( + base_debug_msg + "Command key not present in IPA: %s" % key + ) return False else: arg = args[key] @@ -220,25 +240,35 @@ def compare_args_ipa(module, args, ipa): if isinstance(ipa_arg, list): if not isinstance(arg, list): arg = [arg] + if len(ipa_arg) != len(arg): + module.debug( + base_debug_msg + + "List length doesn't match for key %s: %d %d" + % (key, len(arg), len(ipa_arg),) + ) + return False if isinstance(ipa_arg[0], str) and isinstance(arg[0], int): arg = [to_text(_arg) for _arg in arg] if isinstance(ipa_arg[0], unicode) and isinstance(arg[0], int): arg = [to_text(_arg) for _arg in arg] - # module.warn("%s <=> %s" % (repr(arg), repr(ipa_arg))) try: arg_set = set(arg) ipa_arg_set = set(ipa_arg) except TypeError: if arg != ipa_arg: - # module.warn("%s != %s" % (repr(arg), repr(ipa_arg))) + module.debug( + base_debug_msg + + "Different values: %s %s" % (arg, ipa_arg) + ) return False else: if arg_set != ipa_arg_set: - # module.warn("%s != %s" % (repr(arg), repr(ipa_arg))) + module.debug( + base_debug_msg + + "Different set content: %s %s" + % (arg_set, ipa_arg_set,) + ) return False - - # module.warn("%s == %s" % (repr(arg), repr(ipa_arg))) - return True @@ -289,6 +319,16 @@ def encode_certificate(cert): return encoded +def is_valid_port(port): + if not isinstance(port, int): + return False + + if 1 <= port <= 65535: + return True + + return False + + def is_ipv4_addr(ipaddr): """ Test if figen IP address is a valid IPv4 address @@ -309,3 +349,294 @@ def is_ipv6_addr(ipaddr): except socket.error: return False return True + + +class AnsibleFreeIPAParams(dict): + def __init__(self, ansible_module): + self.update(ansible_module.params) + self.ansible_module = ansible_module + + @property + def names(self): + return self.name + + def __getattr__(self, name): + param = self.get(name) + if param is not None: + return _afm_convert(param) + + +class FreeIPABaseModule(AnsibleModule): + """ + Base class for FreeIPA Ansible modules. + + Provides methods useful methods to be used by our modules. + + This class should be overriten and instantiated for the module. + A basic implementation of an Ansible FreeIPA module expects its + class to: + + 1. Define a class attribute ``ipa_param_mapping`` + 2. Implement the method ``define_ipa_commands()`` + 3. Implement the method ``check_ipa_params()`` (optional) + + After instantiating the class the method ``ipa_run()`` should be called. + + Example (ansible-freeipa/plugins/modules/ipasomemodule.py): + + 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() + + """ + + ipa_param_mapping = None + + def __init__(self, *args, **kwargs): + super(FreeIPABaseModule, self).__init__(*args, **kwargs) + + # Attributes to store kerberos credentials (if needed) + self.ccache_dir = None + self.ccache_name = None + + # Status of an execution. Will be changed to True + # if something is actually peformed. + self.changed = False + + # Status of the connection with the IPA server. + # We need to know if the connection was actually stablished + # before we start sending commands. + self.ipa_connected = False + + # Commands to be executed + self.ipa_commands = [] + + # 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): + """ + Return a dict to be passed to an IPA command. + + The keys of ``ipa_param_mapping`` are also the keys of the return dict. + + The values of ``ipa_param_mapping`` needs to be either: + * A str with the name of a defined method; or + * A key of ``AnsibleModule.param``. + + In case of a method the return of the method will be set as value + for the return dict. + + In case of a AnsibleModule.param the value of the param will be + set in the return dict. In addition to that boolean values will be + automaticaly converted to uppercase strings (as required by FreeIPA + 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() + + # 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 + + def check_ipa_params(self): + """Validate ipa_params before command is called.""" + pass + + def define_ipa_commands(self): + """Define commands that will be run in IPA server.""" + raise NotImplementedError + + def api_command(self, command, name=None, args=None): + """Execute a single command in IPA server.""" + if args is None: + args = {} + + if name is None: + return api_command_no_name(self, command, args) + + return api_command(self, command, name, args) + + def __enter__(self): + """ + Connect to IPA server. + + Check the there are working Kerberos credentials to connect to + IPA server. If there are not we perform a temporary kinit + that will be terminated when exiting the context. + + If the connection fails ``ipa_connected`` attribute will be set + to False. + """ + principal = self.ipa_params.ipaadmin_principal + password = self.ipa_params.ipaadmin_password + + try: + if not valid_creds(self, principal): + self.ccache_dir, self.ccache_name = temp_kinit( + principal, password, + ) + + api_connect() + + except Exception as excpt: + self.fail_json(msg=str(excpt)) + else: + self.ipa_connected = True + + return self + + def __exit__(self, exc_type, exc_val, exc_tb): + """ + Terminate a connection with the IPA server. + + Deal with exceptions, destroy temporary kinit credentials and + exit the module with proper arguments. + + """ + if exc_val: + self.fail_json(msg=str(exc_val)) + + # TODO: shouldn't we also disconnect from api backend? + temp_kdestroy(self.ccache_dir, self.ccache_name) + + self.exit_json(changed=self.changed, user=self.exit_args) + + 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")) + + 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.""" + result = None + + for name, command, args in self.ipa_commands: + try: + result = self.api_command(command, name, args) + except Exception as excpt: + self.fail_json(msg="%s: %s: %s" % (command, name, str(excpt))) + else: + if "completed" in result: + if result["completed"] > 0: + self.changed = True + else: + self.changed = True + + self.get_command_errors(command, result) + + def require_ipa_attrs_change(self, command_args, ipa_attrs): + """ + Compare given args with current object attributes. + + Returns True in case current IPA object attributes differ from + args passed to the module. + """ + equal = compare_args_ipa(self, command_args, ipa_attrs) + return not equal + + def pdebug(self, value): + """Debug with pretty formatting.""" + self.debug(pformat(value)) + + def ipa_run(self): + """Execute module actions.""" + with self: + if not self.ipa_connected: + return + + self.check_ipa_params() + self.define_ipa_commands() + self._run_ipa_commands()