From d629a5ece22037550294f982d47b3a32244a275b Mon Sep 17 00:00:00 2001 From: Chris Houseknecht Date: Mon, 18 Dec 2017 20:03:44 -0500 Subject: [PATCH] Refactors common code for new K8s and OpenShift modules (#33646) * Refactors common code for new k8s and openshift modules * Move Ansible module helper code from OpenShift client --- lib/ansible/module_utils/k8s_common.py | 890 ++++++++++++++++--- lib/ansible/module_utils/openshift_common.py | 41 +- 2 files changed, 771 insertions(+), 160 deletions(-) diff --git a/lib/ansible/module_utils/k8s_common.py b/lib/ansible/module_utils/k8s_common.py index 6de1cc520f4..42c6232da73 100644 --- a/lib/ansible/module_utils/k8s_common.py +++ b/lib/ansible/module_utils/k8s_common.py @@ -16,17 +16,26 @@ # 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 __future__ import absolute_import, division, print_function +import os +import re +import copy +import base64 + +from keyword import kwlist + +from ansible.module_utils.six import iteritems from ansible.module_utils.basic import AnsibleModule try: - from openshift.helper.ansible import KubernetesAnsibleModuleHelper, ARG_ATTRIBUTES_BLACKLIST + from openshift.helper import PRIMITIVES + from openshift.helper.kubernetes import KubernetesObjectHelper from openshift.helper.exceptions import KubernetesException HAS_K8S_MODULE_HELPER = True except ImportError as exc: + class KubernetesObjectHelper(object): + pass HAS_K8S_MODULE_HELPER = False try: @@ -35,145 +44,703 @@ try: except ImportError: HAS_YAML = False - -class KubernetesAnsibleException(Exception): - pass +try: + import string_utils + HAS_STRING_UTILS = True +except ImportError: + HAS_STRING_UTILS = False -class KubernetesAnsibleModule(AnsibleModule): - @staticmethod - def get_helper(api_version, kind): - return KubernetesAnsibleModuleHelper(api_version, kind) +ARG_ATTRIBUTES_BLACKLIST = ('property_path',) +PYTHON_KEYWORD_MAPPING = dict(zip(['_{0}'.format(item) for item in kwlist], kwlist)) +PYTHON_KEYWORD_MAPPING.update(dict([reversed(item) for item in iteritems(PYTHON_KEYWORD_MAPPING)])) - def __init__(self, kind, api_version): - self.api_version = api_version - self.kind = kind - self.argspec_cache = None +ARG_SPEC = { + 'state': { + 'default': 'present', + 'choices': ['present', 'absent'], + }, + 'force': { + 'type': 'bool', + 'default': False, + }, + 'resource_definition': { + 'type': 'dict', + 'aliases': ['definition', 'inline'] + }, + 'src': { + 'type': 'path', + }, + 'kind': {}, + 'name': {}, + 'namespace': {}, + 'description': {}, + 'display_name': {}, + 'api_version': { + 'aliases': ['api', 'version'] + }, + 'kubeconfig': { + 'type': 'path', + }, + 'context': {}, + 'host': {}, + 'api_key': { + 'no_log': True, + }, + 'username': {}, + 'password': { + 'no_log': True, + }, + 'verify_ssl': { + 'type': 'bool', + }, + 'ssl_ca_cert': { + 'type': 'path', + }, + 'cert_file': { + 'type': 'path', + }, + 'key_file': { + 'type': 'path', + }, +} - 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) +class AnsibleMixin(object): + _argspec_cache = None @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 + Introspect the model properties, and return an Ansible module arg_spec dict. + :return: dict """ - 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." - ] - } - } + if self._argspec_cache: + return self._argspec_cache - 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()] + argument_spec = copy.deepcopy(ARG_SPEC) + argument_spec.update(self.__transform_properties(self.properties)) + self._argspec_cache = argument_spec + return self._argspec_cache + + def object_from_params(self, module_params, obj=None): + """ + Update a model object with Ansible module param values. Optionally pass an object + to update, otherwise a new object will be created. + :param module_params: dict of key:value pairs + :param obj: model object to update + :return: updated model object + """ + if not obj: + obj = self.model() + obj.kind = string_utils.snake_case_to_camel(self.kind, upper_case_first=False) + obj.api_version = self.api_version.lower() + for param_name, param_value in iteritems(module_params): + spec = self.find_arg_spec(param_name) + if param_value is not None and spec.get('property_path'): + prop_path = copy.copy(spec['property_path']) + self.__set_obj_attribute(obj, prop_path, param_value, param_name) + + if self.kind.lower() == 'project' and (module_params.get('display_name') or + module_params.get('description')): + if not obj.metadata.annotations: + obj.metadata.annotations = {} + if module_params.get('display_name'): + obj.metadata.annotations['openshift.io/display-name'] = module_params['display_name'] + if module_params.get('description'): + obj.metadata.annotations['openshift.io/description'] = module_params['description'] + elif (self.kind.lower() == 'secret' and getattr(obj, 'string_data', None) + and hasattr(obj, 'data')): + if obj.data is None: + obj.data = {} + + # Do a base64 conversion of `string_data` and place it in + # `data` so that later comparisons to existing objects + # (if any) do not result in requiring an unnecessary change. + for key, value in iteritems(obj.string_data): + obj.data[key] = base64.b64encode(value) + + obj.string_data = None + return obj + + def request_body_from_params(self, module_params): + request = { + 'kind': self.base_model_name, + } + for param_name, param_value in iteritems(module_params): + spec = self.find_arg_spec(param_name) + if spec and spec.get('property_path') and param_value is not None: + self.__add_path_to_dict(request, param_name, param_value, spec['property_path']) + + if self.kind.lower() == 'project' and (module_params.get('display_name') or + module_params.get('description')): + if not request.get('metadata'): + request['metadata'] = {} + if not request['metadata'].get('annotations'): + request['metadata']['annotations'] = {} + if module_params.get('display_name'): + request['metadata']['annotations']['openshift.io/display-name'] = module_params['display_name'] + if module_params.get('description'): + request['metadata']['annotations']['openshift.io/description'] = module_params['description'] + return request + + def find_arg_spec(self, module_param_name): + """For testing, allow the param_name value to be an alias""" + if module_param_name in self.argspec: + return self.argspec[module_param_name] + result = None + for key, value in iteritems(self.argspec): + if value.get('aliases'): + for alias in value['aliases']: + if alias == module_param_name: + result = self.argspec[key] + break + if result: + break + if not result: + raise KubernetesException( + "Error: received unrecognized module parameter {0}".format(module_param_name) + ) + return result + + @staticmethod + def __convert_params_to_choices(properties): + def snake_case(name): + result = string_utils.snake_case_to_camel(name.replace('_params', ''), upper_case_first=True) + return result[:1].upper() + result[1:] + choices = {} + for x in list(properties.keys()): + if x.endswith('params'): + choices[x] = snake_case(x) + return choices + + def __add_path_to_dict(self, request_dict, param_name, param_value, path): + local_path = copy.copy(path) + spec = self.find_arg_spec(param_name) + while len(local_path): + p = string_utils.snake_case_to_camel(local_path.pop(0), upper_case_first=False) + if len(local_path): + if request_dict.get(p, None) is None: + request_dict[p] = {} + self.__add_path_to_dict(request_dict[p], param_name, param_value, local_path) + break + else: + param_type = spec.get('type', 'str') + if param_type == 'dict': + request_dict[p] = self.__dict_keys_to_camel(param_name, param_value) + elif param_type == 'list': + request_dict[p] = self.__list_keys_to_camel(param_name, param_value) + else: + request_dict[p] = param_value + + def __dict_keys_to_camel(self, param_name, param_dict): + result = {} + for item, value in iteritems(param_dict): + key_name = self.__property_name_to_camel(param_name, item) + if value: + if isinstance(value, list): + result[key_name] = self.__list_keys_to_camel(param_name, value) + elif isinstance(value, dict): + result[key_name] = self.__dict_keys_to_camel(param_name, value) + else: + result[key_name] = value + return result + + @staticmethod + def __property_name_to_camel(param_name, property_name): + new_name = property_name + if 'annotations' not in param_name and 'labels' not in param_name and 'selector' not in param_name: + camel_name = string_utils.snake_case_to_camel(property_name, upper_case_first=False) + new_name = camel_name[1:] if camel_name.startswith('_') else camel_name + return new_name + + def __list_keys_to_camel(self, param_name, param_list): + result = [] + if isinstance(param_list[0], dict): + for item in param_list: + result.append(self.__dict_keys_to_camel(param_name, item)) + else: + result = param_list + return result + + def __set_obj_attribute(self, obj, property_path, param_value, param_name): + """ + Recursively set object properties + :param obj: The object on which to set a property value. + :param property_path: A list of property names in the form of strings. + :param param_value: The value to set. + :return: The original object. + """ + while len(property_path) > 0: + raw_prop_name = property_path.pop(0) + prop_name = PYTHON_KEYWORD_MAPPING.get(raw_prop_name, raw_prop_name) + prop_kind = obj.swagger_types[prop_name] + if prop_kind in PRIMITIVES: + try: + setattr(obj, prop_name, param_value) + except ValueError as exc: + msg = str(exc) + if param_value is None and 'None' in msg: + pass + else: + raise KubernetesException( + "Error setting {0} to {1}: {2}".format(prop_name, param_value, msg) + ) + elif prop_kind.startswith('dict('): + if not getattr(obj, prop_name): + setattr(obj, prop_name, param_value) + else: + self.__compare_dict(getattr(obj, prop_name), param_value, param_name) + elif prop_kind.startswith('list['): + if getattr(obj, prop_name) is None: + setattr(obj, prop_name, []) + obj_type = prop_kind.replace('list[', '').replace(']', '') + if obj_type not in PRIMITIVES and obj_type not in ('list', 'dict'): + self.__compare_obj_list(getattr(obj, prop_name), param_value, obj_type, param_name) + else: + self.__compare_list(getattr(obj, prop_name), param_value, param_name) + else: + # prop_kind is an object class + sub_obj = getattr(obj, prop_name) + if not sub_obj: + sub_obj = self.model_class_from_name(prop_kind)() + setattr(obj, prop_name, self.__set_obj_attribute(sub_obj, property_path, param_value, param_name)) + return obj + + def __compare_list(self, src_values, request_values, param_name): + """ + Compare src_values list with request_values list, and append any missing + request_values to src_values. + """ + if not request_values: + return + + if not src_values: + src_values += request_values + + if type(src_values[0]).__name__ in PRIMITIVES: + if set(src_values) >= set(request_values): + # src_value list includes request_value list + return + # append the missing elements from request value + src_values += list(set(request_values) - set(src_values)) + elif type(src_values[0]).__name__ == 'dict': + missing = [] + for request_dict in request_values: + match = False + for src_dict in src_values: + if '__cmp__' in dir(src_dict): + # python < 3 + if src_dict >= request_dict: + match = True + break + elif iteritems(src_dict) == iteritems(request_dict): + # python >= 3 + match = True + break + if not match: + missing.append(request_dict) + src_values += missing + elif type(src_values[0]).__name__ == 'list': + missing = [] + for request_list in request_values: + match = False + for src_list in src_values: + if set(request_list) >= set(src_list): + match = True + break + if not match: + missing.append(request_list) + src_values += missing + else: + raise KubernetesException( + "Evaluating {0}: encountered unimplemented type {1} in " + "__compare_list()".format(param_name, type(src_values[0]).__name__) + ) + + def __compare_dict(self, src_value, request_value, param_name): + """ + Compare src_value dict with request_value dict, and update src_value with any differences. + Does not remove items from src_value dict. + """ + if not request_value: + return + for item, value in iteritems(request_value): + if type(value).__name__ in ('str', 'int', 'bool'): + src_value[item] = value + elif type(value).__name__ == 'list': + self.__compare_list(src_value[item], value, param_name) + elif type(value).__name__ == 'dict': + self.__compare_dict(src_value[item], value, param_name) + else: + raise KubernetesException( + "Evaluating {0}: encountered unimplemented type {1} in " + "__compare_dict()".format(param_name, type(value).__name__) + ) + + def __compare_obj_list(self, src_value, request_value, obj_class, param_name): + """ + Compare a src_value (list of ojects) with a request_value (list of dicts), and update + src_value with differences. Assumes each object and each dict has a 'name' attributes, + which can be used for matching. Elements are not removed from the src_value list. + """ + if not request_value: + return + + sample_obj = self.model_class_from_name(obj_class)() + + # Try to determine the unique key for the array + key_names = [ + 'name', + 'type' + ] + key_name = None + for key in key_names: + if hasattr(sample_obj, key): + key_name = key + break + + if key_name: + # If the key doesn't exist in the request values, then ignore it, rather than throwing an error + for item in request_value: + if not item.get(key_name): + key_name = None + break + + if key_name: + # compare by key field + for item in request_value: + if not item.get(key_name): + # Prevent user from creating something that will be impossible to patch or update later + raise KubernetesException( + "Evaluating {0} - expecting parameter {1} to contain a `{2}` attribute " + "in __compare_obj_list().".format(param_name, + self.get_base_model_name_snake(obj_class), + key_name) + ) + found = False + for obj in src_value: + if not obj: + continue + if getattr(obj, key_name) == item[key_name]: + # Assuming both the src_value and the request value include a name property + found = True + for key, value in iteritems(item): + snake_key = self.attribute_to_snake(key) + item_kind = sample_obj.swagger_types.get(snake_key) + if item_kind and item_kind in PRIMITIVES or type(value).__name__ in PRIMITIVES: + setattr(obj, snake_key, value) + elif item_kind and item_kind.startswith('list['): + obj_type = item_kind.replace('list[', '').replace(']', '') + if getattr(obj, snake_key) is None: + setattr(obj, snake_key, []) + if obj_type not in ('str', 'int', 'bool'): + self.__compare_obj_list(getattr(obj, snake_key), value, obj_type, param_name) + else: + # Straight list comparison + self.__compare_list(getattr(obj, snake_key), value, param_name) + elif item_kind and item_kind.startswith('dict('): + self.__compare_dict(getattr(obj, snake_key), value, param_name) + elif item_kind and type(value).__name__ == 'dict': + # object + param_obj = getattr(obj, snake_key) + if not param_obj: + setattr(obj, snake_key, self.model_class_from_name(item_kind)()) + param_obj = getattr(obj, snake_key) + self.__update_object_properties(param_obj, value) else: - spec[arg_name]['choices'] = option_value - else: - spec[arg_name][option] = option_value + if item_kind: + raise KubernetesException( + "Evaluating {0}: encountered unimplemented type {1} in " + "__compare_obj_list() for model {2}".format( + param_name, + item_kind, + self.get_base_model_name_snake(obj_class)) + ) + else: + raise KubernetesException( + "Evaluating {0}: unable to get swagger_type for {1} in " + "__compare_obj_list() for item {2} in model {3}".format( + param_name, + snake_key, + str(item), + self.get_base_model_name_snake(obj_class)) + ) + if not found: + # Requested item not found. Adding. + obj = self.__update_object_properties(self.model_class_from_name(obj_class)(), item) + src_value.append(obj) + else: + # There isn't a key, or we don't know what it is, so check for all properties to match + for item in request_value: + found = False + for obj in src_value: + match = True + for item_key, item_value in iteritems(item): + # TODO: this should probably take the property type into account + snake_key = self.attribute_to_snake(item_key) + if getattr(obj, snake_key) != item_value: + match = False + break + if match: + found = True + break + if not found: + obj = self.__update_object_properties(self.model_class_from_name(obj_class)(), item) + src_value.append(obj) - self.argspec_cache = spec - return self.argspec_cache + def __update_object_properties(self, obj, item): + """ Recursively update an object's properties. Returns a pointer to the object. """ + + for key, value in iteritems(item): + snake_key = self.attribute_to_snake(key) + try: + kind = obj.swagger_types[snake_key] + except (AttributeError, KeyError): + possible_matches = ', '.join(list(obj.swagger_types.keys())) + class_snake_name = self.get_base_model_name_snake(type(obj).__name__) + raise KubernetesException( + "Unable to find '{0}' in {1}. Valid property names include: {2}".format(snake_key, + class_snake_name, + possible_matches) + ) + if kind in PRIMITIVES or kind.startswith('list[') or kind.startswith('dict('): + self.__set_obj_attribute(obj, [snake_key], value, snake_key) + else: + # kind is an object, hopefully + if not getattr(obj, snake_key): + setattr(obj, snake_key, self.model_class_from_name(kind)()) + self.__update_object_properties(getattr(obj, snake_key), value) + + return obj + + def __transform_properties(self, properties, prefix='', path=None, alternate_prefix=''): + """ + Convert a list of properties to an argument_spec dictionary + + :param properties: List of properties from self.properties_from_model_obj() + :param prefix: String to prefix to argument names. + :param path: List of property names providing the recursive path through the model to the property + :param alternate_prefix: a more minimal version of prefix + :return: dict + """ + primitive_types = list(PRIMITIVES) + ['list', 'dict'] + args = {} + + if path is None: + path = [] + + def add_meta(prop_name, prop_prefix, prop_alt_prefix): + """ Adds metadata properties to the argspec """ + # if prop_alt_prefix != prop_prefix: + # if prop_alt_prefix: + # args[prop_prefix + prop_name]['aliases'] = [prop_alt_prefix + prop_name] + # elif prop_prefix: + # args[prop_prefix + prop_name]['aliases'] = [prop_name] + prop_paths = copy.copy(path) # copy path from outer scope + prop_paths.append('metadata') + prop_paths.append(prop_name) + args[prop_prefix + prop_name]['property_path'] = prop_paths + + for raw_prop, prop_attributes in iteritems(properties): + prop = PYTHON_KEYWORD_MAPPING.get(raw_prop, raw_prop) + if prop in ('api_version', 'status', 'kind', 'items') and not prefix: + # Don't expose these properties + continue + elif prop_attributes['immutable']: + # Property cannot be set by the user + continue + elif prop == 'metadata' and prop_attributes['class'].__name__ == 'UnversionedListMeta': + args['namespace'] = {} + elif prop == 'metadata' and prop_attributes['class'].__name__ != 'UnversionedListMeta': + meta_prefix = prefix + '_metadata_' if prefix else '' + meta_alt_prefix = alternate_prefix + '_metadata_' if alternate_prefix else '' + if meta_prefix and not meta_alt_prefix: + meta_alt_prefix = meta_prefix + if 'labels' in dir(prop_attributes['class']): + args[meta_prefix + 'labels'] = { + 'type': 'dict', + } + add_meta('labels', meta_prefix, meta_alt_prefix) + if 'annotations' in dir(prop_attributes['class']): + args[meta_prefix + 'annotations'] = { + 'type': 'dict', + } + add_meta('annotations', meta_prefix, meta_alt_prefix) + if 'namespace' in dir(prop_attributes['class']): + args[meta_prefix + 'namespace'] = {} + add_meta('namespace', meta_prefix, meta_alt_prefix) + if 'name' in dir(prop_attributes['class']): + args[meta_prefix + 'name'] = {} + add_meta('name', meta_prefix, meta_alt_prefix) + elif prop_attributes['class'].__name__ not in primitive_types and not prop.endswith('params'): + # Adds nested properties recursively + + label = prop + + # Provide a more human-friendly version of the prefix + alternate_label = label\ + .replace('spec', '')\ + .replace('template', '')\ + .replace('config', '') + + p = prefix + p += '_' + label if p else label + a = alternate_prefix + paths = copy.copy(path) + paths.append(prop) + + # if alternate_prefix: + # # Prevent the last prefix from repeating. In other words, avoid things like 'pod_pod' + # pieces = alternate_prefix.split('_') + # alternate_label = alternate_label.replace(pieces[len(pieces) - 1] + '_', '', 1) + # if alternate_label != self.base_model_name and alternate_label not in a: + # a += '_' + alternate_label if a else alternate_label + if prop.endswith('params') and 'type' in properties: + sub_props = dict() + sub_props[prop] = { + 'class': dict, + 'immutable': False + } + args.update(self.__transform_properties(sub_props, prefix=p, path=paths, alternate_prefix=a)) + else: + sub_props = self.properties_from_model_obj(prop_attributes['class']()) + args.update(self.__transform_properties(sub_props, prefix=p, path=paths, alternate_prefix=a)) + else: + # Adds a primitive property + arg_prefix = prefix + '_' if prefix else '' + arg_alt_prefix = alternate_prefix + '_' if alternate_prefix else '' + paths = copy.copy(path) + paths.append(prop) + + property_type = prop_attributes['class'].__name__ + if property_type == 'IntstrIntOrString': + property_type = 'str' + + args[arg_prefix + prop] = { + 'required': False, + 'type': property_type, + 'property_path': paths + } + + if prop.endswith('params') and 'type' in properties: + args[arg_prefix + prop]['type'] = 'dict' + + # Use the alternate prefix to construct a human-friendly alias + if arg_alt_prefix and arg_prefix != arg_alt_prefix: + args[arg_prefix + prop]['aliases'] = [arg_alt_prefix + prop] + elif arg_prefix: + args[arg_prefix + prop]['aliases'] = [prop] + + if prop == 'type': + choices = self.__convert_params_to_choices(properties) + if len(choices) > 0: + args[arg_prefix + prop]['choices'] = choices + return args + + +class KubernetesAnsibleModuleHelper(AnsibleMixin, KubernetesObjectHelper): + pass + + +class KubernetesAnsibleModule(AnsibleModule): + + def __init__(self): + + if not HAS_K8S_MODULE_HELPER: + raise Exception( + "This module requires the OpenShift Python client. Try `pip install openshift`" + ) + + if not HAS_YAML: + raise Exception( + "This module requires PyYAML. Try `pip install PyYAML`" + ) + + if not HAS_STRING_UTILS: + raise Exception( + "This module requires Python string utils. Try `pip install python-string-utils`" + ) + + mutually_exclusive = [ + ('resource_definition', 'src'), + ] + + AnsibleModule.__init__(self, + argument_spec=self._argspec, + supports_check_mode=True, + mutually_exclusive=mutually_exclusive) + + self.kind = self.params.pop('kind') + self.api_version = self.params.pop('api_version') + self.resource_definition = self.params.pop('resource_definition') + self.src = self.params.pop('src') + if self.src: + self.resource_definition = self.load_resource_definition(self.src) + + if self.resource_definition: + self.api_version = self.resource_definition.get('apiVersion') + self.kind = self.resource_definition.get('kind') + + self.api_version = self.api_version.lower() + self.kind = self._to_snake(self.kind) + + if not self.api_version: + self.fail_json( + msg=("Error: no api_version specified. Use the api_version parameter, or provide it as part of a ", + "resource_definition.") + ) + if not self.kind: + self.fail_json( + msg="Error: no kind specified. Use the kind parameter, or provide it as part of a resource_definition" + ) + + self.helper = self._get_helper(self.api_version, self.kind) + + @property + def _argspec(self): + argspec = copy.deepcopy(ARG_SPEC) + argspec.pop('display_name') + argspec.pop('description') + return argspec + + def _get_helper(self, api_version, kind): + try: + helper = KubernetesAnsibleModuleHelper(api_version=api_version, kind=kind, debug=False) + helper.get_model(api_version, kind) + return helper + except KubernetesException as exc: + self.fail_json(msg="Error initializing module helper {0}".format(exc.message)) 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 - """ - - 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) + if self.resource_definition: + resource_params = self.resource_to_parameters(self.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) + self._authenticate() + + state = self.params.pop('state', None) + force = self.params.pop('force', False) name = self.params.get('name') - namespace = self.params.get('namespace', None) + namespace = self.params.get('namespace') 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] = {} + self._remove_aliases() - if dry_run: + return_attributes = dict(changed=False, result=dict()) + + if self._diff: + return_attributes['request'] = self.helper.request_body_from_params(self.params) + + if self.helper.base_model_name_snake.endswith('list'): + k8s_obj = self._read(name, namespace) + return_attributes['result'] = k8s_obj.to_dict() 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), + self.fail_json(msg='Failed to retrieve requested object: {0}'.format(exc.message), error=exc.value.get('status')) if state == 'absent': @@ -186,14 +753,14 @@ class KubernetesAnsibleModule(AnsibleModule): try: self.helper.delete_object(name, namespace) except KubernetesException as exc: - self.fail_json(msg="Failed to delete object: {}".format(exc.message), + self.fail_json(msg="Failed to delete object: {0}".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['result'] = k8s_obj.to_dict() return_attributes['changed'] = True self.exit_json(**return_attributes) @@ -204,9 +771,9 @@ class KubernetesAnsibleModule(AnsibleModule): 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), + self.fail_json(msg="Failed to replace object: {0}".format(exc.message), error=exc.value.get('status')) - return_attributes[self.kind] = k8s_obj.to_dict() + return_attributes['result'] = k8s_obj.to_dict() return_attributes['changed'] = True self.exit_json(**return_attributes) @@ -215,38 +782,57 @@ class KubernetesAnsibleModule(AnsibleModule): 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)) + self.fail_json(msg="Failed to patch object: {0}".format(exc.message)) match, diff = self.helper.objects_match(existing, k8s_obj) if match: - return_attributes[self.kind] = existing.to_dict() + return_attributes['result'] = existing.to_dict() self.exit_json(**return_attributes) - else: - self.log('Existing:') - self.log(json.dumps(existing.to_str(), indent=4)) - self.log('\nDifferences:') - self.log(json.dumps(diff, indent=4)) + elif self._diff: + return_attributes['differences'] = diff # 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() + self.fail_json(msg="Failed to patch object: {0}".format(exc.message)) + return_attributes['result'] = k8s_obj.to_dict() return_attributes['changed'] = True self.exit_json(**return_attributes) + def _authenticate(self): + try: + auth_options = {} + auth_args = ('host', 'api_key', 'kubeconfig', 'context', 'username', 'password', + 'cert_file', 'key_file', 'ssl_ca_cert', 'verify_ssl') + for key, value in iteritems(self.params): + if key in auth_args and value is not None: + auth_options[key] = value + self.helper.set_client_config(**auth_options) + except KubernetesException as e: + self.fail_json(msg='Error loading config', error=str(e)) + + def _remove_aliases(self): + """ + The helper doesn't know what to do with aliased keys + """ + for k, v in iteritems(self._argspec): + if 'aliases' in v: + for alias in v['aliases']: + if alias in self.params: + self.params.pop(alias) + 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)) + self.fail_json(msg="Failed to create object: {0}".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), + self.fail_json(msg="Failed to create object: {0}".format(exc.message), error=exc.value.get('status')) return k8s_obj @@ -263,36 +849,34 @@ class KubernetesAnsibleModule(AnsibleModule): """ Load the requested src path """ result = None path = os.path.normpath(src) - self.log("Reading definition from {}".format(path)) if not os.path.exists(path): - self.fail_json(msg="Error accessing {}. Does the file exist?".format(path)) + self.fail_json(msg="Error accessing {0}. 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)) + self.fail_json(msg="Error loading resource_definition: {0}".format(exc)) return result def resource_to_parameters(self, resource): """ Converts a resource definition to module parameters """ parameters = {} - for key, value in resource.items(): + for key, value in iteritems(resource): if key in ('apiVersion', 'kind', 'status'): continue elif key == 'metadata' and isinstance(value, dict): - for meta_key, meta_value in value.items(): + for meta_key, meta_value in iteritems(value): 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 + parameters[key] = value elif isinstance(value, dict): self._add_parameter(value, [key], parameters) - self.log("Request to parameters: {}".format(json.dumps(parameters))) return parameters def _add_parameter(self, request, path, parameters): - for key, value in request.items(): + for key, value in iteritems(request): if path: - param_name = '_'.join(path + [self.helper.attribute_to_snake(key)]) + param_name = '_'.join(path + [self._to_snake(key)]) else: param_name = self.helper.attribute_to_snake(key) if param_name in self.helper.argspec and value is not None: @@ -303,7 +887,25 @@ class KubernetesAnsibleModule(AnsibleModule): 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) + msg=("Error parsing resource definition. Encountered {0}, which does not map to a parameter " + "expected by the OpenShift Python module.".format(param_name)) ) + + @staticmethod + def _to_snake(name): + """ + Convert a string from camel to snake + :param name: string to convert + :return: string + """ + if not name: + return name + + def replace(m): + m = m.group(0) + return m[0] + '_' + m[1:] + + p = r'[a-z][A-Z]|' \ + r'[A-Z]{2}[a-z]' + result = re.sub(p, replace, name) + return result.lower() diff --git a/lib/ansible/module_utils/openshift_common.py b/lib/ansible/module_utils/openshift_common.py index 7b20f2b586c..6cccd8b40e5 100644 --- a/lib/ansible/module_utils/openshift_common.py +++ b/lib/ansible/module_utils/openshift_common.py @@ -1,5 +1,5 @@ # -# Copyright 2017 Red Hat | Ansible +# Copyright 2018 Red Hat | Ansible # # This file is part of Ansible # @@ -16,41 +16,50 @@ # 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 +import copy + +from ansible.module_utils.k8s_common import KubernetesAnsibleModule, AnsibleMixin, ARG_SPEC try: - from openshift.helper.ansible import OpenShiftAnsibleModuleHelper, ARG_ATTRIBUTES_BLACKLIST - from openshift.helper.exceptions import KubernetesException, OpenShiftException + from openshift.helper.openshift import OpenShiftObjectHelper + from openshift.helper.exceptions import KubernetesException HAS_OPENSHIFT_HELPER = True except ImportError as exc: + class OpenShiftObjectHelper(object): + pass HAS_OPENSHIFT_HELPER = False -class OpenShiftAnsibleException(KubernetesAnsibleException): +class OpenShiftAnsibleModuleHelper(AnsibleMixin, OpenShiftObjectHelper): pass class OpenShiftAnsibleModule(KubernetesAnsibleModule): - def __init__(self, kind, api_version): + def __init__(self): + if not HAS_OPENSHIFT_HELPER: - raise OpenShiftAnsibleException( + raise Exception( "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) + super(OpenShiftAnsibleModule, self).__init__() - @staticmethod - def get_helper(api_version, kind): - return OpenShiftAnsibleModuleHelper(api_version, kind) + @property + def _argspec(self): + return copy.deepcopy(ARG_SPEC) + + def _get_helper(self, api_version, kind): + try: + helper = OpenShiftAnsibleModuleHelper(api_version=api_version, kind=kind, debug=False) + helper.get_model(api_version, kind) + return helper + except KubernetesException as exc: + self.exit_json(msg="Error initializing module helper {}".format(exc.message)) def _create(self, namespace): if self.kind.lower() == 'project': return self._create_project() - else: - return super(OpenShiftAnsibleModule, self)._create(namespace) + return super(OpenShiftAnsibleModule, self)._create(namespace) def _create_project(self): new_obj = None