From 5b5777d202746ea65991a5addaac231d34925af8 Mon Sep 17 00:00:00 2001 From: Mike Graves Date: Mon, 24 May 2021 08:35:48 -0400 Subject: [PATCH] Split JSON patch out into a new module (#99) Co-authored-by: Abhijeet Kasurde --- .../fragments/99-json-patch-module.yaml | 4 + molecule/default/converge.yml | 7 + molecule/default/tasks/json_patch.yml | 170 +++++++++++ molecule/default/tasks/merge_type.yml | 109 ------- plugins/module_utils/common.py | 47 +-- plugins/modules/k8s.py | 1 + plugins/modules/k8s_json_patch.py | 280 ++++++++++++++++++ 7 files changed, 467 insertions(+), 151 deletions(-) create mode 100644 changelogs/fragments/99-json-patch-module.yaml create mode 100644 molecule/default/tasks/json_patch.yml create mode 100644 plugins/modules/k8s_json_patch.py diff --git a/changelogs/fragments/99-json-patch-module.yaml b/changelogs/fragments/99-json-patch-module.yaml new file mode 100644 index 0000000000..ec1b5595ba --- /dev/null +++ b/changelogs/fragments/99-json-patch-module.yaml @@ -0,0 +1,4 @@ +--- +major_changes: + - k8s - deprecate merge_type=json. The JSON patch functionality has never worked (https://github.com/ansible-collections/kubernetes.core/pull/99). + - k8s_json_patch - split JSON patch functionality out into a separate module (https://github.com/ansible-collections/kubernetes.core/pull/99). diff --git a/molecule/default/converge.yml b/molecule/default/converge.yml index a2e7bfb00a..f389e905c7 100644 --- a/molecule/default/converge.yml +++ b/molecule/default/converge.yml @@ -90,6 +90,13 @@ tags: [ info, k8s ] tags: - always + - name: Include json_patch.yml + include_tasks: + file: tasks/json_patch.yml + apply: + tags: [ json_patch, k8s ] + tags: + - always - name: Include lists.yml include_tasks: file: tasks/lists.yml diff --git a/molecule/default/tasks/json_patch.yml b/molecule/default/tasks/json_patch.yml new file mode 100644 index 0000000000..3c7aec0723 --- /dev/null +++ b/molecule/default/tasks/json_patch.yml @@ -0,0 +1,170 @@ +- vars: + namespace: json-patch + pod: json-patch + deployment: json-patch + + block: + - name: Ensure namespace exists + kubernetes.core.k8s: + kind: namespace + name: "{{ namespace }}" + + - name: Create a simple pod + kubernetes.core.k8s: + definition: + apiVersion: v1 + kind: Pod + metadata: + namespace: "{{ namespace }}" + name: "{{ pod }}" + labels: + label1: foo + spec: + containers: + - image: busybox:musl + name: busybox + command: + - sh + - -c + - while true; do echo $(date); sleep 10; done + wait: yes + + - name: Add a label and replace the image in checkmode + kubernetes.core.k8s_json_patch: + kind: Pod + namespace: "{{ namespace }}" + name: "{{ pod }}" + patch: + - op: add + path: /metadata/labels/label2 + value: bar + - op: replace + path: /spec/containers/0/image + value: busybox:glibc + check_mode: yes + register: result + + - name: Assert patch was made + assert: + that: + - result.changed + - result.result.metadata.labels.label2 == "bar" + - result.result.spec.containers[0].image == "busybox:glibc" + + - name: Describe pod + kubernetes.core.k8s_info: + kind: Pod + name: "{{ pod }}" + namespace: "{{ namespace }}" + register: result + + - name: Assert pod has not changed + assert: + that: + - result.resources[0].metadata.labels.label2 is not defined + - result.resources[0].spec.containers[0].image == "busybox:musl" + + - name: Add a label and replace the image + kubernetes.core.k8s_json_patch: + kind: Pod + namespace: "{{ namespace }}" + name: "{{ pod }}" + patch: + - op: add + path: /metadata/labels/label2 + value: bar + - op: replace + path: /spec/containers/0/image + value: busybox:glibc + register: result + + - name: Assert patch was made + assert: + that: + - result.changed + + - name: Describe pod + kubernetes.core.k8s_info: + kind: Pod + name: "{{ pod }}" + namespace: "{{ namespace }}" + register: result + + - name: Assert that both patch operations have been applied + assert: + that: + - result.resources[0].metadata.labels.label2 == "bar" + - result.resources[0].spec.containers[0].image == "busybox:glibc" + + - name: Apply the same patch to the pod + kubernetes.core.k8s_json_patch: + kind: Pod + namespace: "{{ namespace }}" + name: "{{ pod }}" + patch: + - op: add + path: /metadata/labels/label2 + value: bar + - op: replace + path: /spec/containers/0/image + value: busybox:glibc + register: result + + - name: Assert that no changes were made + assert: + that: + - not result.changed + + - name: Create a simple deployment + kubernetes.core.k8s: + wait: yes + definition: + apiVersion: apps/v1 + kind: Deployment + metadata: + namespace: "{{ namespace }}" + name: "{{ deployment }}" + labels: + name: "{{ deployment }}" + spec: + replicas: 2 + selector: + matchLabels: + app: busybox + template: + metadata: + labels: + app: busybox + spec: + containers: + - name: busybox + image: busybox + command: + - sh + - -c + - while true; do echo $(date); sleep 10; done + + - name: Apply patch and wait for deployment to be ready + kubernetes.core.k8s_json_patch: + kind: Deployment + namespace: "{{ namespace }}" + name: "{{ deployment }}" + patch: + - op: replace + path: /spec/replicas + value: 3 + wait: yes + register: result + + - name: Assert all replicas are available + assert: + that: + - result.result.status.availableReplicas == 3 + + always: + - name: Ensure namespace has been deleted + kubernetes.core.k8s: + kind: Namespace + name: "{{ namespace }}" + state: absent + ignore_errors: yes diff --git a/molecule/default/tasks/merge_type.yml b/molecule/default/tasks/merge_type.yml index 4a8f31e561..fad238bd60 100644 --- a/molecule/default/tasks/merge_type.yml +++ b/molecule/default/tasks/merge_type.yml @@ -134,115 +134,6 @@ - merge_out.resources[0].spec.template.spec.containers | list | length == 1 - merge_out.resources[0].spec.template.spec.containers[0].image == 'python' - # Json - - name: create simple pod - kubernetes.core.k8s: - namespace: "{{ k8s_patch_namespace }}" - definition: - apiVersion: v1 - kind: Pod - metadata: - name: "{{ k8s_json }}-pod" - labels: - name: "{{ k8s_json }}-pod" - spec: - containers: - - args: - - /bin/sh - - -c - - while true; do echo $(date); sleep 10; done - image: python:3.7-alpine - imagePullPolicy: Always - name: alpine - - - name: Patch pod - update container image - kubernetes.core.k8s: - kind: Pod - namespace: "{{ k8s_patch_namespace }}" - name: "{{ k8s_json }}-pod" - merge_type: - - json - definition: - - op: replace - path: /spec/containers/0/image - value: python:3.8-alpine - register: pod_patch - - - name: assert that patch was performed - assert: - that: - - pod_patch.changed - - - name: describe Pod after patching - kubernetes.core.k8s_info: - kind: Pod - name: "{{ k8s_json }}-pod" - namespace: "{{ k8s_patch_namespace }}" - register: describe_pod - - - name: assert that image name has changed - assert: - that: - - describe_pod.resources[0].spec.containers[0].image == 'python:3.8-alpine' - - - name: create a simple nginx deployment - kubernetes.core.k8s: - namespace: "{{ k8s_patch_namespace }}" - definition: - apiVersion: apps/v1 - kind: Deployment - metadata: - name: "{{ k8s_json }}-depl" - labels: - name: "{{ k8s_json }}-depl" - spec: - replicas: 2 - selector: - matchLabels: - app: nginx - template: - metadata: - labels: - app: nginx - spec: - containers: - - name: nginx-container - image: nginx - args: - - /bin/sh - - -c - - while true; do echo $(date); sleep 10; done - - - name: Patch Nginx deployment command - kubernetes.core.k8s: - kind: Deployment - namespace: "{{ k8s_patch_namespace }}" - name: "{{ k8s_json }}-depl" - merge_type: - - json - definition: - - op: add - path: '/spec/template/spec/containers/0/args/-' - value: 'touch /var/log' - register: patch_out - - - name: assert that patch succeed - assert: - that: - - patch_out.changed - - - name: describe deployment after patching - kubernetes.core.k8s_info: - kind: Deployment - name: "{{ k8s_json }}-depl" - namespace: "{{ k8s_patch_namespace }}" - register: describe_depl - - - name: assert that args changed on deployment - assert: - that: - - describe_depl.resources[0].spec.template.spec.containers[0].args | length == 4 - always: - name: Ensure namespace has been deleted kubernetes.core.k8s: diff --git a/plugins/module_utils/common.py b/plugins/module_utils/common.py index de3c92824f..e3a48cca03 100644 --- a/plugins/module_utils/common.py +++ b/plugins/module_utils/common.py @@ -97,17 +97,6 @@ K8S_IMP_ERR = traceback.format_exc() -JSON_PATCH_IMP_ERR = None -try: - import jsonpatch - HAS_JSON_PATCH = True - jsonpatch_import_exception = None -except ImportError as e: - HAS_JSON_PATCH = False - jsonpatch_import_exception = e - JSON_PATCH_IMP_ERR = traceback.format_exc() - - def configuration_digest(configuration): m = hashlib.sha256() for k in AUTH_ARG_MAP: @@ -841,42 +830,16 @@ def build_error_msg(kind, name, msg): self.fail_json(msg=msg, **result) return result - def json_patch(self, existing, definition, merge_type): - if merge_type == "json": - if not HAS_JSON_PATCH: - error = { - "msg": missing_required_lib('jsonpatch'), - "exception": JSON_PATCH_IMP_ERR, - "error": to_native(jsonpatch_import_exception) - } - return None, error - try: - patch = jsonpatch.JsonPatch([definition]) - result_patch = patch.apply(existing.to_dict()) - return result_patch, None - except jsonpatch.InvalidJsonPatch as e: - error = { - "msg": "invalid json patch", - "error": to_native(e) - } - return None, error - except jsonpatch.JsonPatchConflict as e: - error = { - "msg": "patch could not be applied due to conflict situation", - "error": to_native(e) - } - return None, error - return definition, None - def patch_resource(self, resource, definition, existing, name, namespace, merge_type=None): + if merge_type == "json": + self.module.deprecate( + msg="json as a merge_type value is deprecated. Please use the k8s_json_patch module instead.", + version="3.0.0", collection_name="kubernetes.core") try: params = dict(name=name, namespace=namespace) if merge_type: params['content_type'] = 'application/{0}-patch+json'.format(merge_type) - patch_data, error = self.json_patch(existing, definition, merge_type) - if error is not None: - return None, error - k8s_obj = resource.patch(patch_data, **params).to_dict() + k8s_obj = resource.patch(definition, **params).to_dict() match, diffs = self.diff_objects(existing.to_dict(), k8s_obj) error = {} return k8s_obj, {} diff --git a/plugins/modules/k8s.py b/plugins/modules/k8s.py index 27a5ebafbc..5bcb51cff2 100644 --- a/plugins/modules/k8s.py +++ b/plugins/modules/k8s.py @@ -63,6 +63,7 @@ C(['strategic-merge', 'merge']), which is ideal for using the same parameters on resource kinds that combine Custom Resources and built-in resources. - mutually exclusive with C(apply) + - I(merge_type=json) is deprecated and will be removed in version 3.0.0. Please use M(kubernetes.core.k8s_json_patch) instead. choices: - json - merge diff --git a/plugins/modules/k8s_json_patch.py b/plugins/modules/k8s_json_patch.py new file mode 100644 index 0000000000..45db2ccd37 --- /dev/null +++ b/plugins/modules/k8s_json_patch.py @@ -0,0 +1,280 @@ +#!/usr/bin/python +# -*- coding: utf-8 -*- +# Copyright: (C), 2018 Red Hat | Ansible +# 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 + + +DOCUMENTATION = r''' +module: k8s_json_patch + +short_description: Apply JSON patch operations to existing objects + +description: + - This module is used to apply RFC 6902 JSON patch operations only. + - Use the M(k8s) module for strategic merge or JSON merge operations. + - The jsonpatch library is required for check mode. + +version_added: 2.0.0 + +author: +- Mike Graves (@gravesm) + +options: + api_version: + description: + - Use to specify the API version. + - Use in conjunction with I(kind), I(name), and I(namespace) to identify a specific object. + type: str + default: v1 + aliases: + - api + - version + kind: + description: + - Use to specify an object model. + - Use in conjunction with I(api_version), I(name), and I(namespace) to identify a specific object. + type: str + required: yes + namespace: + description: + - Use to specify an object namespace. + - Use in conjunction with I(api_version), I(kind), and I(name) to identify a specific object. + type: str + name: + description: + - Use to specify an object name. + - Use in conjunction with I(api_version), I(kind), and I(namespace) to identify a specific object. + type: str + required: yes + patch: + description: + - List of JSON patch operations. + required: yes + type: list + elements: dict + +extends_documentation_fragment: + - kubernetes.core.k8s_auth_options + - kubernetes.core.k8s_wait_options + +requirements: + - "python >= 3.6" + - "kubernetes >= 12.0.0" + - "PyYAML >= 3.11" + - "jsonpatch" +''' + +EXAMPLES = r''' +- name: Apply multiple patch operations to an existing Pod + kubernetes.core.k8s_json_patch: + kind: Pod + namespace: testing + name: mypod + patch: + - op: add + path: /metadata/labels/app + value: myapp + - op: replace + patch: /spec/containers/0/image + value: nginx +''' + +RETURN = r''' +result: + description: The modified object. + returned: success + type: dict + contains: + api_version: + description: The versioned schema of this representation of an object. + returned: success + type: str + kind: + description: The REST resource this object represents. + returned: success + type: str + metadata: + description: Standard object metadata. Includes name, namespace, annotations, labels, etc. + returned: success + type: dict + spec: + description: Specific attributes of the object. Will vary based on the I(api_version) and I(kind). + returned: success + type: dict + status: + description: Current status details for the object. + returned: success + type: dict +duration: + description: Elapsed time of task in seconds. + returned: when C(wait) is true + type: int + sample: 48 +error: + description: The error when patching the object. + returned: error + type: dict + sample: { + "msg": "Failed to import the required Python library (jsonpatch) ...", + "exception": "Traceback (most recent call last): ..." + } +''' + +import copy +import traceback + +from ansible.module_utils.basic import missing_required_lib +from ansible.module_utils._text import to_native +from ansible_collections.kubernetes.core.plugins.module_utils.ansiblemodule import AnsibleModule +from ansible_collections.kubernetes.core.plugins.module_utils.args_common import AUTH_ARG_SPEC, WAIT_ARG_SPEC +from ansible_collections.kubernetes.core.plugins.module_utils.common import ( + get_api_client, K8sAnsibleMixin) + +try: + from kubernetes.dynamic.exceptions import DynamicApiError +except ImportError: + # kubernetes library check happens in common.py + pass + +JSON_PATCH_IMPORT_ERR = None +try: + import jsonpatch + HAS_JSON_PATCH = True +except ImportError: + HAS_JSON_PATCH = False + JSON_PATCH_IMPORT_ERR = traceback.format_exc() + + +JSON_PATCH_ARGS = { + 'api_version': { + 'default': 'v1', + 'aliases': ['api', 'version'], + }, + "kind": { + "type": "str", + "required": True, + }, + "namespace": { + "type": "str", + }, + "name": { + "type": "str", + "required": True, + }, + "patch": { + "type": "list", + "required": True, + "elements": "dict", + }, +} + + +def json_patch(existing, patch): + if not HAS_JSON_PATCH: + error = { + "msg": missing_required_lib('jsonpatch'), + "exception": JSON_PATCH_IMPORT_ERR, + } + return None, error + try: + patch = jsonpatch.JsonPatch(patch) + patched = patch.apply(existing) + return patched, None + except jsonpatch.InvalidJsonPatch as e: + error = { + "msg": "Invalid JSON patch", + "exception": e + } + return None, error + except jsonpatch.JsonPatchConflict as e: + error = { + "msg": "Patch could not be applied due to a conflict", + "exception": e + } + return None, error + + +def execute_module(k8s_module, module): + kind = module.params.get("kind") + api_version = module.params.get("api_version") + name = module.params.get("name") + namespace = module.params.get("namespace") + patch = module.params.get("patch") + + wait = module.params.get("wait") + wait_sleep = module.params.get("wait_sleep") + wait_timeout = module.params.get("wait_timeout") + wait_condition = None + if module.params.get("wait_condition") and module.params.get("wait_condition").get("type"): + wait_condition = module.params['wait_condition'] + # definition is needed for wait + definition = { + "kind": kind, + "metadata": { + "name": name, + "namespace": namespace, + } + } + + def build_error_msg(kind, name, msg): + return "%s %s: %s" % (kind, name, msg) + + resource = k8s_module.find_resource(kind, api_version, fail=True) + + try: + existing = resource.get(name=name, namespace=namespace) + except DynamicApiError as exc: + msg = 'Failed to retrieve requested object: {0}'.format(exc.body) + module.fail_json(msg=build_error_msg(kind, name, msg), error=exc.status, status=exc.status, reason=exc.reason) + except ValueError as exc: + msg = 'Failed to retrieve requested object: {0}'.format(to_native(exc)) + module.fail_json(msg=build_error_msg(kind, name, msg), error='', status='', reason='') + + if module.check_mode: + obj, error = json_patch(existing.to_dict(), patch) + if error: + module.fail_json(**error) + else: + try: + obj = resource.patch(patch, name=name, namespace=namespace, content_type="application/json-patch+json").to_dict() + except DynamicApiError as exc: + msg = "Failed to patch existing object: {0}".format(exc.body) + module.fail_json(msg=msg, error=exc.status, status=exc.status, reason=exc.reason) + except Exception as exc: + msg = "Failed to patch existing object: {0}".format(exc) + module.fail_json(msg=msg, error=to_native(exc), status='', reason='') + + success = True + result = {"result": obj} + if wait and not module.check_mode: + success, result['result'], result['duration'] = k8s_module.wait(resource, definition, wait_sleep, wait_timeout, condition=wait_condition) + match, diffs = k8s_module.diff_objects(existing.to_dict(), obj) + result["changed"] = not match + result["diff"] = diffs + + if not success: + msg = "Resource update timed out" + module.fail_json(msg=msg, **result) + + module.exit_json(**result) + + +def main(): + args = copy.deepcopy(AUTH_ARG_SPEC) + args.update(copy.deepcopy(WAIT_ARG_SPEC)) + args.update(JSON_PATCH_ARGS) + module = AnsibleModule(argument_spec=args, supports_check_mode=True) + k8s_module = K8sAnsibleMixin(module) + k8s_module.params = module.params + k8s_module.check_library_version() + client = get_api_client(module) + k8s_module.client = client + execute_module(k8s_module, module) + + +if __name__ == "__main__": + main()