diff --git a/molecule/default/converge.yml b/molecule/default/converge.yml index bb7a714..e56cc13 100644 --- a/molecule/default/converge.yml +++ b/molecule/default/converge.yml @@ -73,3 +73,20 @@ name: osdkau-test-failure-modes state: absent wait: yes + + - block: + - name: Create namespace + k8s: + kind: Namespace + name: osdkau-test-k8s-events + + - import_tasks: tasks/k8s_events.yml + vars: + namespace: osdkau-test-k8s-events + always: + - name: Clean up namespace + k8s: + kind: Namespace + name: osdkau-test-k8s-events + state: absent + wait: yes diff --git a/molecule/default/tasks/k8s_events.yml b/molecule/default/tasks/k8s_events.yml new file mode 100644 index 0000000..6a7124a --- /dev/null +++ b/molecule/default/tasks/k8s_events.yml @@ -0,0 +1,39 @@ +--- +- name: Create TestCR resource + k8s: + definition: + apiVersion: apps.example.com/v1alpha1 + kind: TestCR + metadata: + namespace: '{{ namespace }}' + name: my-test + spec: + size: 2 + +- name: Create a k8s event + k8s_event: + namespace: '{{ namespace }}' + name: test-name + message: test-message + reason: test-reason + involvedObject: + apiVersion: apps.example.com/v1alpha1 + kind: Event + name: test-involved-object + namespace: '{{ namespace }}' + +- name: Get the Event + k8s_info: + kind: Event + name: test-name + namespace: '{{ namespace }}' + register: event_obj + +- debug: var=event_obj + +- assert: + that: + - event_obj.resources.0.metadata.name == 'test-name' + - event_obj.resources.0.message == 'test-message' + - event_obj.resources.0.reason == 'test-reason' + - event_obj.resources.0.involvedObject.name == 'test-involved-object' diff --git a/plugins/module_utils/api_utils.py b/plugins/module_utils/api_utils.py new file mode 100644 index 0000000..4c2ce1d --- /dev/null +++ b/plugins/module_utils/api_utils.py @@ -0,0 +1,102 @@ +from __future__ import absolute_import, division, print_function + +__metaclass__ = type + + +import os +import traceback + +from ansible.module_utils._text import to_native +from ansible.module_utils.six import iteritems + +K8S_IMP_ERR = None +try: + from ansible_collections.operator_sdk.util.plugins.module_utils.args_common import ( + AUTH_ARG_SPEC, + AUTH_ARG_MAP, + ) + import kubernetes + from openshift.dynamic import DynamicClient + from openshift.dynamic.exceptions import (ResourceNotFoundError, ResourceNotUniqueError) + HAS_K8S_MODULE_HELPER = True + k8s_import_exception = None +except ImportError as e: + HAS_K8S_MODULE_HELPER = False + k8s_import_exception = e + K8S_IMP_ERR = traceback.format_exc() + + +def get_api_client(module=None): + auth = {} + + def _raise_or_fail(exc, message): + if module: + module.fail_json(msg=message, error=to_native(exc)) + else: + raise exc + + # If authorization variables aren't defined, look for them in environment variables + for true_name, arg_name in AUTH_ARG_MAP.items(): + if module and module.params.get(arg_name): + auth[true_name] = module.params.get(arg_name) + else: + env_value = os.getenv('K8S_AUTH_{0}'.format(arg_name.upper()), None) or os.getenv('K8S_AUTH_{0}'.format(true_name.upper()), None) + if env_value is not None: + if AUTH_ARG_SPEC[arg_name].get('type') == 'bool': + env_value = env_value.lower() not in ['0', 'false', 'no'] + auth[true_name] = env_value + + def auth_set(*names): + return all([auth.get(name) for name in names]) + + if auth_set('username', 'password', 'host') or auth_set('api_key', 'host'): + # We have enough in the parameters to authenticate, no need to load incluster or kubeconfig + pass + elif auth_set('kubeconfig') or auth_set('context'): + try: + kubernetes.config.load_kube_config(auth.get('kubeconfig'), auth.get('context'), persist_config=auth.get('persist_config')) + except Exception as err: + _raise_or_fail(err, 'Failed to load kubeconfig due to %s') + + else: + # First try to do incluster config, then kubeconfig + try: + kubernetes.config.load_incluster_config() + except kubernetes.config.ConfigException: + try: + kubernetes.config.load_kube_config(auth.get('kubeconfig'), auth.get('context'), persist_config=auth.get('persist_config')) + except Exception as err: + _raise_or_fail(err, 'Failed to load kubeconfig due to %s') + + # Override any values in the default configuration with Ansible parameters + # As of kubernetes-client v12.0.0, get_default_copy() is required here + try: + configuration = kubernetes.client.Configuration().get_default_copy() + except AttributeError: + configuration = kubernetes.client.Configuration() + + for key, value in iteritems(auth): + if key in AUTH_ARG_MAP.keys() and value is not None: + if key == 'api_key': + setattr(configuration, key, {'authorization': "Bearer {0}".format(value)}) + else: + setattr(configuration, key, value) + + try: + client = DynamicClient(kubernetes.client.ApiClient(configuration)) + except Exception as err: + _raise_or_fail(err, 'Failed to get client due to %s') + + return client + + +def find_resource(client, kind, api_version): + for attribute in ['kind', 'name', 'singular_name']: + try: + return client.resources.get(**{'api_version': api_version, attribute: kind}) + except (ResourceNotFoundError, ResourceNotUniqueError): + pass + try: + return client.resources.get(api_version=api_version, short_names=[kind]) + except (ResourceNotFoundError, ResourceNotUniqueError): + return None diff --git a/plugins/modules/k8s_event.py b/plugins/modules/k8s_event.py new file mode 100644 index 0000000..0f98e1f --- /dev/null +++ b/plugins/modules/k8s_event.py @@ -0,0 +1,325 @@ +#!/usr/bin/python +# -*- coding: utf-8 -*- + +from __future__ import absolute_import, division, print_function + + +__metaclass__ = type + +ANSIBLE_METADATA = { + "metadata_version": "1.1", + "status": ["preview"], + "supported_by": "community", +} + +DOCUMENTATION = """ + +module: k8s_event + +short_description: Create Kubernetes Events. + +version_added: "0.0.1" + +author: "Venkat Ramaraju (@VenkatRamaraju)" + +description: + - Allows users to more easily emit events for their managed objects. + +extends_documentation_fragment: + - operator_sdk.util.osdk_auth_options + +options: + state: + type: str + description: + - Determines whether an object should be created, patched or deleted. If set to "present" a new object will + be created if it does not already exist. If the object already exists, it will be patched if the attributes + differ from the new specifications. If attributes do not differ, no changes will be made. If set to "absent", + the object will be deleted if it already exists. If it does not exist, no changes are made. + - By default, state is set to "present". + choices: + - "present" + - "absent" + default: "present" + name: + type: str + required: true + description: + - The unique name of the resource. + namespace: + type: str + required: true + description: + - The space within which each name must be unique. + - Not all objects are required to be scoped to a namespace. + merge_type: + type: list + description: + - Determines whether to override the default patch merge type with the specified. + - The default is strategic merge. + choices: + - "json" + - "merge" + - "strategic-merge" + message: + type: str + required: true + description: + - A human-readable description of the status of this operation. + reason: + type: str + required: true + description: + - A human-readable description of the status of this operation. + reportingComponent: + type: str + description: + - Name of the controller that emitted this Event, e.g. kubernetes.io/kubelet. + type: + type: str + description: + - Type of this event. New types could be added in the future. + choices: + - "Normal" + - "Warning" + source: + type: dict + description: EventSource + - Component for reporting this Event + involvedObject: + type: dict + description: The object that this event is about. + appendTimestamp: + type: bool + description: Event name should have timestamp appended to it + +requirements: + - python >= 2.7 + - openshift >= 0.6.2 +""" + +EXAMPLES = """ +- name: Create Kubernetes Event + k8s_event: + state: present + name: test-k8s-event + namespace: default + message: Event created + merge-type: strategic-merge + reason: Testing event creation + reportingComponent: Reporting components + appendTimestamp: true + type: Normal + source: + component: test-component + involvedObject: + apiVersion: v1 + kind: Service + name: test-k8s-events + namespace: default +""" + +RETURN = """ +result: + description: + - If a change was made, will return the patched object, otherwise returns the instance object. + returned: success + type: complex + contains: + contains: + namespace: + description: Namespace defines the space within which each name must be unique + returned: success + type: str + name: + description: The unique name of the resource. + returned: success + type: str + count: + description: Count of event occurrences + returned: success + type: int + message: + description: A human-readable description of the status of this operation. + returned: success + type: dict + kind: + description: Always 'Event'. + returned: success + type: str + firstTimestamp: + description: Timestamp of first occurrence of Event + returned: success + type: timestamp + reason: + description: Machine understandable string that gives the reason for the transition into the object's status. + returned: success + type: dict + reportingComponent: + description: Name of the controller that emitted this Event + returned: success + type: + description: Type of this event. New types could be added in the future. + returned: success + source: + description: The component reporting this event. + returned: success + lastTimestamp: + description: Timestamp of last occurrence of Event + returned: success + type: timestamp + involvedObject: + description: The object that this event is about. + returned: success +""" + +import copy +import datetime +import traceback +from ansible.module_utils.basic import AnsibleModule + +K8S_IMP_ERR = None +try: + from ansible_collections.operator_sdk.util.plugins.module_utils.args_common import AUTH_ARG_SPEC + from ansible_collections.operator_sdk.util.plugins.module_utils.api_utils import ( + get_api_client, + find_resource, + ) + import openshift + HAS_K8S_MODULE_HELPER = True + k8s_import_exception = None +except ImportError as e: + HAS_K8S_MODULE_HELPER = False + k8s_import_exception = e + K8S_IMP_ERR = traceback.format_exc() + +EVENT_ARG_SPEC = { + "state": {"default": "present", "choices": ["present", "absent"]}, + "name": {"required": True}, + "namespace": {"required": True}, + "merge_type": {"type": "list", "choices": ["json", "merge", "strategic-merge"]}, + "message": {"type": "str", "required": True}, + "reason": {"type": "str", "required": True}, + "reportingComponent": {"type": "str"}, + "type": {"choices": ["Normal", "Warning"]}, + "appendTimestamp": {"type": "bool"}, + "source": { + "type": "dict", + "component": {"type": "str", "required": True} + }, + "involvedObject": { + "type": "dict", + "apiVersion": {"type": "str", "required": True}, + "kind": {"type": "str", "required": True}, + "name": {"type": "str", "required": True}, + "namespace": {"type": "str", "required": True}, + } +} + + +class KubernetesEvent(AnsibleModule): + def __init__(self, *args, **kwargs): + super(KubernetesEvent, self).__init__(*args, argument_spec=self.argspec, **kwargs) + self.client = None + + @property + def argspec(self): + """ argspec property builder """ + argumentSpec = copy.deepcopy(AUTH_ARG_SPEC) + argumentSpec.update(EVENT_ARG_SPEC) + return argumentSpec + + def execute_module(self): + self.client = get_api_client(self) + now = datetime.datetime.now(datetime.timezone.utc) + if self.params['appendTimestamp']: + self.params["name"] = self.params["name"] + "." + str(now) + + metadata = {"name": self.params.get("name"), "namespace": self.params.get("namespace")} + resource = find_resource(self.client, "Event", "v1") + v1_events = self.client.resources.get(api_version="v1", kind='Event') + event = { + "kind": "Event", + "eventTime": None, + "message": self.params.get("message"), + "metadata": metadata, + "reason": self.params.get("reason"), + "reportingComponent": self.params.get("reportingComponent"), + "source": self.params.get("source"), + "type": self.params.get("type"), + } + + if self.params['appendTimestamp']: + try: + created_event = v1_events.create(body=event, namespace=self.params.get("namespace")) + return dict(result=created_event.to_dict(), changed=True) + except Exception as err: + self.fail_json(msg="Unable to create event: {0}".format(err)) + + prior_event = None + try: + prior_event = resource.get( + name=metadata["name"], + namespace=metadata["namespace"]) + except openshift.dynamic.exceptions.NotFoundError: + pass + + prior_count = 1 + rfc = now.isoformat() + first_timestamp = rfc + last_timestamp = rfc + + if prior_event and prior_event["reason"] == self.params['reason']: + prior_count = prior_event["count"] + 1 + first_timestamp = prior_event["firstTimestamp"] + last_timestamp = rfc + + involved_obj = self.params.get("involvedObject") + if involved_obj: + try: + involved_object_resource = find_resource(self.client, involved_obj["kind"], "v1") + api_involved_object = involved_object_resource.get( + name=involved_obj["name"], namespace=involved_obj["namespace"]) + + involved_obj["uid"] = api_involved_object["metadata"]["uid"] + involved_obj["resourceVersion"] = api_involved_object["metadata"]["resourceVersion"] + + except openshift.dynamic.exceptions.NotFoundError: + pass + + # Return data + added_event_fields = { + "count": prior_count, + "firstTimestamp": first_timestamp, + "involvedObject": involved_obj, + "lastTimestamp": last_timestamp, + } + + event.update(added_event_fields) + + try: + instance = v1_events.get(name=self.params.get("name"), namespace=self.params.get("namespace")) + except openshift.dynamic.exceptions.NotFoundError: + try: + created_event = v1_events.create(body=event, namespace=self.params.get("namespace")) + return dict(result=created_event.to_dict(), changed=True) + except Exception as err: + self.fail_json(msg="Unable to create event: {0}".format(err)) + + try: + result = v1_events.patch(body=event, namespace=self.params.get("namespace")) + result_dict = result.to_dict() + changed = instance.to_dict() != result_dict + return dict(result=result_dict, changed=changed) + except Exception as err: + self.fail_json(msg="Unable to create event: {0}".format(err)) + + +def main(): + module = KubernetesEvent() + result_event = module.execute_module() + module.exit_json(**result_event) + + +if __name__ == "__main__": + main() diff --git a/plugins/modules/k8s_status.py b/plugins/modules/k8s_status.py index f2a6d81..79fb47e 100644 --- a/plugins/modules/k8s_status.py +++ b/plugins/modules/k8s_status.py @@ -160,30 +160,22 @@ type: dict """ - -import os import re import copy import traceback from ansible.module_utils.basic import AnsibleModule, missing_required_lib from ansible.module_utils._text import to_native -from ansible.module_utils.six import iteritems - -from ansible_collections.operator_sdk.util.plugins.module_utils.args_common import ( - AUTH_ARG_SPEC, - NAME_ARG_SPEC, - AUTH_ARG_MAP, -) K8S_IMP_ERR = None try: - import kubernetes - import openshift - from openshift.dynamic import DynamicClient - from openshift.dynamic.exceptions import ( - ResourceNotFoundError, ResourceNotUniqueError, DynamicApiError + from ansible_collections.operator_sdk.util.plugins.module_utils.api_utils import (get_api_client, find_resource) + from ansible_collections.operator_sdk.util.plugins.module_utils.args_common import ( + AUTH_ARG_SPEC, + NAME_ARG_SPEC ) + import openshift + from openshift.dynamic.exceptions import DynamicApiError HAS_K8S_MODULE_HELPER = True k8s_import_exception = None except ImportError as e: @@ -278,82 +270,6 @@ def validate_condition(condition): return [validate_condition(c) for c in conditions] -def get_api_client(module=None): - auth = {} - - def _raise_or_fail(exc, message): - if module: - module.fail_json(msg=message, error=to_native(exc)) - else: - raise exc - - # If authorization variables aren't defined, look for them in environment variables - for true_name, arg_name in AUTH_ARG_MAP.items(): - if module and module.params.get(arg_name): - auth[true_name] = module.params.get(arg_name) - else: - env_value = os.getenv('K8S_AUTH_{0}'.format(arg_name.upper()), None) or os.getenv('K8S_AUTH_{0}'.format(true_name.upper()), None) - if env_value is not None: - if AUTH_ARG_SPEC[arg_name].get('type') == 'bool': - env_value = env_value.lower() not in ['0', 'false', 'no'] - auth[true_name] = env_value - - def auth_set(*names): - return all([auth.get(name) for name in names]) - - if auth_set('username', 'password', 'host') or auth_set('api_key', 'host'): - # We have enough in the parameters to authenticate, no need to load incluster or kubeconfig - pass - elif auth_set('kubeconfig') or auth_set('context'): - try: - kubernetes.config.load_kube_config(auth.get('kubeconfig'), auth.get('context'), persist_config=auth.get('persist_config')) - except Exception as err: - _raise_or_fail(err, 'Failed to load kubeconfig due to %s') - - else: - # First try to do incluster config, then kubeconfig - try: - kubernetes.config.load_incluster_config() - except kubernetes.config.ConfigException: - try: - kubernetes.config.load_kube_config(auth.get('kubeconfig'), auth.get('context'), persist_config=auth.get('persist_config')) - except Exception as err: - _raise_or_fail(err, 'Failed to load kubeconfig due to %s') - - # Override any values in the default configuration with Ansible parameters - # As of kubernetes-client v12.0.0, get_default_copy() is required here - try: - configuration = kubernetes.client.Configuration().get_default_copy() - except AttributeError: - configuration = kubernetes.client.Configuration() - - for key, value in iteritems(auth): - if key in AUTH_ARG_MAP.keys() and value is not None: - if key == 'api_key': - setattr(configuration, key, {'authorization': "Bearer {0}".format(value)}) - else: - setattr(configuration, key, value) - - try: - client = DynamicClient(kubernetes.client.ApiClient(configuration)) - except Exception as err: - _raise_or_fail(err, 'Failed to get client due to %s') - - return client - - -def find_resource(client, kind, api_version): - for attribute in ['kind', 'name', 'singular_name']: - try: - return client.resources.get(**{'api_version': api_version, attribute: kind}) - except (ResourceNotFoundError, ResourceNotUniqueError): - pass - try: - return client.resources.get(api_version=api_version, short_names=[kind]) - except (ResourceNotFoundError, ResourceNotUniqueError): - return None - - class KubernetesAnsibleStatusModule(AnsibleModule): def __init__(self, *args, **kwargs): diff --git a/tests/sanity/ignore-2.9.txt b/tests/sanity/ignore-2.9.txt index 08d3af5..bbfc8fe 100644 --- a/tests/sanity/ignore-2.9.txt +++ b/tests/sanity/ignore-2.9.txt @@ -1,2 +1,3 @@ plugins/modules/k8s_status.py validate-modules:missing-gplv3-license plugins/modules/requeue_after.py validate-modules:missing-gplv3-license +plugins/modules/k8s_event.py validate-modules:missing-gplv3-license \ No newline at end of file