From 4010987d1f836d68f147cb7383329b1d5c066b2c Mon Sep 17 00:00:00 2001 From: Mike Graves Date: Fri, 15 Oct 2021 10:20:43 -0400 Subject: [PATCH] Add support for dry run (#245) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add support for dry run SUMMARY Kubernetes server-side dry run will be used when the kubernetes client version is >=18.20.0. For older versions of the client, the existing client side speculative change implementation will be used. The effect of this change should be mostly transparent to the end user and is reflected in the fact the tests have not changed but should still pass. With this change, there are a few edge cases that will be improved. One example of these edge cases is to use check mode on an existing Service resource. With dry run this will correctly report no changes, while the older client side implementation will erroneously report changes to the port spec. ISSUE TYPE Feature Pull Request COMPONENT NAME ADDITIONAL INFORMATION Reviewed-by: Gonéri Le Bouder Reviewed-by: Mike Graves Reviewed-by: Alina Buzachis Reviewed-by: None Reviewed-by: None --- changelogs/fragments/245-add-dry-run.yaml | 3 ++ molecule/default/tasks/full.yml | 5 ++++ plugins/module_utils/apply.py | 7 +++-- plugins/module_utils/common.py | 34 +++++++++++++++++------ plugins/module_utils/k8sdynamicclient.py | 4 +-- plugins/modules/k8s_json_patch.py | 7 +++-- 6 files changed, 44 insertions(+), 16 deletions(-) create mode 100644 changelogs/fragments/245-add-dry-run.yaml diff --git a/changelogs/fragments/245-add-dry-run.yaml b/changelogs/fragments/245-add-dry-run.yaml new file mode 100644 index 00000000..cf3c40c0 --- /dev/null +++ b/changelogs/fragments/245-add-dry-run.yaml @@ -0,0 +1,3 @@ +--- +minor_changes: + - add support for dry run with kubernetes client version >=18.20 (https://github.com/ansible-collections/kubernetes.core/pull/245). diff --git a/molecule/default/tasks/full.yml b/molecule/default/tasks/full.yml index 03dfa402..b6b1bd26 100644 --- a/molecule/default/tasks/full.yml +++ b/molecule/default/tasks/full.yml @@ -60,11 +60,16 @@ environment: K8S_AUTH_KUBECONFIG: ~/.kube/customconfig + - name: Get currently installed version of kubernetes + ansible.builtin.command: python -c "import kubernetes; print(kubernetes.__version__)" + register: kubernetes_version + - name: Using in-memory kubeconfig should succeed kubernetes.core.k8s: name: testing kind: Namespace kubeconfig: "{{ lookup('file', '~/.kube/customconfig') | from_yaml }}" + when: kubernetes_version.stdout is version("17.17.0", ">=") always: - name: Return kubeconfig diff --git a/plugins/module_utils/apply.py b/plugins/module_utils/apply.py index 486d9a5a..034859cd 100644 --- a/plugins/module_utils/apply.py +++ b/plugins/module_utils/apply.py @@ -110,16 +110,17 @@ def apply_object(resource, definition): return apply_patch(actual.to_dict(), definition) -def k8s_apply(resource, definition): +def k8s_apply(resource, definition, **kwargs): existing, desired = apply_object(resource, definition) if not existing: - return resource.create(body=desired, namespace=definition['metadata'].get('namespace')) + return resource.create(body=desired, namespace=definition['metadata'].get('namespace'), **kwargs) if existing == desired: return resource.get(name=definition['metadata']['name'], namespace=definition['metadata'].get('namespace')) return resource.patch(body=desired, name=definition['metadata']['name'], namespace=definition['metadata'].get('namespace'), - content_type='application/merge-patch+json') + content_type='application/merge-patch+json', + **kwargs) # The patch is the difference from actual to desired without deletions, plus deletions diff --git a/plugins/module_utils/common.py b/plugins/module_utils/common.py index 2ace5779..c3ca5217 100644 --- a/plugins/module_utils/common.py +++ b/plugins/module_utils/common.py @@ -229,6 +229,7 @@ class K8sAnsibleMixin(object): module.fail_json(msg=missing_required_lib('kubernetes'), exception=K8S_IMP_ERR, error=to_native(k8s_import_exception)) self.kubernetes_version = kubernetes.__version__ + self.supports_dry_run = LooseVersion(self.kubernetes_version) >= LooseVersion("18.20.0") if pyyaml_required and not HAS_YAML: module.fail_json(msg=missing_required_lib("PyYAML"), exception=YAML_IMP_ERR) @@ -686,7 +687,9 @@ class K8sAnsibleMixin(object): else: # Delete the object result['changed'] = True - if not self.check_mode: + if self.check_mode and not self.supports_dry_run: + return result + else: if delete_options: body = { 'apiVersion': 'v1', @@ -694,6 +697,8 @@ class K8sAnsibleMixin(object): } body.update(delete_options) params['body'] = body + if self.check_mode: + params['dry_run'] = "All" try: k8s_obj = resource.delete(**params) result['result'] = k8s_obj.to_dict() @@ -705,7 +710,7 @@ class K8sAnsibleMixin(object): return result else: self.fail_json(msg=build_error_msg(definition['kind'], origin_name, msg), error=exc.status, status=exc.status, reason=exc.reason) - if wait: + if wait and not self.check_mode: success, resource, duration = self.wait(resource, definition, wait_sleep, wait_timeout, 'absent', label_selectors=label_selectors) result['duration'] = duration if not success: @@ -726,7 +731,7 @@ class K8sAnsibleMixin(object): kind=definition['kind'], name=origin_name, namespace=namespace) return result if apply: - if self.check_mode: + if self.check_mode and not self.supports_dry_run: ignored, patch = apply_object(resource, _encode_stringdata(definition)) if existing: k8s_obj = dict_merge(existing.to_dict(), patch) @@ -734,7 +739,10 @@ class K8sAnsibleMixin(object): k8s_obj = patch else: try: - k8s_obj = resource.apply(definition, namespace=namespace).to_dict() + params = {} + if self.check_mode: + params['dry_run'] = 'All' + k8s_obj = resource.apply(definition, namespace=namespace, **params).to_dict() except DynamicApiError as exc: msg = "Failed to apply object: {0}".format(exc.body) if self.warnings: @@ -775,11 +783,14 @@ class K8sAnsibleMixin(object): parameter has been set to '{state}'".format( kind=definition['kind'], name=origin_name, state=state) return result - elif self.check_mode: + elif self.check_mode and not self.supports_dry_run: k8s_obj = _encode_stringdata(definition) else: + params = {} + if self.check_mode: + params['dry_run'] = "All" try: - k8s_obj = resource.create(definition, namespace=namespace).to_dict() + k8s_obj = resource.create(definition, namespace=namespace, **params).to_dict() except ConflictError: # Some resources, like ProjectRequests, can't be created multiple times, # because the resources that they create don't match their kind @@ -826,11 +837,14 @@ class K8sAnsibleMixin(object): diffs = [] if state == 'present' and existing and force: - if self.check_mode: + if self.check_mode and not self.supports_dry_run: k8s_obj = _encode_stringdata(definition) else: + params = {} + if self.check_mode: + params['dry_run'] = "All" try: - k8s_obj = resource.replace(definition, name=name, namespace=namespace, append_hash=append_hash).to_dict() + k8s_obj = resource.replace(definition, name=name, namespace=namespace, append_hash=append_hash, **params).to_dict() except DynamicApiError as exc: msg = "Failed to replace object: {0}".format(exc.body) if self.warnings: @@ -861,7 +875,7 @@ class K8sAnsibleMixin(object): return result # Differences exist between the existing obj and requested params - if self.check_mode: + if self.check_mode and not self.supports_dry_run: k8s_obj = dict_merge(existing.to_dict(), _encode_stringdata(definition)) else: for merge_type in self.params['merge_type'] or ['strategic-merge', 'merge']: @@ -903,6 +917,8 @@ class K8sAnsibleMixin(object): version="3.0.0", collection_name="kubernetes.core") try: params = dict(name=name, namespace=namespace) + if self.check_mode: + params['dry_run'] = 'All' if merge_type: params['content_type'] = 'application/{0}-patch+json'.format(merge_type) k8s_obj = resource.patch(definition, **params).to_dict() diff --git a/plugins/module_utils/k8sdynamicclient.py b/plugins/module_utils/k8sdynamicclient.py index 0827009c..73fe6197 100644 --- a/plugins/module_utils/k8sdynamicclient.py +++ b/plugins/module_utils/k8sdynamicclient.py @@ -24,7 +24,7 @@ from ansible_collections.kubernetes.core.plugins.module_utils.exceptions import class K8SDynamicClient(DynamicClient): - def apply(self, resource, body=None, name=None, namespace=None): + def apply(self, resource, body=None, name=None, namespace=None, **kwargs): body = super().serialize_body(body) body['metadata'] = body.get('metadata', dict()) name = name or body['metadata'].get('name') @@ -33,7 +33,7 @@ class K8SDynamicClient(DynamicClient): if resource.namespaced: body['metadata']['namespace'] = super().ensure_namespace(resource, namespace, body) try: - return k8s_apply(resource, body) + return k8s_apply(resource, body, **kwargs) except ApplyException as e: raise ValueError("Could not apply strategic merge to %s/%s: %s" % (body['kind'], body['metadata']['name'], e)) diff --git a/plugins/modules/k8s_json_patch.py b/plugins/modules/k8s_json_patch.py index c38e2eb6..f3adf8a3 100644 --- a/plugins/modules/k8s_json_patch.py +++ b/plugins/modules/k8s_json_patch.py @@ -234,13 +234,16 @@ def execute_module(k8s_module, module): 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: + if module.check_mode and not k8s_module.supports_dry_run: obj, error = json_patch(existing.to_dict(), patch) if error: module.fail_json(**error) else: + params = {} + if module.check_mode: + params["dry_run"] = "All" try: - obj = resource.patch(patch, name=name, namespace=namespace, content_type="application/json-patch+json").to_dict() + obj = resource.patch(patch, name=name, namespace=namespace, content_type="application/json-patch+json", **params).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)