From 0e0660a3d9fb2447f1463789a460e330eb116145 Mon Sep 17 00:00:00 2001 From: Chris Houseknecht Date: Mon, 10 Apr 2017 14:39:13 -0400 Subject: [PATCH] Adds custom module_utils --- module_utils/README.md | 7 + module_utils/k8s_common.py | 297 +++++++++++++++++++++++++++++++ module_utils/openshift_common.py | 70 ++++++++ 3 files changed, 374 insertions(+) create mode 100644 module_utils/README.md create mode 100644 module_utils/k8s_common.py create mode 100644 module_utils/openshift_common.py diff --git a/module_utils/README.md b/module_utils/README.md new file mode 100644 index 00000000..d75bf7de --- /dev/null +++ b/module_utils/README.md @@ -0,0 +1,7 @@ +### module_utils + +If you're running Ansible 2.3, custom module_utils is supported. + +The K8s and OpenShift common modules will land in Ansible 2.4, so for now we're including the latest here, just to make life a little easier for those not running Ansible from source. As indicated above, you will need to have Ansible 2.3 installed for this to work. + +If your install Ansible fails to find the common modules from this folder, then the easiest solution may be to run Ansible from source. If you need assistance, please view the [Running From Source guide](http://docs.ansible.com/ansible/intro_installation.html#running-from-source). diff --git a/module_utils/k8s_common.py b/module_utils/k8s_common.py new file mode 100644 index 00000000..c54c0468 --- /dev/null +++ b/module_utils/k8s_common.py @@ -0,0 +1,297 @@ +# +# Copyright 2017 Red Hat | Ansible +# +# This file is part of Ansible +# +# Ansible is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# Ansible is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with Ansible. If not, see . + +import copy +import json +import os + +from ansible.module_utils.basic import AnsibleModule + +try: + from openshift.helper.ansible import KubernetesAnsibleModuleHelper, ARG_ATTRIBUTES_BLACKLIST + from openshift.helper.exceptions import KubernetesException + HAS_K8S_MODULE_HELPER = True +except ImportError as exc: + HAS_K8S_MODULE_HELPER = False + +try: + import yaml + HAS_YAML = True +except ImportError: + HAS_YAML = False + + +class KubernetesAnsibleException(Exception): + pass + + +class KubernetesAnsibleModule(AnsibleModule): + @staticmethod + def get_helper(api_version, kind): + return KubernetesAnsibleModuleHelper(api_version, kind) + + def __init__(self, kind, api_version): + self.api_version = api_version + self.kind = kind + self.argspec_cache = None + + if not HAS_K8S_MODULE_HELPER: + raise KubernetesAnsibleException( + "This module requires the OpenShift Python client. Try `pip install openshift`" + ) + + if not HAS_YAML: + raise KubernetesAnsibleException( + "This module requires PyYAML. Try `pip install PyYAML`" + ) + + try: + self.helper = self.get_helper(api_version, kind) + except Exception as exc: + raise KubernetesAnsibleException( + "Error initializing AnsibleModuleHelper: {}".format(exc) + ) + + mutually_exclusive = ( + ('resource_definition', 'src'), + ) + + AnsibleModule.__init__(self, + argument_spec=self.argspec, + supports_check_mode=True, + mutually_exclusive=mutually_exclusive) + + @property + def argspec(self): + """ + Build the module argument spec from the helper.argspec, removing any extra attributes not needed by + Ansible. + + :return: dict: a valid Ansible argument spec + """ + if not self.argspec_cache: + spec = {} + for arg_name, arg_properties in self.helper.argspec.items(): + spec[arg_name] = {} + for option, option_value in arg_properties.items(): + if option not in ARG_ATTRIBUTES_BLACKLIST: + if option == 'choices': + if isinstance(option_value, dict): + spec[arg_name]['choices'] = [value for key, value in option_value.items()] + else: + spec[arg_name]['choices'] = option_value + else: + spec[arg_name][option] = option_value + + self.argspec_cache = spec + return self.argspec_cache + + def execute_module(self): + """ + Performs basic CRUD operations on the model object. Ends by calling + AnsibleModule.fail_json(), if an error is encountered, otherwise + AnsibleModule.exit_json() with a dict containing: + changed: boolean + api_version: the API version + : a dict representing the object's state + :return: None + """ + + if self.params.get('debug'): + self.helper.enable_debug(reset_logfile=False) + self.helper.log_argspec() + + resource_definition = self.params.get('resource_definition') + if self.params.get('src'): + resource_definition = self.load_resource_definition(self.params['src']) + if resource_definition: + resource_params = self.resource_to_parameters(resource_definition) + self.params.update(resource_params) + + state = self.params.get('state', None) + force = self.params.get('force', False) + name = self.params.get('name') + namespace = self.params.get('namespace', None) + existing = None + + return_attributes = dict(changed=False, api_version=self.api_version) + return_attributes[self.helper.base_model_name_snake] = {} + + try: + auth_options = {} + for key, value in self.helper.argspec.items(): + if value.get('auth_option') and self.params.get(key) is not None: + auth_options[key] = self.params[key] + self.helper.set_client_config(**auth_options) + except KubernetesException as e: + self.fail_json(msg='Error loading config', error=str(e)) + + if state is None: + # This is a list, rollback or ? module with no 'state' param + if self.helper.base_model_name_snake.endswith('list'): + # For list modules, execute a GET, and exit + k8s_obj = self._read(name, namespace) + return_attributes[self.kind] = k8s_obj.to_dict() + self.exit_json(**return_attributes) + elif self.helper.has_method('create'): + # For a rollback, execute a POST, and exit + k8s_obj = self._create(namespace) + return_attributes[self.kind] = k8s_obj.to_dict() + return_attributes['changed'] = True + self.exit_json(**return_attributes) + else: + self.fail_json(msg="Missing state parameter. Expected one of: present, absent") + + # CRUD modules + try: + existing = self.helper.get_object(name, namespace) + except KubernetesException as exc: + self.fail_json(msg='Failed to retrieve requested object: {}'.format(exc.message), + error=exc.value.get('status')) + + if state == 'absent': + if not existing: + # The object already does not exist + self.exit_json(**return_attributes) + else: + # Delete the object + if not self.check_mode: + try: + self.helper.delete_object(name, namespace) + except KubernetesException as exc: + self.fail_json(msg="Failed to delete object: {}".format(exc.message), + error=exc.value.get('status')) + return_attributes['changed'] = True + self.exit_json(**return_attributes) + else: + if not existing: + k8s_obj = self._create(namespace) + return_attributes[self.kind] = k8s_obj.to_dict() + return_attributes['changed'] = True + self.exit_json(**return_attributes) + + if existing and force: + k8s_obj = None + request_body = self.helper.request_body_from_params(self.params) + if not self.check_mode: + try: + k8s_obj = self.helper.replace_object(name, namespace, body=request_body) + except KubernetesException as exc: + self.fail_json(msg="Failed to replace object: {}".format(exc.message), + error=exc.value.get('status')) + return_attributes[self.kind] = k8s_obj.to_dict() + return_attributes['changed'] = True + self.exit_json(**return_attributes) + + # Check if existing object should be patched + k8s_obj = copy.deepcopy(existing) + try: + self.helper.object_from_params(self.params, obj=k8s_obj) + except KubernetesException as exc: + self.fail_json(msg="Failed to patch object: {}".format(exc.message)) + match, diff = self.helper.objects_match(existing, k8s_obj) + if match: + return_attributes[self.kind] = existing.to_dict() + self.exit_json(**return_attributes) + else: + self.helper.log('Existing:') + self.helper.log(json.dumps(existing.to_dict(), indent=4)) + self.helper.log('\nDifferences:') + self.helper.log(json.dumps(diff, indent=4)) + # Differences exist between the existing obj and requested params + if not self.check_mode: + try: + k8s_obj = self.helper.patch_object(name, namespace, k8s_obj) + except KubernetesException as exc: + self.fail_json(msg="Failed to patch object: {}".format(exc.message)) + return_attributes[self.kind] = k8s_obj.to_dict() + return_attributes['changed'] = True + self.exit_json(**return_attributes) + + def _create(self, namespace): + request_body = None + k8s_obj = None + try: + request_body = self.helper.request_body_from_params(self.params) + except KubernetesException as exc: + self.fail_json(msg="Failed to create object: {}".format(exc.message)) + if not self.check_mode: + try: + k8s_obj = self.helper.create_object(namespace, body=request_body) + except KubernetesException as exc: + self.fail_json(msg="Failed to create object: {}".format(exc.message), + error=exc.value.get('status')) + return k8s_obj + + def _read(self, name, namespace): + k8s_obj = None + try: + k8s_obj = self.helper.get_object(name, namespace) + except KubernetesException as exc: + self.fail_json(msg='Failed to retrieve requested object', + error=exc.value.get('status')) + return k8s_obj + + def load_resource_definition(self, src): + """ Load the requested src path """ + result = None + path = os.path.normpath(src) + self.helper.log("Reading definition from {}".format(path)) + if not os.path.exists(path): + self.fail_json(msg="Error accessing {}. Does the file exist?".format(path)) + try: + result = yaml.safe_load(open(path, 'r')) + except (IOError, yaml.YAMLError) as exc: + self.fail_json(msg="Error loading resource_definition: {}".format(exc)) + return result + + def resource_to_parameters(self, resource): + """ Converts a resource definition to module parameters """ + parameters = {} + for key, value in resource.items(): + if key in ('apiVersion', 'kind', 'status'): + continue + elif key == 'metadata' and isinstance(value, dict): + for meta_key, meta_value in value.items(): + if meta_key in ('name', 'namespace', 'labels', 'annotations'): + parameters[meta_key] = meta_value + elif key in self.helper.argspec and value is not None: + parameters[key] = value + elif isinstance(value, dict): + self._add_parameter(value, [key], parameters) + self.helper.log("Request to parameters: {}".format(json.dumps(parameters))) + return parameters + + def _add_parameter(self, request, path, parameters): + for key, value in request.items(): + if path: + param_name = '_'.join(path + [self.helper.attribute_to_snake(key)]) + else: + param_name = self.helper.attribute_to_snake(key) + if param_name in self.helper.argspec and value is not None: + parameters[param_name] = value + elif isinstance(value, dict): + continue_path = copy.copy(path) if path else [] + continue_path.append(self.helper.attribute_to_snake(key)) + self._add_parameter(value, continue_path, parameters) + else: + self.fail_json( + msg=("Error parsing resource definition. Encountered {}, which does not map to a module " + "parameter. If this looks like a problem with the module, please open an issue at " + "github.com/openshift/openshift-restclient-python/issues").format(param_name) + ) diff --git a/module_utils/openshift_common.py b/module_utils/openshift_common.py new file mode 100644 index 00000000..a57f970f --- /dev/null +++ b/module_utils/openshift_common.py @@ -0,0 +1,70 @@ +# +# Copyright 2017 Red Hat | Ansible +# +# This file is part of Ansible +# +# Ansible is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# Ansible is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with Ansible. If not, see . + +from ansible.module_utils.k8s_common import KubernetesAnsibleException, KubernetesAnsibleModule + +try: + from openshift.helper.ansible import OpenShiftAnsibleModuleHelper, ARG_ATTRIBUTES_BLACKLIST + from openshift.helper.exceptions import KubernetesException, OpenShiftException + HAS_OPENSHIFT_HELPER = True +except ImportError as exc: + HAS_OPENSHIFT_HELPER = False + + +class OpenShiftAnsibleException(KubernetesAnsibleException): + pass + + +class OpenShiftAnsibleModule(KubernetesAnsibleModule): + def __init__(self, kind, api_version): + if not HAS_OPENSHIFT_HELPER: + raise OpenShiftAnsibleException( + "This module requires the OpenShift Python client. Try `pip install openshift`" + ) + + try: + super(OpenShiftAnsibleModule, self).__init__(kind, api_version) + except KubernetesAnsibleException as exc: + raise OpenShiftAnsibleException(exc.args) + + @staticmethod + def get_helper(api_version, kind): + return OpenShiftAnsibleModuleHelper(api_version, kind) + + def _create(self, namespace): + if self.kind.lower() == 'project': + return self._create_project() + else: + return super(OpenShiftAnsibleModule, self)._create(namespace) + + def _create_project(self): + new_obj = None + k8s_obj = None + try: + new_obj = self.helper.object_from_params(self.params) + except KubernetesException as exc: + self.fail_json(msg="Failed to create object: {}".format(exc.message)) + try: + k8s_obj = self.helper.create_project(metadata=new_obj.metadata, + display_name=self.params.get('display_name'), + description=self.params.get('description')) + except KubernetesException as exc: + self.fail_json(msg='Failed to retrieve requested object', + error=exc.value.get('status')) + return k8s_obj +