diff --git a/lib/ansible/module_utils/k8s/common.py b/lib/ansible/module_utils/k8s/common.py index 93d0c3b72c2..37ec9320220 100644 --- a/lib/ansible/module_utils/k8s/common.py +++ b/lib/ansible/module_utils/k8s/common.py @@ -19,17 +19,31 @@ from __future__ import absolute_import, division, print_function import os +import re import copy +import json +from datetime import datetime from ansible.module_utils.six import iteritems from ansible.module_utils.basic import AnsibleModule +from ansible.module_utils.k8s.helper import\ + AnsibleMixin,\ + HAS_STRING_UTILS + try: - import kubernetes - from openshift.dynamic import DynamicClient + from openshift.helper.kubernetes import KubernetesObjectHelper + from openshift.helper.openshift import OpenShiftObjectHelper + from openshift.helper.exceptions import KubernetesException HAS_K8S_MODULE_HELPER = True -except ImportError: +except ImportError as exc: + class KubernetesObjectHelper(object): + pass + + class OpenShiftObjectHelper(object): + pass + HAS_K8S_MODULE_HELPER = False try: @@ -38,141 +52,104 @@ try: except ImportError: HAS_YAML = False -try: - import dictdiffer - HAS_DICTDIFFER = True -except ImportError: - HAS_DICTDIFFER = False -try: - import urllib3 - urllib3.disable_warnings() -except ImportError: +def remove_secret_data(obj_dict): + """ Remove any sensitive data from a K8s dict""" + if obj_dict.get('data'): + # Secret data + obj_dict.pop('data') + if obj_dict.get('string_data'): + # The API should not return sting_data in Secrets, but just in case + obj_dict.pop('string_data') + if obj_dict['metadata'].get('annotations'): + # Remove things like 'openshift.io/token-secret' from metadata + for key in [k for k in obj_dict['metadata']['annotations'] if 'secret' in k]: + obj_dict['metadata']['annotations'].pop(key) + + +def to_snake(name): + """ Convert a string from camel to snake """ + 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]' + return re.sub(p, _replace, name).lower() + + +class DateTimeEncoder(json.JSONEncoder): + # When using json.dumps() with K8s object, pass cls=DateTimeEncoder to handle any datetime objects + def default(self, o): + if isinstance(o, datetime): + return o.isoformat() + return json.JSONEncoder.default(self, o) + + +class KubernetesAnsibleModuleHelper(AnsibleMixin, KubernetesObjectHelper): pass -ARG_ATTRIBUTES_BLACKLIST = ('property_path',) - -COMMON_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': {}, - 'api_version': { - 'default': 'v1', - 'aliases': ['api', 'version'], - }, -} - -AUTH_ARG_SPEC = { - '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', - }, -} - - -class K8sAnsibleMixin(object): - _argspec_cache = None + +class KubernetesAnsibleModule(AnsibleModule): + resource_definition = None + api_version = None + kind = None + helper = None + + def __init__(self, *args, **kwargs): + + kwargs['argument_spec'] = self.argspec + AnsibleModule.__init__(self, *args, **kwargs) + + if not HAS_K8S_MODULE_HELPER: + self.fail_json(msg="This module requires the OpenShift Python client. Try `pip install openshift`") + + if not HAS_YAML: + self.fail_json(msg="This module requires PyYAML. Try `pip install PyYAML`") + + if not HAS_STRING_UTILS: + self.fail_json(msg="This module requires Python string utils. Try `pip install python-string-utils`") @property def argspec(self): - """ - Introspect the model properties, and return an Ansible module arg_spec dict. - :return: dict - """ - if self._argspec_cache: - return self._argspec_cache - argument_spec = copy.deepcopy(COMMON_ARG_SPEC) - argument_spec.update(copy.deepcopy(AUTH_ARG_SPEC)) - self._argspec_cache = argument_spec - return self._argspec_cache - - def get_api_client(self, **auth): - auth_args = AUTH_ARG_SPEC.keys() - - auth = auth or getattr(self, 'params', {}) - - configuration = kubernetes.client.Configuration() - for key, value in iteritems(auth): - if key in auth_args and value is not None: - if key == 'api_key': - setattr(configuration, key, {'authorization': "Bearer {0}".format(value)}) - else: - setattr(configuration, key, value) - elif key in auth_args and value is None: - env_value = os.getenv('K8S_AUTH_{0}'.format(key.upper()), None) - if env_value is not None: - setattr(configuration, key, env_value) - - kubernetes.client.Configuration.set_default(configuration) - - if auth.get('username') and auth.get('password') and auth.get('host'): - auth_method = 'params' - elif auth.get('api_key') and auth.get('host'): - auth_method = 'params' - elif auth.get('kubeconfig') or auth.get('context'): - auth_method = 'file' - else: - auth_method = 'default' - - # First try to do incluster config, then kubeconfig - if auth_method == 'default': - try: - kubernetes.config.load_incluster_config() - return DynamicClient(kubernetes.client.ApiClient()) - except kubernetes.config.ConfigException: - return DynamicClient(self.client_from_kubeconfig(auth.get('kubeconfig'), auth.get('context'))) - - if auth_method == 'file': - return DynamicClient(self.client_from_kubeconfig(auth.get('kubeconfig'), auth.get('context'))) - - if auth_method == 'params': - return DynamicClient(kubernetes.client.ApiClient(configuration)) - - def client_from_kubeconfig(self, config_file, context): + raise NotImplementedError() + + def get_helper(self, api_version, kind): try: - return kubernetes.config.new_client_from_config(config_file, context) - except (IOError, kubernetes.config.ConfigException): - # If we failed to load the default config file then we'll return - # an empty configuration - # If one was specified, we will crash - if not config_file: - return kubernetes.client.ApiClient() - raise + 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): + raise NotImplementedError() + + def exit_json(self, **return_attributes): + """ Filter any sensitive data that we don't want logged """ + if return_attributes.get('result') and \ + return_attributes['result'].get('kind') in ('Secret', 'SecretList'): + if return_attributes['result'].get('data'): + remove_secret_data(return_attributes['result']) + elif return_attributes['result'].get('items'): + for item in return_attributes['result']['items']: + remove_secret_data(item) + super(KubernetesAnsibleModule, 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): """ @@ -184,53 +161,63 @@ class K8sAnsibleMixin(object): if alias in self.params: self.params.pop(alias) - def load_resource_definitions(self, src): + def load_resource_definition(self, src): """ Load the requested src path """ result = None path = os.path.normpath(src) if not os.path.exists(path): self.fail_json(msg="Error accessing {0}. Does the file exist?".format(path)) try: - with open(path, 'r') as f: - result = list(yaml.safe_load_all(f)) + result = yaml.safe_load(open(path, 'r')) except (IOError, yaml.YAMLError) as exc: self.fail_json(msg="Error loading resource_definition: {0}".format(exc)) return result - @staticmethod - def diff_objects(existing, new): - if not HAS_DICTDIFFER: - return False, [] - - def get_shared_attrs(o1, o2): - shared_attrs = {} - for k, v in o2.items(): - if isinstance(v, dict): - shared_attrs[k] = get_shared_attrs(o1.get(k, {}), v) - else: - shared_attrs[k] = o1.get(k) - return shared_attrs - - diffs = list(dictdiffer.diff(new, get_shared_attrs(existing, new))) - match = len(diffs) == 0 - return match, diffs - - -class KubernetesAnsibleModule(AnsibleModule, K8sAnsibleMixin): - resource_definition = None - api_version = None - kind = None - - def __init__(self, *args, **kwargs): + def resource_to_parameters(self, resource): + """ Converts a resource definition to module parameters """ + parameters = {} + 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 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 + elif isinstance(value, dict): + self._add_parameter(value, [to_snake(key)], parameters) + return parameters + + def _add_parameter(self, request, path, parameters): + for key, value in iteritems(request): + if path: + param_name = '_'.join(path + [to_snake(key)]) + else: + param_name = 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(to_snake(key)) + self._add_parameter(value, continue_path, parameters) + else: + self.fail_json( + msg=("Error parsing resource definition. Encountered {0}, which does not map to a parameter " + "expected by the OpenShift Python module.".format(param_name)) + ) + + +class OpenShiftAnsibleModuleHelper(AnsibleMixin, OpenShiftObjectHelper): + pass - kwargs['argument_spec'] = self.argspec - AnsibleModule.__init__(self, *args, **kwargs) - if not HAS_K8S_MODULE_HELPER: - self.fail_json(msg="This module requires the OpenShift Python client. Try `pip install openshift`") - - if not HAS_YAML: - self.fail_json(msg="This module requires PyYAML. Try `pip install PyYAML`") +class OpenShiftAnsibleModuleMixin(object): - def execute_module(self): - raise NotImplementedError() + 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.fail_json(msg="Error initializing module helper: {0}".format(exc.message)) diff --git a/lib/ansible/module_utils/k8s/helper.py b/lib/ansible/module_utils/k8s/helper.py new file mode 100644 index 00000000000..438dd9d0dd8 --- /dev/null +++ b/lib/ansible/module_utils/k8s/helper.py @@ -0,0 +1,633 @@ +# +# Copyright 2018 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 base64 +import copy + +from ansible.module_utils.six import iteritems, string_types +from keyword import kwlist + +try: + from openshift.helper import PRIMITIVES + from openshift.helper.exceptions import KubernetesException + HAS_K8S_MODULE_HELPER = True +except ImportError as exc: + HAS_K8S_MODULE_HELPER = False + +# TODO Remove string_utils dependency +try: + import string_utils + HAS_STRING_UTILS = True +except ImportError: + HAS_STRING_UTILS = False + + +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)])) + +COMMON_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': {}, + 'api_version': { + 'default': 'v1', + 'aliases': ['api', 'version'], + }, +} + +AUTH_ARG_SPEC = { + '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', + }, +} + +OPENSHIFT_ARG_SPEC = { + 'description': {}, + 'display_name': {}, +} + + +class AnsibleMixin(object): + _argspec_cache = None + + @property + def argspec(self): + """ + Introspect the model properties, and return an Ansible module arg_spec dict. + :return: dict + """ + if self._argspec_cache: + return self._argspec_cache + argument_spec = copy.deepcopy(COMMON_ARG_SPEC) + argument_spec.update(copy.deepcopy(AUTH_ARG_SPEC)) + argument_spec.update(copy.deepcopy(OPENSHIFT_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 + + model_class = 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(model_class, 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 = model_class.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', 'object'): + 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: + 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.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.model_class_from_name(obj_class)(**item) + src_value.append(obj) + + 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_class() + :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_class(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 == 'object': + 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 diff --git a/lib/ansible/module_utils/k8s/inventory.py b/lib/ansible/module_utils/k8s/inventory.py index d8cf8b6654f..c8c48c39c0b 100644 --- a/lib/ansible/module_utils/k8s/inventory.py +++ b/lib/ansible/module_utils/k8s/inventory.py @@ -18,24 +18,22 @@ from __future__ import absolute_import, division, print_function -from ansible.module_utils.k8s.common import K8sAnsibleMixin, HAS_K8S_MODULE_HELPER +from ansible.module_utils.six import iteritems try: - from ansible.errors import AnsibleError -except ImportError: - AnsibleError = Exception - -try: - from openshift.dynamic.exceptions import DynamicApiError -except ImportError: - pass + from openshift.helper.kubernetes import KubernetesObjectHelper + from openshift.helper.openshift import OpenShiftObjectHelper + from openshift.helper.exceptions import KubernetesException + HAS_K8S_MODULE_HELPER = True +except ImportError as exc: + HAS_K8S_MODULE_HELPER = False class K8sInventoryException(Exception): pass -class K8sInventoryHelper(K8sAnsibleMixin): +class K8sInventoryHelper(object): helper = None transport = 'kubectl' @@ -58,7 +56,7 @@ class K8sInventoryHelper(K8sAnsibleMixin): self.fetch_objects(connections) def fetch_objects(self, connections): - client = self.get_api_client() + self.helper = self.get_helper('v1', 'namespace_list') if connections: if not isinstance(connections, list): @@ -67,50 +65,68 @@ class K8sInventoryHelper(K8sAnsibleMixin): for connection in connections: if not isinstance(connection, dict): raise K8sInventoryException("Expecting connection to be a dictionary.") - client = self.get_api_client(**connection) - name = connection.get('name', self.get_default_host_name(client.configuration.host)) + self.authenticate(connection) + name = connection.get('name', self.get_default_host_name(self.helper.api_client.host)) if connection.get('namespaces'): namespaces = connections['namespaces'] else: - namespaces = self.get_available_namespaces(client) + namespaces = self.get_available_namespaces() for namespace in namespaces: - self.get_pods_for_namespace(client, name, namespace) - self.get_services_for_namespace(client, name, namespace) + self.get_pods_for_namespace(name, namespace) + self.get_services_for_namespace(name, namespace) else: - name = self.get_default_host_name(client.configuration.host) - namespaces = self.get_available_namespaces(client) + name = self.get_default_host_name(self.helper.api_client.host) + namespaces = self.get_available_namespaces() for namespace in namespaces: - self.get_pods_for_namespace(client, name, namespace) - self.get_services_for_namespace(client, name, namespace) + self.get_pods_for_namespace(name, namespace) + self.get_services_for_namespace(name, namespace) + + def authenticate(self, connection=None): + auth_options = {} + if connection: + auth_args = ('host', 'api_key', 'kubeconfig', 'context', 'username', 'password', + 'cert_file', 'key_file', 'ssl_ca_cert', 'verify_ssl') + for key, value in iteritems(connection): + if key in auth_args and value is not None: + auth_options[key] = value + try: + self.helper.set_client_config(**auth_options) + except KubernetesException as exc: + raise K8sInventoryException('Error connecting to the API: {0}'.format(exc.message)) @staticmethod def get_default_host_name(host): return host.replace('https://', '').replace('http://', '').replace('.', '-').replace(':', '_') - def get_available_namespaces(self, client): - v1_namespace = client.resources.get(api_version='v1', kind='Namespace') + def get_helper(self, api_version, kind): + try: + helper = KubernetesObjectHelper(api_version=api_version, kind=kind, debug=False) + helper.get_model(api_version, kind) + return helper + except KubernetesException as exc: + raise K8sInventoryException('Error initializing object helper: {0}'.format(exc.message)) + + def get_available_namespaces(self): try: - obj = v1_namespace.get() - except DynamicApiError as exc: + obj = self.helper.get_object() + except KubernetesObjectHelper as exc: raise K8sInventoryException('Error fetching Namespace list: {0}'.format(exc.message)) return [namespace.metadata.name for namespace in obj.items] - def get_pods_for_namespace(self, client, name, namespace): - v1_pod = client.resources.get(api_version='v1', kind='Pod') + def get_pods_for_namespace(self, name, namespace): + self.helper.set_model('v1', 'pod_list') try: - obj = v1_pod.get(namespace=namespace) - except DynamicApiError as exc: + obj = self.helper.get_object(namespace=namespace) + except KubernetesException as exc: raise K8sInventoryException('Error fetching Pod list: {0}'.format(exc.message)) - namespace_group = 'namespace_{0}'.format(namespace) - namespace_pods_group = '{0}_pods'.format(namespace_group) + namespace_pod_group = '{0}_pods'.format(namespace) self.inventory.add_group(name) - self.inventory.add_group(namespace_group) - self.inventory.add_child(name, namespace_group) - self.inventory.add_group(namespace_pods_group) - self.inventory.add_child(namespace_group, namespace_pods_group) - + self.inventory.add_group(namespace) + self.inventory.add_child(name, namespace) + self.inventory.add_group(namespace_pod_group) + self.inventory.add_child(namespace, namespace_pod_group) for pod in obj.items: pod_name = pod.metadata.name pod_groups = [] @@ -120,17 +136,17 @@ class K8sInventoryHelper(K8sAnsibleMixin): if pod.metadata.labels: pod_labels = pod.metadata.labels # create a group for each label_value - for key, value in pod.metadata.labels: - group_name = 'label_{0}_{1}'.format(key, value) + for key, value in iteritems(pod.metadata.labels): + group_name = '{0}_{1}'.format(key, value) if group_name not in pod_groups: pod_groups.append(group_name) self.inventory.add_group(group_name) - for container in pod.status.containerStatuses: + for container in pod.status.container_statuses: # add each pod_container to the namespace group, and to each label_value group container_name = '{0}_{1}'.format(pod.metadata.name, container.name) self.inventory.add_host(container_name) - self.inventory.add_child(namespace_pods_group, container_name) + self.inventory.add_child(namespace_pod_group, container_name) if pod_groups: for group in pod_groups: self.inventory.add_child(group, container_name) @@ -139,14 +155,14 @@ class K8sInventoryHelper(K8sAnsibleMixin): self.inventory.set_variable(container_name, 'object_type', 'pod') self.inventory.set_variable(container_name, 'labels', pod_labels) self.inventory.set_variable(container_name, 'annotations', pod_annotations) - self.inventory.set_variable(container_name, 'cluster_name', pod.metadata.clusterName) - self.inventory.set_variable(container_name, 'pod_node_name', pod.spec.nodeName) - self.inventory.set_variable(container_name, 'pod_name', pod.spec.name) - self.inventory.set_variable(container_name, 'pod_host_ip', pod.status.hostIP) + self.inventory.set_variable(container_name, 'cluster_name', pod.metadata.cluster_name) + self.inventory.set_variable(container_name, 'pod_node_name', pod.spec.node_name) + self.inventory.set_variable(container_name, 'pod_name', pod.spec.node_name) + self.inventory.set_variable(container_name, 'pod_host_ip', pod.status.host_ip) self.inventory.set_variable(container_name, 'pod_phase', pod.status.phase) - self.inventory.set_variable(container_name, 'pod_ip', pod.status.podIP) - self.inventory.set_variable(container_name, 'pod_self_link', pod.metadata.selfLink) - self.inventory.set_variable(container_name, 'pod_resource_version', pod.metadata.resourceVersion) + self.inventory.set_variable(container_name, 'pod_ip', pod.status.pod_ip) + self.inventory.set_variable(container_name, 'pod_self_link', pod.metadata.self_link) + self.inventory.set_variable(container_name, 'pod_resource_version', pod.metadata.resource_version) self.inventory.set_variable(container_name, 'pod_uid', pod.metadata.uid) self.inventory.set_variable(container_name, 'container_name', container.image) self.inventory.set_variable(container_name, 'container_image', container.image) @@ -163,22 +179,20 @@ class K8sInventoryHelper(K8sAnsibleMixin): self.inventory.set_variable(container_name, 'ansible_{0}_container'.format(self.transport), container.name) - def get_services_for_namespace(self, client, name, namespace): - v1_service = client.resources.get(api_version='v1', kind='Service') + def get_services_for_namespace(self, name, namespace): + self.helper.set_model('v1', 'service_list') try: - obj = v1_service.get(namespace=namespace) - except DynamicApiError as exc: + obj = self.helper.get_object(namespace=namespace) + except KubernetesException as exc: raise K8sInventoryException('Error fetching Service list: {0}'.format(exc.message)) - namespace_group = 'namespace_{0}'.format(namespace) - namespace_services_group = '{0}_services'.format(namespace_group) + namespace_service_group = '{0}_services'.format(namespace) self.inventory.add_group(name) - self.inventory.add_group(namespace_group) - self.inventory.add_child(name, namespace_group) - self.inventory.add_group(namespace_services_group) - self.inventory.add_child(namespace_group, namespace_services_group) - + self.inventory.add_group(namespace) + self.inventory.add_child(name, namespace) + self.inventory.add_group(namespace_service_group) + self.inventory.add_child(namespace, namespace_service_group) for service in obj.items: service_name = service.metadata.name service_labels = {} if not service.metadata.labels else service.metadata.labels @@ -188,54 +202,51 @@ class K8sInventoryHelper(K8sAnsibleMixin): if service.metadata.labels: # create a group for each label_value - for key, value in service.metadata.labels: - group_name = 'label_{0}_{1}'.format(key, value) + for key, value in iteritems(service.metadata.labels): + group_name = '{0}_{1}'.format(key, value) self.inventory.add_group(group_name) self.inventory.add_child(group_name, service_name) - try: - self.inventory.add_child(namespace_services_group, service_name) - except AnsibleError as e: - raise + self.inventory.add_child(namespace_service_group, service_name) ports = [{'name': port.name, 'port': port.port, 'protocol': port.protocol, - 'targetPort': port.targetPort, - 'nodePort': port.nodePort} for port in service.spec.ports or []] + 'targetPort': port.target_port, + 'nodePort': port.node_port} for port in service.spec.ports] # add hostvars self.inventory.set_variable(service_name, 'object_type', 'service') self.inventory.set_variable(service_name, 'labels', service_labels) self.inventory.set_variable(service_name, 'annotations', service_annotations) - self.inventory.set_variable(service_name, 'cluster_name', service.metadata.clusterName) + self.inventory.set_variable(service_name, 'cluster_name', service.metadata.cluster_name) self.inventory.set_variable(service_name, 'ports', ports) self.inventory.set_variable(service_name, 'type', service.spec.type) - self.inventory.set_variable(service_name, 'self_link', service.metadata.selfLink) - self.inventory.set_variable(service_name, 'resource_version', service.metadata.resourceVersion) + self.inventory.set_variable(service_name, 'self_link', service.metadata.self_link) + self.inventory.set_variable(service_name, 'resource_version', service.metadata.resource_version) self.inventory.set_variable(service_name, 'uid', service.metadata.uid) - if service.spec.externalTrafficPolicy: + if service.spec.external_traffic_policy: self.inventory.set_variable(service_name, 'external_traffic_policy', - service.spec.externalTrafficPolicy) - if service.spec.externalIPs: - self.inventory.set_variable(service_name, 'external_ips', service.spec.externalIPs) + service.spec.external_traffic_policy) + if hasattr(service.spec, 'external_ips') and service.spec.external_ips: + self.inventory.set_variable(service_name, 'external_ips', service.spec.external_ips) - if service.spec.externalName: - self.inventory.set_variable(service_name, 'external_name', service.spec.externalName) + if service.spec.external_name: + self.inventory.set_variable(service_name, 'external_name', service.spec.external_name) - if service.spec.healthCheckNodePort: + if service.spec.health_check_node_port: self.inventory.set_variable(service_name, 'health_check_node_port', - service.spec.healthCheckNodePort) - if service.spec.loadBalancerIP: + service.spec.health_check_node_port) + if service.spec.load_balancer_ip: self.inventory.set_variable(service_name, 'load_balancer_ip', - service.spec.loadBalancerIP) + service.spec.load_balancer_ip) if service.spec.selector: self.inventory.set_variable(service_name, 'selector', service.spec.selector) - if hasattr(service.status.loadBalancer, 'ingress') and service.status.loadBalancer.ingress: + if hasattr(service.status.load_balancer, 'ingress') and service.status.load_balancer.ingress: load_balancer = [{'hostname': ingress.hostname, - 'ip': ingress.ip} for ingress in service.status.loadBalancer.ingress] + 'ip': ingress.ip} for ingress in service.status.load_balancer.ingress] self.inventory.set_variable(service_name, 'load_balancer', load_balancer) @@ -245,39 +256,46 @@ class OpenShiftInventoryHelper(K8sInventoryHelper): def fetch_objects(self, connections): super(OpenShiftInventoryHelper, self).fetch_objects(connections) - client = self.get_api_client() + self.helper = self.get_helper('v1', 'namespace_list') if connections: for connection in connections: - client = self.get_api_client(**connection) - name = connection.get('name', self.get_default_host_name(client.configuration.host)) + self.authenticate(connection) + name = connection.get('name', self.get_default_host_name(self.helper.api_client.host)) if connection.get('namespaces'): namespaces = connection['namespaces'] else: - namespaces = self.get_available_namespaces(client) + namespaces = self.get_available_namespaces() for namespace in namespaces: - self.get_routes_for_namespace(client, name, namespace) + self.get_routes_for_namespace(name, namespace) else: - name = self.get_default_host_name(client.configuration.host) - namespaces = self.get_available_namespaces(client) + name = self.get_default_host_name(self.helper.api_client.host) + namespaces = self.get_available_namespaces() for namespace in namespaces: - self.get_routes_for_namespace(client, name, namespace) + self.get_routes_for_namespace(name, namespace) - def get_routes_for_namespace(self, client, name, namespace): - v1_route = client.resources.get(api_version='v1', kind='Route') + def get_helper(self, api_version, kind): + try: + helper = OpenShiftObjectHelper(api_version=api_version, kind=kind, debug=False) + helper.get_model(api_version, kind) + return helper + except KubernetesException as exc: + raise K8sInventoryException('Error initializing object helper: {0}'.format(exc.message)) + + def get_routes_for_namespace(self, name, namespace): + self.helper.set_model('v1', 'route_list') try: - obj = v1_route.get(namespace=namespace) - except DynamicApiError as exc: + obj = self.helper.get_object(namespace=namespace) + except KubernetesException as exc: raise K8sInventoryException('Error fetching Routes list: {0}'.format(exc.message)) - namespace_group = 'namespace_{0}'.format(namespace) - namespace_routes_group = '{0}_routes'.format(namespace_group) + namespace_routes_group = '{0}_routes'.format(namespace) self.inventory.add_group(name) - self.inventory.add_group(namespace_group) - self.inventory.add_child(name, namespace_group) + self.inventory.add_group(namespace) + self.inventory.add_child(name, namespace) self.inventory.add_group(namespace_routes_group) - self.inventory.add_child(namespace_group, namespace_routes_group) + self.inventory.add_child(namespace, namespace_routes_group) for route in obj.items: route_name = route.metadata.name route_labels = {} if not route.metadata.labels else route.metadata.labels @@ -287,8 +305,8 @@ class OpenShiftInventoryHelper(K8sInventoryHelper): if route.metadata.labels: # create a group for each label_value - for key, value in route.metadata.labels: - group_name = 'label_{0}_{1}'.format(key, value) + for key, value in iteritems(route.metadata.labels): + group_name = '{0}_{1}'.format(key, value) self.inventory.add_group(group_name) self.inventory.add_child(group_name, route_name) @@ -297,10 +315,10 @@ class OpenShiftInventoryHelper(K8sInventoryHelper): # add hostvars self.inventory.set_variable(route_name, 'labels', route_labels) self.inventory.set_variable(route_name, 'annotations', route_annotations) - self.inventory.set_variable(route_name, 'cluster_name', route.metadata.clusterName) + self.inventory.set_variable(route_name, 'cluster_name', route.metadata.cluster_name) self.inventory.set_variable(route_name, 'object_type', 'route') - self.inventory.set_variable(route_name, 'self_link', route.metadata.selfLink) - self.inventory.set_variable(route_name, 'resource_version', route.metadata.resourceVersion) + self.inventory.set_variable(route_name, 'self_link', route.metadata.self_link) + self.inventory.set_variable(route_name, 'resource_version', route.metadata.resource_version) self.inventory.set_variable(route_name, 'uid', route.metadata.uid) if route.spec.host: @@ -309,5 +327,5 @@ class OpenShiftInventoryHelper(K8sInventoryHelper): if route.spec.path: self.inventory.set_variable(route_name, 'path', route.spec.path) - if hasattr(route.spec.port, 'targetPort') and route.spec.port.targetPort: + if hasattr(route.spec.port, 'target_port') and route.spec.port.target_port: self.inventory.set_variable(route_name, 'port', route.spec.port) diff --git a/lib/ansible/module_utils/k8s/lookup.py b/lib/ansible/module_utils/k8s/lookup.py new file mode 100644 index 00000000000..a05f3ca8120 --- /dev/null +++ b/lib/ansible/module_utils/k8s/lookup.py @@ -0,0 +1,208 @@ +# +# Copyright 2018 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 __future__ import absolute_import, division, print_function + +import json +import os + +from ansible.module_utils.k8s.common import OpenShiftAnsibleModuleMixin, DateTimeEncoder, remove_secret_data, to_snake +from ansible.module_utils.k8s.helper import AUTH_ARG_SPEC + +try: + from openshift.helper.kubernetes import KubernetesObjectHelper + 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 KubernetesLookup(object): + + def __init__(self): + + if not HAS_K8S_MODULE_HELPER: + raise Exception( + "Requires the OpenShift Python client. Try `pip install openshift`" + ) + + if not HAS_YAML: + raise Exception( + "Requires PyYAML. Try `pip install PyYAML`" + ) + + self.kind = None + self.name = None + self.namespace = None + self.api_version = None + self.label_selector = None + self.field_selector = None + self.include_uninitialized = None + self.resource_definition = None + self.helper = None + self.connection = {} + + def run(self, terms, variables=None, **kwargs): + self.kind = kwargs.get('kind') + self.name = kwargs.get('resource_name') + self.namespace = kwargs.get('namespace') + self.api_version = kwargs.get('api_version', 'v1') + self.label_selector = kwargs.get('label_selector') + self.field_selector = kwargs.get('field_selector') + self.include_uninitialized = kwargs.get('include_uninitialized', False) + + resource_definition = kwargs.get('resource_definition') + src = kwargs.get('src') + if src: + resource_definition = self.load_resource_definition(src) + if resource_definition: + self.params_from_resource_definition(resource_definition) + + if not self.kind: + raise Exception( + "Error: no Kind specified. Use the 'kind' parameter, or provide an object YAML configuration " + "using the 'resource_definition' parameter." + ) + + self.kind = to_snake(self.kind) + self.helper = self.get_helper(self.api_version, self.kind) + + auth_args = ('host', 'api_key', 'kubeconfig', 'context', 'username', 'password', + 'cert_file', 'key_file', 'ssl_ca_cert', 'verify_ssl') + + for arg in AUTH_ARG_SPEC: + if arg in auth_args and kwargs.get(arg) is not None: + self.connection[arg] = kwargs.get(arg) + + try: + self.helper.set_client_config(**self.connection) + except Exception as exc: + raise Exception( + "Client authentication failed: {0}".format(exc.message) + ) + + if self.name: + return self.get_object() + + return self.list_objects() + + def get_helper(self, api_version, kind): + try: + helper = KubernetesObjectHelper(api_version=api_version, kind=kind, debug=False) + helper.get_model(api_version, kind) + return helper + except KubernetesException as exc: + raise Exception("Error initializing helper: {0}".format(exc.message)) + + def load_resource_definition(self, src): + """ Load the requested src path """ + path = os.path.normpath(src) + if not os.path.exists(path): + raise Exception("Error accessing {0}. Does the file exist?".format(path)) + try: + result = yaml.safe_load(open(path, 'r')) + except (IOError, yaml.YAMLError) as exc: + raise Exception("Error loading resource_definition: {0}".format(exc)) + return result + + def params_from_resource_definition(self, defn): + if defn.get('apiVersion'): + self.api_version = defn['apiVersion'] + if defn.get('kind'): + self.kind = defn['kind'] + if defn.get('metadata', {}).get('name'): + self.name = defn['metadata']['name'] + if defn.get('metadata', {}).get('namespace'): + self.namespace = defn['metadata']['namespace'] + + def get_object(self): + """ Fetch a named object """ + try: + result = self.helper.get_object(self.name, self.namespace) + except KubernetesException as exc: + raise Exception('Failed to retrieve requested object: {0}'.format(exc.message)) + response = [] + if result is not None: + # Convert Datetime objects to ISO format + result_json = json.loads(json.dumps(result.to_dict(), cls=DateTimeEncoder)) + if self.kind == 'secret': + remove_secret_data(result_json) + response.append(result_json) + + return response + + def list_objects(self): + """ Query for a set of objects """ + if self.namespace: + method_name = 'list_namespaced_{0}'.format(self.kind) + try: + method = self.helper.lookup_method(method_name=method_name) + except KubernetesException: + raise Exception( + "Failed to find method {0} for API {1}".format(method_name, self.api_version) + ) + else: + method_name = 'list_{0}_for_all_namespaces'.format(self.kind) + try: + method = self.helper.lookup_method(method_name=method_name) + except KubernetesException: + method_name = 'list_{0}'.format(self.kind) + try: + method = self.helper.lookup_method(method_name=method_name) + except KubernetesException: + raise Exception( + "Failed to find method for API {0} and Kind {1}".format(self.api_version, self.kind) + ) + + params = {} + if self.field_selector: + params['field_selector'] = self.field_selector + if self.label_selector: + params['label_selector'] = self.label_selector + params['include_uninitialized'] = self.include_uninitialized + + if self.namespace: + try: + result = method(self.namespace, **params) + except KubernetesException as exc: + raise Exception(exc.message) + else: + try: + result = method(**params) + except KubernetesException as exc: + raise Exception(exc.message) + + response = [] + if result is not None: + # Convert Datetime objects to ISO format + result_json = json.loads(json.dumps(result.to_dict(), cls=DateTimeEncoder)) + response = result_json.get('items', []) + if self.kind == 'secret': + for item in response: + remove_secret_data(item) + return response + + +class OpenShiftLookup(OpenShiftAnsibleModuleMixin, KubernetesLookup): + pass diff --git a/lib/ansible/module_utils/k8s/raw.py b/lib/ansible/module_utils/k8s/raw.py index c3e1ccb88f2..7b7a68ca373 100644 --- a/lib/ansible/module_utils/k8s/raw.py +++ b/lib/ansible/module_utils/k8s/raw.py @@ -18,12 +18,13 @@ from __future__ import absolute_import, division, print_function +import copy -from ansible.module_utils.k8s.common import KubernetesAnsibleModule - +from ansible.module_utils.k8s.helper import COMMON_ARG_SPEC, AUTH_ARG_SPEC, OPENSHIFT_ARG_SPEC +from ansible.module_utils.k8s.common import KubernetesAnsibleModule, OpenShiftAnsibleModuleMixin, to_snake try: - from openshift.dynamic.exceptions import DynamicApiError, NotFoundError, ConflictError + from openshift.helper.exceptions import KubernetesException except ImportError: # Exception handled in common pass @@ -32,8 +33,6 @@ except ImportError: class KubernetesRawModule(KubernetesAnsibleModule): def __init__(self, *args, **kwargs): - self.client = None - mutually_exclusive = [ ('resource_definition', 'src'), ] @@ -43,140 +42,170 @@ class KubernetesRawModule(KubernetesAnsibleModule): supports_check_mode=True, **kwargs) - kind = self.params.pop('kind') - api_version = self.params.pop('api_version') - name = self.params.pop('name') - namespace = self.params.pop('namespace') - resource_definition = self.params.pop('resource_definition') - if resource_definition: - self.resource_definitions = [resource_definition] - src = self.params.pop('src') - if src: - self.resource_definitions = self.load_resource_definitions(src) - - if not resource_definition and not src: - self.resource_definitions = [{ - 'kind': kind, - 'apiVersion': api_version, - 'metadata': { - 'name': name, - 'namespace': namespace - } - }] + 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 = 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(COMMON_ARG_SPEC) + argspec.update(copy.deepcopy(AUTH_ARG_SPEC)) + return argspec def execute_module(self): - changed = False - results = [] - self.client = self.get_api_client() - for definition in self.resource_definitions: - kind = definition.get('kind') - search_kind = kind - if kind.lower().endswith('list'): - search_kind = kind[:-4] - api_version = definition.get('apiVersion') - try: - resource = self.client.resources.get(kind=search_kind, api_version=api_version) - except Exception as e: - self.fail_json(msg='Failed to find resource {0}.{1}: {2}'.format( - api_version, search_kind, e - )) - result = self.perform_action(resource, definition) - changed = changed or result['changed'] - results.append(result) - - if len(results) == 1: - self.exit_json(**results[0]) - - self.exit_json(**{ - 'changed': changed, - 'result': { - 'results': results - } - }) - - def perform_action(self, resource, definition): - result = {'changed': False, 'result': {}} + if self.resource_definition: + resource_params = self.resource_to_parameters(self.resource_definition) + self.params.update(resource_params) + + self.authenticate() + state = self.params.pop('state', None) force = self.params.pop('force', False) - name = definition.get('metadata', {}).get('name') - namespace = definition.get('metadata', {}).get('namespace') + name = self.params.get('name') + namespace = self.params.get('namespace') existing = None self.remove_aliases() - if definition['kind'].endswith('list'): - result['result'] = resource.get(namespace=namespace).to_dict() - result['changed'] = False - result['method'] = 'get' - return result + return_attributes = dict(changed=False, result=dict()) + + 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: - existing = resource.get(name=name, namespace=namespace) - except NotFoundError: - pass - except DynamicApiError as exc: - self.fail_json(msg='Failed to retrieve requested object: {0}'.format(exc.body), - error=exc.status, status=exc.status, reason=exc.reason) + existing = self.helper.get_object(name, namespace) + except KubernetesException as exc: + self.fail_json(msg='Failed to retrieve requested object: {0}'.format(exc.message), + error=exc.value.get('status')) if state == 'absent': - result['method'] = "delete" if not existing: # The object already does not exist - return result + self.exit_json(**return_attributes) else: # Delete the object if not self.check_mode: try: - k8s_obj = resource.delete(name, namespace=namespace) - result['result'] = k8s_obj.to_dict() - except DynamicApiError as exc: - self.fail_json(msg="Failed to delete object: {0}".format(exc.body), - error=exc.status, status=exc.status, reason=exc.reason) - result['changed'] = True - return result + self.helper.delete_object(name, namespace) + except KubernetesException as exc: + 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: - if not self.check_mode: - try: - k8s_obj = resource.create(definition, namespace=namespace) - except ConflictError: - # Some resources, like ProjectRequests, can't be created multiple times, - # because the resources that they create don't match their kind - # In this case we'll mark it as unchanged and warn the user - self.warn("{0} was not found, but creating it returned a 409 Conflict error. This can happen \ - if the resource you are creating does not directly create a resource of the same kind.".format(name)) - return result - result['result'] = k8s_obj.to_dict() - result['changed'] = True - result['method'] = 'create' - return result + k8s_obj = self._create(namespace) + return_attributes['result'] = 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 = resource.replace(definition, name=name, namespace=namespace) - result['result'] = k8s_obj.to_dict() - except DynamicApiError as exc: - self.fail_json(msg="Failed to replace object: {0}".format(exc.body), - error=exc.status, status=exc.status, reason=exc.reason) - result['changed'] = True - result['method'] = 'replace' - return result - - match, diffs = self.diff_objects(existing.to_dict(), definition) - + k8s_obj = self.helper.replace_object(name, namespace, body=request_body) + except KubernetesException as exc: + self.fail_json(msg="Failed to replace object: {0}".format(exc.message), + error=exc.value.get('status')) + return_attributes['result'] = 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: {0}".format(exc.message)) + match, diff = self.helper.objects_match(self.helper.fix_serialization(existing), k8s_obj) if match: - result['result'] = existing.to_dict() - return result + return_attributes['result'] = existing.to_dict() + self.exit_json(**return_attributes) # Differences exist between the existing obj and requested params if not self.check_mode: try: - k8s_obj = resource.patch(definition, name=name, namespace=namespace) - result['result'] = k8s_obj.to_dict() - except DynamicApiError as exc: - self.fail_json(msg="Failed to patch object: {0}".format(exc.body), - error=exc.status, status=exc.status, reason=exc.reason) - result['changed'] = True - result['method'] = 'patch' - result['diff'] = diffs - return result + k8s_obj = self.helper.patch_object(name, namespace, k8s_obj) + except KubernetesException as exc: + 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 _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: {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: {0}".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 + + +class OpenShiftRawModule(OpenShiftAnsibleModuleMixin, KubernetesRawModule): + + @property + def argspec(self): + args = super(OpenShiftRawModule, self).argspec + args.update(copy.deepcopy(OPENSHIFT_ARG_SPEC)) + return args + + def _create(self, namespace): + if self.kind.lower() == 'project': + return self._create_project() + return KubernetesRawModule._create(self, 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: {0}".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 diff --git a/lib/ansible/module_utils/k8s/scale.py b/lib/ansible/module_utils/k8s/scale.py index d0d6cc223c8..3441bb89fc8 100644 --- a/lib/ansible/module_utils/k8s/scale.py +++ b/lib/ansible/module_utils/k8s/scale.py @@ -22,12 +22,13 @@ import copy import math import time +from ansible.module_utils.six import iteritems +from ansible.module_utils.k8s.common import OpenShiftAnsibleModuleMixin from ansible.module_utils.k8s.raw import KubernetesRawModule -from ansible.module_utils.k8s.common import AUTH_ARG_SPEC, COMMON_ARG_SPEC +from ansible.module_utils.k8s.helper import AUTH_ARG_SPEC, COMMON_ARG_SPEC try: from openshift import watch - from openshift.dynamic.client import ResourceInstance from openshift.helper.exceptions import KubernetesException except ImportError as exc: class KubernetesException(Exception): @@ -46,12 +47,14 @@ SCALE_ARG_SPEC = { class KubernetesAnsibleScaleModule(KubernetesRawModule): def execute_module(self): - definition = self.resource_definitions[0] + if self.resource_definition: + resource_params = self.resource_to_parameters(self.resource_definition) + self.params.update(resource_params) - self.client = self.get_api_client() + self.authenticate() - name = definition['metadata']['name'] - namespace = definition['metadata'].get('namespace') + name = self.params.get('name') + namespace = self.params.get('namespace') current_replicas = self.params.get('current_replicas') replicas = self.params.get('replicas') resource_version = self.params.get('resource_version') @@ -62,10 +65,8 @@ class KubernetesAnsibleScaleModule(KubernetesRawModule): existing_count = None return_attributes = dict(changed=False, result=dict()) - resource = self.client.resources.get(api_version=definition['apiVersion'], kind=definition['kind']) - try: - existing = resource.get(name=name, namespace=namespace) + existing = self.helper.get_object(name, namespace) return_attributes['result'] = existing.to_dict() except KubernetesException as exc: self.fail_json(msg='Failed to retrieve requested object: {0}'.format(exc.message), @@ -79,7 +80,7 @@ class KubernetesAnsibleScaleModule(KubernetesRawModule): if existing_count is None: self.fail_json(msg='Failed to retrieve the available count for the requested object.') - if resource_version and resource_version != existing.metadata.resourceVersion: + if resource_version and resource_version != existing.metadata.resource_version: self.exit_json(**return_attributes) if current_replicas is not None and existing_count != current_replicas: @@ -90,13 +91,25 @@ class KubernetesAnsibleScaleModule(KubernetesRawModule): if not self.check_mode: if self.kind == 'job': existing.spec.parallelism = replicas - k8s_obj = resource.patch(existing.to_dict()) + k8s_obj = self.helper.patch_object(name, namespace, existing) else: - k8s_obj = self.scale(resource, existing, replicas, wait, wait_time) + k8s_obj = self.scale(existing, replicas, wait, wait_time) return_attributes['result'] = k8s_obj.to_dict() self.exit_json(**return_attributes) + def resource_to_parameters(self, resource): + """ Converts a resource definition to module parameters """ + parameters = {} + 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 iteritems(value): + if meta_key in ('name', 'namespace', 'resourceVersion'): + parameters[meta_key] = meta_value + return parameters + @property def argspec(self): args = copy.deepcopy(COMMON_ARG_SPEC) @@ -106,67 +119,91 @@ class KubernetesAnsibleScaleModule(KubernetesRawModule): args.update(SCALE_ARG_SPEC) return args - def scale(self, resource, existing_object, replicas, wait, wait_time): + def scale(self, existing_object, replicas, wait, wait_time): name = existing_object.metadata.name namespace = existing_object.metadata.namespace + method_name = 'patch_namespaced_{0}_scale'.format(self.kind) + method = None + model = None - if not hasattr(resource, 'scale'): + try: + method = self.helper.lookup_method(method_name=method_name) + except KubernetesException: self.fail_json( - msg="Cannot perform scale on resource of kind {0}".format(resource.kind) + msg="Failed to get method {0}. Is 'scale' a valid operation for {1}?".format(method_name, self.kind) ) - scale_obj = {'metadata': {'name': name, 'namespace': namespace}, 'spec': {'replicas': replicas}} + try: + model = self.helper.get_model(self.api_version, 'scale') + except KubernetesException: + self.fail_json( + msg="Failed to fetch the 'Scale' model for API version {0}. Are you using the correct " + "API?".format(self.api_version) + ) + + scale_obj = model() + scale_obj.kind = 'scale' + scale_obj.api_version = self.api_version.lower() + scale_obj.metadata = self.helper.get_model( + self.api_version, + self.helper.get_base_model_name(scale_obj.swagger_types['metadata']) + )() + scale_obj.metadata.name = name + scale_obj.metadata.namespace = namespace + scale_obj.spec = self.helper.get_model( + self.api_version, + self.helper.get_base_model_name(scale_obj.swagger_types['spec']) + )() + scale_obj.spec.replicas = replicas return_obj = None stream = None if wait: - w, stream = self._create_stream(resource, namespace, wait_time) + w, stream = self._create_stream(namespace, wait_time) try: - resource.scale.patch(body=scale_obj) + method(name, namespace, scale_obj) except Exception as exc: self.fail_json( msg="Scale request failed: {0}".format(exc.message) ) if wait and stream is not None: - return_obj = self._read_stream(resource, w, stream, name, replicas) + return_obj = self._read_stream(w, stream, name, replicas) if not return_obj: return_obj = self._wait_for_response(name, namespace) return return_obj - def _create_stream(self, resource, namespace, wait_time): + def _create_stream(self, namespace, wait_time): """ Create a stream of events for the object """ w = None stream = None try: + list_method = self.helper.lookup_method('list', namespace) w = watch.Watch() - w._api_client = self.client.client + w._api_client = self.helper.api_client if namespace: - stream = w.stream(resource.get, serialize=False, namespace=namespace, timeout_seconds=wait_time) + stream = w.stream(list_method, namespace, timeout_seconds=wait_time) else: - stream = w.stream(resource.get, serialize=False, namespace=namespace, timeout_seconds=wait_time) + stream = w.stream(list_method, timeout_seconds=wait_time) except KubernetesException: pass + except Exception: + raise return w, stream - def _read_stream(self, resource, watcher, stream, name, replicas): + def _read_stream(self, watcher, stream, name, replicas): """ Wait for ready_replicas to equal the requested number of replicas. """ return_obj = None try: for event in stream: if event.get('object'): - obj = ResourceInstance(resource, event['object']) + obj = event['object'] if obj.metadata.name == name and hasattr(obj, 'status'): - if replicas == 0: - if not hasattr(obj.status, 'readyReplicas') or not obj.status.readyReplicas: - return_obj = obj - watcher.stop() - break - if hasattr(obj.status, 'readyReplicas') and obj.status.readyReplicas == replicas: + if hasattr(obj.status, 'ready_replicas') and obj.status.ready_replicas == replicas: return_obj = obj watcher.stop() break @@ -175,23 +212,27 @@ class KubernetesAnsibleScaleModule(KubernetesRawModule): if not return_obj: self.fail_json(msg="Error fetching the patched object. Try a higher wait_timeout value.") - if replicas and return_obj.status.readyReplicas is None: + if return_obj.status.ready_replicas is None: self.fail_json(msg="Failed to fetch the number of ready replicas. Try a higher wait_timeout value.") - if replicas and return_obj.status.readyReplicas != replicas: + if return_obj.status.ready_replicas != replicas: self.fail_json(msg="Number of ready replicas is {0}. Failed to reach {1} ready replicas within " "the wait_timeout period.".format(return_obj.status.ready_replicas, replicas)) return return_obj - def _wait_for_response(self, resource, name, namespace): + def _wait_for_response(self, name, namespace): """ Wait for an API response """ tries = 0 half = math.ceil(20 / 2) obj = None while tries <= half: - obj = resource.get(name=name, namespace=namespace) + obj = self.helper.get_object(name, namespace) if obj: break tries += 2 time.sleep(2) return obj + + +class OpenShiftAnsibleScaleModule(OpenShiftAnsibleModuleMixin, KubernetesAnsibleScaleModule): + pass diff --git a/lib/ansible/modules/clustering/k8s/_k8s_raw.py b/lib/ansible/modules/clustering/k8s/_k8s_raw.py deleted file mode 120000 index b0ee072f962..00000000000 --- a/lib/ansible/modules/clustering/k8s/_k8s_raw.py +++ /dev/null @@ -1 +0,0 @@ -k8s.py \ No newline at end of file diff --git a/lib/ansible/modules/clustering/k8s/k8s.py b/lib/ansible/modules/clustering/k8s/k8s_raw.py similarity index 96% rename from lib/ansible/modules/clustering/k8s/k8s.py rename to lib/ansible/modules/clustering/k8s/k8s_raw.py index 57d0408dceb..ff54c678066 100644 --- a/lib/ansible/modules/clustering/k8s/k8s.py +++ b/lib/ansible/modules/clustering/k8s/k8s_raw.py @@ -15,15 +15,13 @@ ANSIBLE_METADATA = {'metadata_version': '1.1', DOCUMENTATION = ''' -module: k8s +module: k8s_raw short_description: Manage Kubernetes (K8s) objects -version_added: "2.6" +version_added: "2.5" -author: - - "Chris Houseknecht (@chouseknecht)" - - "Fabian von Feilitzsch (@fabianvf)" +author: "Chris Houseknecht (@chouseknecht)" description: - Use the OpenShift Python client to perform CRUD operations on K8s objects. @@ -41,7 +39,7 @@ extends_documentation_fragment: requirements: - "python >= 2.7" - - "openshift >= 0.6" + - "openshift == 0.4.3" - "PyYAML >= 3.11" ''' diff --git a/lib/ansible/modules/clustering/k8s/k8s_scale.py b/lib/ansible/modules/clustering/k8s/k8s_scale.py index f438aaa0e4c..5fb3dec414b 100644 --- a/lib/ansible/modules/clustering/k8s/k8s_scale.py +++ b/lib/ansible/modules/clustering/k8s/k8s_scale.py @@ -21,9 +21,7 @@ short_description: Set a new size for a Deployment, ReplicaSet, Replication Cont version_added: "2.5" -author: - - "Chris Houseknecht (@chouseknecht)" - - "Fabian von Feilitzsch (@fabianvf)" +author: "Chris Houseknecht (@chouseknecht)" description: - Similar to the kubectl scale command. Use to set the number of replicas for a Deployment, ReplicatSet, @@ -37,7 +35,7 @@ extends_documentation_fragment: requirements: - "python >= 2.7" - - "openshift >= 0.6" + - "openshift == 0.4.3" - "PyYAML >= 3.11" ''' diff --git a/lib/ansible/modules/clustering/openshift/_openshift_raw.py b/lib/ansible/modules/clustering/openshift/_openshift_raw.py deleted file mode 120000 index 53f9af49a89..00000000000 --- a/lib/ansible/modules/clustering/openshift/_openshift_raw.py +++ /dev/null @@ -1 +0,0 @@ -../k8s/k8s.py \ No newline at end of file diff --git a/lib/ansible/modules/clustering/openshift/_openshift_scale.py b/lib/ansible/modules/clustering/openshift/_openshift_scale.py deleted file mode 120000 index bfa5417ea0e..00000000000 --- a/lib/ansible/modules/clustering/openshift/_openshift_scale.py +++ /dev/null @@ -1 +0,0 @@ -../k8s/k8s_scale.py \ No newline at end of file diff --git a/lib/ansible/modules/clustering/openshift/openshift_raw.py b/lib/ansible/modules/clustering/openshift/openshift_raw.py new file mode 100644 index 00000000000..bf39917d111 --- /dev/null +++ b/lib/ansible/modules/clustering/openshift/openshift_raw.py @@ -0,0 +1,204 @@ +#!/usr/bin/python +# -*- coding: utf-8 -*- + +# (c) 2018, Chris Houseknecht <@chouseknecht> +# GNU General Public License v3.0+ (see COPYING or https://www.gnu.org/licenses/gpl-3.0.txt) + +from __future__ import absolute_import, division, print_function + +__metaclass__ = type + + +ANSIBLE_METADATA = {'metadata_version': '1.1', + 'status': ['preview'], + 'supported_by': 'community'} + +DOCUMENTATION = ''' + +module: openshift_raw + +short_description: Manage OpenShift objects + +version_added: "2.5" + +author: "Chris Houseknecht (@chouseknecht)" + +description: + - Use the OpenShift Python client to perform CRUD operations on OpenShift objects. + - Pass the object definition from a source file or inline. See examples for reading + files and using Jinja templates. + - Access to the full range of K8s and OpenShift APIs. + - Authenticate using either a config file, certificates, password or token. + - Supports check mode. + +extends_documentation_fragment: + - k8s_state_options + - k8s_name_options + - k8s_resource_options + - k8s_auth_options + +options: + description: + description: + - Use only when creating a project, otherwise ignored. Adds a description to the project + metadata. + display_name: + description: + - Use only when creating a project, otherwise ignored. Adds a display name to the project + metadata. + +requirements: + - "python >= 2.7" + - "openshift == 0.4.3" + - "PyYAML >= 3.11" +''' + +EXAMPLES = ''' +- name: Create a project + openshift_raw: + api_version: v1 + kind: Project + name: testing + description: Testing + display_name: "This is a test project." + state: present + +- name: Create a Persistent Volume Claim from an inline definition + openshift_raw: + state: present + definition: + apiVersion: v1 + kind: PersistentVolumeClaim + metadata: + name: elastic-volume + namespace: testing + spec: + resources: + requests: + storage: 5Gi + accessModes: + - ReadWriteOnce + +- name: Create a Deployment from an inline definition + openshift_raw: + state: present + definition: + apiVersion: v1 + kind: DeploymentConfig + metadata: + name: elastic + labels: + app: galaxy + service: elastic + namespace: testing + spec: + template: + metadata: + labels: + app: galaxy + service: elastic + spec: + containers: + - name: elastic + volumeMounts: + - mountPath: /usr/share/elasticsearch/data + name: elastic-volume + command: ["elasticsearch"] + image: "ansible/galaxy-elasticsearch:2.4.6" + volumes: + - name: elastic-volume + persistentVolumeClaim: + claimName: elastic-volume + replicas: 1 + strategy: + type: Rolling + +- name: Remove an existing Deployment + openshift_raw: + api_version: v1 + kind: DeploymentConfig + name: elastic + namespace: testing + state: absent + +- name: Create a Secret + openshift_raw: + definition: + apiVersion: v1 + kind: Secret + metadata: + name: mysecret + namespace: testing + type: Opaque + data: + username: "{{ 'admin' | b64encode }}" + password: "{{ 'foobard' | b64encode }}" + +- name: Retrieve a Secret + openshift_raw: + api: v1 + kind: Secret + name: mysecret + namespace: testing + register: mysecret + +# Passing the object definition from a file + +- name: Create a Deployment by reading the definition from a local file + openshift_raw: + state: present + src: /testing/deployment.yml + +- name: Read definition file from the Ansible controller file system + openshift_raw: + state: present + definition: "{{ lookup('file', '/testing/deployment.yml') | from_yaml }}" + +- name: Read definition file from the Ansible controller file system after Jinja templating + openshift_raw: + state: present + definition: "{{ lookup('template', '/testing/deployment.yml') | from_yaml }}" +''' + +RETURN = ''' +result: + description: + - The created, patched, or otherwise present object. Will be empty in the case of a deletion. + returned: success + type: complex + contains: + api_version: + description: The versioned schema of this representation of an object. + returned: success + type: str + kind: + description: Represents the REST resource this object represents. + returned: success + type: str + metadata: + description: Standard object metadata. Includes name, namespace, annotations, labels, etc. + returned: success + type: complex + spec: + description: Specific attributes of the object. Will vary based on the I(api_version) and I(kind). + returned: success + type: complex + status: + description: Current status details for the object. + returned: success + type: complex + items: + description: Returned only when the I(kind) is a List type resource. Contains a set of objects. + returned: when resource is a List + type: list +''' + +from ansible.module_utils.k8s.raw import OpenShiftRawModule + + +def main(): + OpenShiftRawModule().execute_module() + + +if __name__ == '__main__': + main() diff --git a/lib/ansible/modules/clustering/openshift/openshift_scale.py b/lib/ansible/modules/clustering/openshift/openshift_scale.py new file mode 100644 index 00000000000..e8e3dd26d95 --- /dev/null +++ b/lib/ansible/modules/clustering/openshift/openshift_scale.py @@ -0,0 +1,127 @@ +#!/usr/bin/python +# -*- coding: utf-8 -*- + +# (c) 2018, Chris Houseknecht <@chouseknecht> +# GNU General Public License v3.0+ (see COPYING or https://www.gnu.org/licenses/gpl-3.0.txt) + +from __future__ import absolute_import, division, print_function + + +__metaclass__ = type + +ANSIBLE_METADATA = {'metadata_version': '1.1', + 'status': ['preview'], + 'supported_by': 'community'} + +DOCUMENTATION = ''' + +module: openshift_scale + +short_description: Set a new size for a Deployment Config, Deployment, Replica Set, Replication Controller, or Job. + +version_added: "2.5" + +author: "Chris Houseknecht (@chouseknecht)" + +description: + - Similar to the oc scale command. Use to set the number of replicas for a Deployment Config, Deployment, + ReplicatSet, or Replication Controller, or the parallelism attribute of a Job. Supports check mode. + +extends_documentation_fragment: + - k8s_name_options + - k8s_auth_options + - k8s_resource_options + - k8s_scale_options + +requirements: + - "python >= 2.7" + - "openshift == 0.4.3" + - "PyYAML >= 3.11" +''' + +EXAMPLES = ''' +- name: Scale deployment config up, and extend timeout + openshift_scale: + api_version: v1 + kind: DeploymentConfig + name: elastic + namespace: myproject + replicas: 3 + wait_timeout: 60 + +- name: Scale deployment config down when current replicas match + openshift_scale: + api_version: v1 + kind: DeploymentConfig + name: elastic + namespace: myproject + current_replicas: 3 + replicas: 2 + +- name: Increase job parallelism + openshift_scale: + api_version: batch/v1 + kind: job + name: pi-with-timeout + namespace: testing + replicas: 2 + +# Match object using local file or inline definition + +- name: Scale deployment based on a file from the local filesystem + openshift_scale: + src: /myproject/elastic_deployment.yml + replicas: 3 + wait: no + +- name: Scale deployment based on a template output + openshift_scale: + resource_definition: "{{ lookup('template', '/myproject/elastic_deployment.yml') | from_yaml }}" + replicas: 3 + wait: no + +- name: Scale deployment based on a file from the Ansible controller filesystem + openshift_scale: + resource_definition: "{{ lookup('file', '/myproject/elastic_deployment.yml') | from_yaml }}" + replicas: 3 + wait: no +''' + +RETURN = ''' +result: + description: + - If a change was made, will return the patched object, otherwise returns the existing object. + returned: success + type: complex + contains: + api_version: + description: The versioned schema of this representation of an object. + returned: success + type: str + kind: + description: Represents the REST resource this object represents. + returned: success + type: str + metadata: + description: Standard object metadata. Includes name, namespace, annotations, labels, etc. + returned: success + type: complex + spec: + description: Specific attributes of the object. Will vary based on the I(api_version) and I(kind). + returned: success + type: complex + status: + description: Current status details for the object. + returned: success + type: complex +''' + +from ansible.module_utils.k8s.scale import OpenShiftAnsibleScaleModule + + +def main(): + OpenShiftAnsibleScaleModule().execute_module() + + +if __name__ == '__main__': + main() diff --git a/lib/ansible/plugins/inventory/k8s.py b/lib/ansible/plugins/inventory/k8s.py index 9ce2c4c2a1e..b5089288885 100644 --- a/lib/ansible/plugins/inventory/k8s.py +++ b/lib/ansible/plugins/inventory/k8s.py @@ -9,7 +9,6 @@ DOCUMENTATION = ''' plugin_type: inventory authors: - Chris Houseknecht <@chouseknecht> - - Fabian von Feilitzsch <@fabianvf> short_description: Kubernetes (K8s) inventory source @@ -77,7 +76,7 @@ DOCUMENTATION = ''' requirements: - "python >= 2.7" - - "openshift >= 0.6" + - "openshift == 0.4.1" - "PyYAML >= 3.11" ''' diff --git a/lib/ansible/plugins/inventory/openshift.py b/lib/ansible/plugins/inventory/openshift.py index 6b1dabe5b86..17b8f347ede 100644 --- a/lib/ansible/plugins/inventory/openshift.py +++ b/lib/ansible/plugins/inventory/openshift.py @@ -77,7 +77,7 @@ DOCUMENTATION = ''' requirements: - "python >= 2.7" - - "openshift >= 0.6" + - "openshift == 0.4.1" - "PyYAML >= 3.11" ''' diff --git a/lib/ansible/plugins/lookup/_openshift.py b/lib/ansible/plugins/lookup/_openshift.py deleted file mode 120000 index b0ee072f962..00000000000 --- a/lib/ansible/plugins/lookup/_openshift.py +++ /dev/null @@ -1 +0,0 @@ -k8s.py \ No newline at end of file diff --git a/lib/ansible/plugins/lookup/k8s.py b/lib/ansible/plugins/lookup/k8s.py index 8e7abe64b03..5b7c6069482 100644 --- a/lib/ansible/plugins/lookup/k8s.py +++ b/lib/ansible/plugins/lookup/k8s.py @@ -29,15 +29,11 @@ DOCUMENTATION = """ description: - Uses the OpenShift Python client to fetch a specific object by name, all matching objects within a - namespace, or all matching objects for all namespaces, as well as information about the cluster. + namespace, or all matching objects for all namespaces. - Provides access the full range of K8s APIs. - Enables authentication via config file, certificates, password or token. options: - cluster_info: - description: - - Use to specify the type of cluster information you are attempting to retrieve. Will take priority - over all the other options. api_version: description: - Use to specify the API version. If I(resource definition) is provided, the I(apiVersion) from the @@ -119,7 +115,7 @@ DOCUMENTATION = """ requirements: - "python >= 2.7" - - "openshift >= 0.6" + - "openshift == 0.4.1" - "PyYAML >= 3.11" notes: @@ -193,95 +189,7 @@ RETURN = """ """ from ansible.plugins.lookup import LookupBase - -import os - -from ansible.module_utils.six import iteritems -from ansible.module_utils.k8s.common import K8sAnsibleMixin - -try: - from openshift.dynamic import DynamicClient - from openshift.dynamic.exceptions import NotFoundError - 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 KubernetesLookup(K8sAnsibleMixin): - - def __init__(self): - - if not HAS_K8S_MODULE_HELPER: - raise Exception( - "Requires the OpenShift Python client. Try `pip install openshift`" - ) - - if not HAS_YAML: - raise Exception( - "Requires PyYAML. Try `pip install PyYAML`" - ) - - self.kind = None - self.name = None - self.namespace = None - self.api_version = None - self.label_selector = None - self.field_selector = None - self.include_uninitialized = None - self.resource_definition = None - self.helper = None - self.connection = {} - - def run(self, terms, variables=None, **kwargs): - self.params = kwargs - self.client = self.get_api_client() - - cluster_info = kwargs.get('cluster_info') - if cluster_info == 'version': - return [self.client.version] - if cluster_info == 'api_groups': - return [self.client.resources.api_groups] - - self.kind = kwargs.get('kind') - self.name = kwargs.get('resource_name') - self.namespace = kwargs.get('namespace') - self.api_version = kwargs.get('api_version', 'v1') - self.label_selector = kwargs.get('label_selector') - self.field_selector = kwargs.get('field_selector') - self.include_uninitialized = kwargs.get('include_uninitialized', False) - - resource_definition = kwargs.get('resource_definition') - src = kwargs.get('src') - if src: - resource_definition = self.load_resource_definitions(src)[0] - if resource_definition: - self.kind = resource_definition.get('kind', self.kind) - self.api_version = resource_definition.get('apiVersion', self.api_version) - self.name = resource_definition.get('metadata', {}).get('name', self.name) - self.namespace = resource_definition.get('metadata', {}).get('namespace', self.namespace) - - if not self.kind: - raise Exception( - "Error: no Kind specified. Use the 'kind' parameter, or provide an object YAML configuration " - "using the 'resource_definition' parameter." - ) - - resource = self.client.resources.get(kind=self.kind, api_version=self.api_version) - try: - k8s_obj = resource.get(name=self.name, namespace=self.namespace, label_selector=self.label_selector, field_selector=self.field_selector) - except NotFoundError: - return [] - - if self.name: - return [k8s_obj.to_dict()] - - return k8s_obj.to_dict().get('items') +from ansible.module_utils.k8s.lookup import KubernetesLookup class LookupModule(LookupBase): diff --git a/lib/ansible/plugins/lookup/openshift.py b/lib/ansible/plugins/lookup/openshift.py new file mode 100644 index 00000000000..fffeca777bc --- /dev/null +++ b/lib/ansible/plugins/lookup/openshift.py @@ -0,0 +1,197 @@ +# +# Copyright 2018 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 __future__ import (absolute_import, division, print_function) + +__metaclass__ = type + +DOCUMENTATION = """ + lookup: openshift + + version_added: "2.5" + + short_description: Query the OpenShift API + + description: + - Uses the OpenShift Python client to fetch a specific object by name, all matching objects within a + namespace, or all matching objects for all namespaces. + - Provides access the full range of K8s APIs. + - Enables authentication via config file, certificates, password or token. + + options: + api_version: + description: + - Use to specify the API version. If I(resource definition) is provided, the I(apiVersion) from the + I(resource_definition) will override this option. + default: v1 + kind: + description: + - Use to specify an object model. If I(resource definition) is provided, the I(kind) from a + I(resource_definition) will override this option. + required: true + resource_name: + description: + - Fetch a specific object by name. If I(resource definition) is provided, the I(metadata.name) value + from the I(resource_definition) will override this option. + namespace: + description: + - Limit the objects returned to a specific namespace. If I(resource definition) is provided, the + I(metadata.namespace) value from the I(resource_definition) will override this option. + label_selector: + description: + - Additional labels to include in the query. Ignored when I(resource_name) is provided. + field_selector: + description: + - Specific fields on which to query. Ignored when I(resource_name) is provided. + resource_definition: + description: + - "Provide a YAML configuration for an object. NOTE: I(kind), I(api_version), I(resource_name), I(namespace), + and I(resource_version) will be overwritten by corresponding values found in the provided + I(resource_definition)." + src: + description: + - "Provide a path to a file containing a valid YAML definition of an object dated. Mutually + exclusive with I(resource_definition). NOTE: I(kind), I(api_version), I(resource_name), and I(namespace) + will be overwritten by corresponding values found in the configuration read in from the I(src) file." + - Reads from the local file system. To read from the Ansible controller's file system, use the file lookup + plugin or template lookup plugin, combined with the from_yaml filter, and pass the result to + I(resource_definition). See Examples below. + host: + description: + - Provide a URL for accessing the API. Can also be specified via K8S_AUTH_HOST environment variable. + api_key: + description: + - Token used to authenticate with the API. Can also be specified via K8S_AUTH_API_KEY environment variable. + kubeconfig: + description: + - Path to an existing Kubernetes config file. If not provided, and no other connection + options are provided, the openshift client will attempt to load the default + configuration file from I(~/.kube/config.json). Can also be specified via K8S_AUTH_KUBECONFIG environment + variable. + context: + description: + - The name of a context found in the config file. Can also be specified via K8S_AUTH_CONTEXT environment + variable. + username: + description: + - Provide a username for authenticating with the API. Can also be specified via K8S_AUTH_USERNAME environment + variable. + password: + description: + - Provide a password for authenticating with the API. Can also be specified via K8S_AUTH_PASSWORD environment + variable. + cert_file: + description: + - Path to a certificate used to authenticate with the API. Can also be specified via K8S_AUTH_CERT_FILE + environment variable. + key_file: + description: + - Path to a key file used to authenticate with the API. Can also be specified via K8S_AUTH_HOST environment + variable. + ssl_ca_cert: + description: + - Path to a CA certificate used to authenticate with the API. Can also be specified via K8S_AUTH_SSL_CA_CERT + environment variable. + verify_ssl: + description: + - Whether or not to verify the API server's SSL certificates. Can also be specified via K8S_AUTH_VERIFY_SSL + environment variable. + type: bool + + requirements: + - "python >= 2.7" + - "openshift == 0.4.1" + - "PyYAML >= 3.11" + + notes: + - "The OpenShift Python client wraps the K8s Python client, providing full access to + all of the APIS and models available on both platforms. For API version details and + additional information visit https://github.com/openshift/openshift-restclient-python" +""" + +EXAMPLES = """ +- name: Fetch a list of projects + set_fact: + projects: "{{ lookup('openshift', api_version='v1', kind='Project') }}" + +- name: Fetch all deployments + set_fact: + deployments: "{{ lookup('openshift', kind='DeploymentConfig', namespace='testing') }}" + +- name: Fetch all deployments in a namespace + set_fact: + deployments: "{{ lookup('openshift', kind='DeploymentConfig', namespace='testing') }}" + +- name: Fetch a specific deployment by name + set_fact: + deployments: "{{ lookup('openshift', kind='DeploymentConfig', namespace='testing', resource_name='elastic') }}" + +- name: Fetch with label selector + set_fact: + service: "{{ lookup('openshift', kind='Service', label_selector='app=galaxy') }}" + +# Use parameters from a YAML config + +- name: Load config from the Ansible controller filesystem + set_fact: + config: "{{ lookup('file', 'service.yml') | from_yaml }}" + +- name: Using the config (loaded from a file in prior task), fetch the latest version of the object + set_fact: + service: "{{ lookup('openshift', resource_definition=config) }}" + +- name: Use a config from the local filesystem + set_fact: + service: "{{ lookup('openshift', src='service.yml') }}" +""" + +RETURN = """ + _list: + description: + - One or more object definitions returned from the API. + type: complex + contains: + api_version: + description: The versioned schema of this representation of an object. + returned: success + type: str + kind: + description: Represents the REST resource this object represents. + returned: success + type: str + metadata: + description: Standard object metadata. Includes name, namespace, annotations, labels, etc. + returned: success + type: complex + spec: + description: Specific attributes of the object. Will vary based on the I(api_version) and I(kind). + returned: success + type: complex + status: + description: Current status details for the object. + returned: success + type: complex +""" + +from ansible.plugins.lookup import LookupBase +from ansible.module_utils.k8s.lookup import OpenShiftLookup + + +class LookupModule(LookupBase): + def run(self, terms, variables=None, **kwargs): + return OpenShiftLookup().run(terms, variables=variables, **kwargs)