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 0000000000..cf3c40c04d --- /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 03dfa402f0..b6b1bd26ca 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 486d9a5a2c..034859cdec 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 2ace57795e..c3ca521721 100644 --- a/plugins/module_utils/common.py +++ b/plugins/module_utils/common.py @@ -229,6 +229,7 @@ def __init__(self, module, pyyaml_required=True, *args, **kwargs): 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 @@ def _empty_resource_list(): 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 @@ def _empty_resource_list(): } 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 @@ def _empty_resource_list(): 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 @@ def _empty_resource_list(): 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 @@ def _empty_resource_list(): 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 @@ def _empty_resource_list(): 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 @@ def _empty_resource_list(): 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 @@ def _empty_resource_list(): 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 @@ def patch_resource(self, resource, definition, existing, name, namespace, merge_ 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 0827009ce0..73fe61971d 100644 --- a/plugins/module_utils/k8sdynamicclient.py +++ b/plugins/module_utils/k8sdynamicclient.py @@ -24,7 +24,7 @@ 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 @@ def apply(self, resource, body=None, name=None, namespace=None): 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 c38e2eb6b5..f3adf8a39e 100644 --- a/plugins/modules/k8s_json_patch.py +++ b/plugins/modules/k8s_json_patch.py @@ -234,13 +234,16 @@ def build_error_msg(kind, name, msg): 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)