From bbcd284edf36240b95fc8ab42bde4cb467d03246 Mon Sep 17 00:00:00 2001 From: Chris Houseknecht Date: Wed, 23 Aug 2017 21:08:11 -0400 Subject: [PATCH] Restores module_utils --- README.md | 4 +- module_utils/README.md | 3 + module_utils/k8s_common.py | 313 +++++++++++++++++++++++++++++++++++++ 3 files changed, 319 insertions(+), 1 deletion(-) create mode 100644 module_utils/README.md create mode 100644 module_utils/k8s_common.py diff --git a/README.md b/README.md index a8429905..d2fc0516 100644 --- a/README.md +++ b/README.md @@ -8,9 +8,11 @@ The modules are found in the [library folder](./library). Each has full document If you find an issue with a particular module, or have suggestions, please file an issue at the [OpenShift Rest Client repo](https://github.com/openshift/openshift-restclient-python/issues). +For convenience, the `k8s_common.py` module is included under [module_utils](./module_utils). This is because it is not currenlty part of an official Ansible release. It is part of Ansible, and you will find it in the `devel` branch. So at some point in the future, it will be part of a relase. In the meantime, it will also be included here. If you happen to find a bug, or would like to make a change, please open issue ans submit pull requests at the [Ansible repo](https://github.com/ansible/ansible). + ## Requirements -- Ansible installed from source +- Ansible - [OpenShift Rest Client](https://github.com/openshift/openshift-restclient-python) installed on the host where the modules will execute. ## Installation and use diff --git a/module_utils/README.md b/module_utils/README.md new file mode 100644 index 00000000..53a6c9f9 --- /dev/null +++ b/module_utils/README.md @@ -0,0 +1,3 @@ +The `k8_common.py` module is not currently available in an official release of Ansible. It is part of Ansible, and you'll find it on the `devel` branch, so at some point it will make it into a release. Until then, it's included here in order for convenience. + +If you have uncovered a problem with it, or would like to make a change, please open issue and submit pull requess at the [Ansible repo](https://github.com/ansible/ansible). diff --git a/module_utils/k8s_common.py b/module_utils/k8s_common.py new file mode 100644 index 00000000..7800898f --- /dev/null +++ b/module_utils/k8s_common.py @@ -0,0 +1,313 @@ +# +# 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 = { + 'dry_run': { + 'type': 'bool', + 'default': False, + 'description': [ + "If set to C(True) the module will exit without executing any action." + "Useful to only generate YAML file definitions for the resources in the tasks." + ] + } + } + + 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) + dry_run = self.params.pop('dry_run', False) + name = self.params.get('name') + namespace = self.params.get('namespace', None) + existing = None + + return_attributes = dict(changed=False, + api_version=self.api_version, + request=self.helper.request_body_from_params(self.params)) + return_attributes[self.helper.base_model_name_snake] = {} + + if dry_run: + self.exit_json(**return_attributes) + + 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) + )