diff --git a/plugins/doc_fragments/k8s_wait_options.py b/plugins/doc_fragments/k8s_wait_options.py index 34456e44..6b1f4d53 100644 --- a/plugins/doc_fragments/k8s_wait_options.py +++ b/plugins/doc_fragments/k8s_wait_options.py @@ -64,9 +64,21 @@ options: - The possible reasons in a condition are specific to each resource type in Kubernetes. - See the API documentation of the status field for a given resource to see possible choices. type: dict - wait_for: + wait_property: description: - Specifies a property on the resource to wait for. - Ignored if C(wait) is not set or is set to I(False). - type: str + type: dict + suboptions: + property: + type: str + required: True + description: + - The property name to wait for. + - This value must be jmespath valid expression, see details here U(http://jmespath.org). + value: + type: str + description: + - The expected value of the C(property). + - If this is missing, we will check only that the attribute C(property) is present. ''' diff --git a/plugins/module_utils/args_common.py b/plugins/module_utils/args_common.py index 1284c6f8..02d1ba0f 100644 --- a/plugins/module_utils/args_common.py +++ b/plugins/module_utils/args_common.py @@ -71,7 +71,14 @@ WAIT_ARG_SPEC = dict( reason=dict() ) ), - wait_for=dict() + wait_property=dict( + type='dict', + default=None, + options=dict( + property=dict(), + value=dict() + ) + ) ) # Map kubernetes-client parameters to ansible parameters diff --git a/plugins/module_utils/common.py b/plugins/module_utils/common.py index d1fa17a8..5e4a3b87 100644 --- a/plugins/module_utils/common.py +++ b/plugins/module_utils/common.py @@ -29,7 +29,7 @@ from distutils.version import LooseVersion from ansible_collections.kubernetes.core.plugins.module_utils.args_common import (AUTH_ARG_MAP, AUTH_ARG_SPEC, AUTH_PROXY_HEADERS_SPEC) from ansible_collections.kubernetes.core.plugins.module_utils.hashes import generate_hash -from ansible_collections.kubernetes.core.plugins.module_utils.exceptions import WaitException +from ansible_collections.kubernetes.core.plugins.module_utils.jsonpath import match_json_property from ansible.module_utils.basic import missing_required_lib from ansible.module_utils.six import iteritems, string_types @@ -98,14 +98,6 @@ except ImportError as e: k8s_import_exception = e K8S_IMP_ERR = traceback.format_exc() -try: - import jsonpath_rw - HAS_JSONPATH_RW = True - jsonpath_import_exception = None -except ImportError as e: - HAS_JSONPATH_RW = False - jsonpath_import_exception = e - JSON_PATCH_IMP_ERR = None try: import jsonpatch @@ -250,7 +242,7 @@ class K8sAnsibleMixin(object): self.fail(msg='Failed to find exact match for {0}.{1} by [kind, name, singularName, shortNames]'.format(api_version, kind)) def kubernetes_facts(self, kind, api_version, name=None, namespace=None, label_selectors=None, field_selectors=None, - wait=False, wait_sleep=5, wait_timeout=120, state='present', condition=None, wait_for=None): + wait=False, wait_sleep=5, wait_timeout=120, state='present', condition=None, wait_property=None): resource = self.find_resource(kind, api_version) api_found = bool(resource) if not api_found: @@ -306,7 +298,7 @@ class K8sAnsibleMixin(object): for resource_instance in resource_list: success, res, duration = self.wait(resource, resource_instance, sleep=wait_sleep, timeout=wait_timeout, - state=state, condition=condition, wait_for=wait_for) + state=state, condition=condition, wait_property=wait_property) if not success: self.fail(msg="Failed to gather information about %s(s) even" " after waiting for %s seconds" % (res.get('kind'), duration)) @@ -391,7 +383,7 @@ class K8sAnsibleMixin(object): response = response.to_dict() return False, response, _wait_for_elapsed() - def wait(self, resource, definition, sleep, timeout, state='present', condition=None, wait_for=None): + def wait(self, resource, definition, sleep, timeout, state='present', condition=None, property=None): def _deployment_ready(deployment): # FIXME: frustratingly bool(deployment.status) is True even if status is empty @@ -441,28 +433,16 @@ class K8sAnsibleMixin(object): def _resource_absent(resource): return not resource - # wait_for requires jsonpath-rw library - jsonpath_expr = None - if wait_for is not None: - if not HAS_JSONPATH_RW: - if hasattr(self, 'fail_json'): - self.fail_json(msg=missing_required_lib('jsonpath_rw'), error=to_native(jsonpath_import_exception)) - raise WaitException("wait_for option requires 'jsonpath_rw' library") - try: - wait_expr = wait_for - if wait_for.startswith("."): - wait_expr = "$" + wait_for - jsonpath_expr = jsonpath_rw.parse(wait_expr) - except Exception as parse_err: - if hasattr(self, 'fail_json'): - self.fail_json(msg="Failed to parse wait_for attribute {0}".format(wait_for), error=to_native(parse_err)) - raise WaitException("Failed to parse wait_for attribute {0} error is {1}".format(wait_for, to_native(parse_err))) + with open("/tmp/resource.txt", "w+") as f: + import json + f.write("------- Property -------\n{}".format(json.dumps(property, indent=2))) def _wait_for_property(resource): - try: - return all([match.value for match in jsonpath_expr.find(resource)]) - except Exception as e: - return False + test = match_json_property(self, resource.to_dict(), property.get('property'), property.get('value', None)) + with open("/tmp/resource.txt", "w+") as f: + import json + f.write("------- test = {}\n{}".format(test, json.dumps(resource.to_dict(), indent=2))) + return test waiter = dict( Deployment=_deployment_ready, @@ -472,17 +452,17 @@ class K8sAnsibleMixin(object): kind = definition['kind'] predicates = [] if state == 'present': - if condition is None and wait_for is None: + if condition is None and property is None: predicates.append(waiter.get(kind, lambda x: x)) else: if condition: # add waiter on custom condition predicates.append(_custom_condition) - if wait_for: + if property: # json path predicate predicates.append(_wait_for_property) else: - predicates.append(_resource_absent) + predicates = [_resource_absent] return self._wait_for(resource, definition['metadata']['name'], definition['metadata'].get('namespace'), predicates, sleep, timeout, state) def set_resource_definitions(self, module): @@ -625,7 +605,7 @@ class K8sAnsibleMixin(object): continue_on_error = self.params.get('continue_on_error') if self.params.get('wait_condition') and self.params['wait_condition'].get('type'): wait_condition = self.params['wait_condition'] - wait_for = self.params.get('wait_for') + wait_property = self.params.get('wait_property') def build_error_msg(kind, name, msg): return "%s %s: %s" % (kind, name, msg) @@ -735,7 +715,7 @@ class K8sAnsibleMixin(object): result['result'] = k8s_obj if wait and not self.check_mode: success, result['result'], result['duration'] = self.wait(resource, definition, wait_sleep, wait_timeout, - condition=wait_condition, wait_for=wait_for) + condition=wait_condition, property=wait_property) if existing: existing = existing.to_dict() else: @@ -788,8 +768,8 @@ class K8sAnsibleMixin(object): success = True result['result'] = k8s_obj if wait and not self.check_mode: - success, result['result'], result['duration'] = self.wait(resource, definition, wait_sleep, - wait_timeout, condition=wait_condition, wait_for=wait_for) + success, result['result'], result['duration'] = self.wait(resource, definition, wait_sleep, wait_timeout, + condition=wait_condition, property=wait_property) result['changed'] = True result['method'] = 'create' if not success: @@ -824,8 +804,8 @@ class K8sAnsibleMixin(object): success = True result['result'] = k8s_obj if wait and not self.check_mode: - success, result['result'], result['duration'] = self.wait(resource, definition, wait_sleep, - wait_timeout, condition=wait_condition, wait_for=wait_for) + success, result['result'], result['duration'] = self.wait(resource, definition, wait_sleep, wait_timeout, + condition=wait_condition, property=wait_property) match, diffs = self.diff_objects(existing.to_dict(), result['result']) result['changed'] = not match result['method'] = 'replace' @@ -859,8 +839,8 @@ class K8sAnsibleMixin(object): success = True result['result'] = k8s_obj if wait and not self.check_mode: - success, result['result'], result['duration'] = self.wait(resource, definition, - wait_sleep, wait_timeout, condition=wait_condition, wait_for=wait_for) + success, result['result'], result['duration'] = self.wait(resource, definition, wait_sleep, wait_timeout, + condition=wait_condition, property=wait_property) match, diffs = self.diff_objects(existing.to_dict(), result['result']) result['changed'] = not match result['method'] = 'patch' diff --git a/plugins/module_utils/exceptions.py b/plugins/module_utils/exceptions.py index fba4165c..35d3c2fd 100644 --- a/plugins/module_utils/exceptions.py +++ b/plugins/module_utils/exceptions.py @@ -19,7 +19,3 @@ __metaclass__ = type class ApplyException(Exception): """ Could not apply patch """ - - -class WaitException(Exception): - """ Bad parameters for Wait operation """ diff --git a/plugins/module_utils/jsonpath.py b/plugins/module_utils/jsonpath.py new file mode 100644 index 00000000..faa6e39b --- /dev/null +++ b/plugins/module_utils/jsonpath.py @@ -0,0 +1,62 @@ +# Copyright [2021] [Red Hat, Inc.] +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + + +from __future__ import absolute_import, division, print_function +__metaclass__ = type + +import traceback +from ansible.module_utils.basic import missing_required_lib +from ansible.module_utils._text import to_native + +try: + import jmespath + HAS_JMESPATH_LIB = True + jmespath_import_exception = None +except ImportError as e: + HAS_JMESPATH_LIB = False + jmespath_import_exception = e + JMESPATH_IMP_ERR = traceback.format_exc() + + +def match_json_property(module, data, expr, value=None): + """ + This function uses jmespath to validate json data + - module: running the function (used to fail in case of error) + - data: JSON document + - expr: Specify how to extract elements from a JSON document (jmespath, http://jmespath.org) + - value: the matching JSON element should have this value, if set to None this is ignored + """ + def _raise_or_fail(err, **kwargs): + if module and hasattr(module, "fail_json"): + module.fail_json(error=to_native(err), **kwargs) + raise err + + def _match_value(buf, v): + # convert all values from bool to str and lowercase them + return v.lower() in [str(i).lower() for i in buf] + + if not HAS_JMESPATH_LIB: + _raise_or_fail(jmespath_import_exception, msg=missing_required_lib('jmespath'), exception=JMESPATH_IMP_ERR) + + jmespath.functions.REVERSE_TYPES_MAP['string'] = jmespath.functions.REVERSE_TYPES_MAP['string'] + ('AnsibleUnicode', 'AnsibleUnsafeText', ) + try: + content = jmespath.search(expr, data) + if not content: + return False + if not value or _match_value(content, value): + return True + return False + except Exception as err: + _raise_or_fail(err, msg="JMESPathError failed to extract from JSON document using expr: {}".format(expr)) diff --git a/plugins/modules/k8s.py b/plugins/modules/k8s.py index f98044b4..d5a4f45a 100644 --- a/plugins/modules/k8s.py +++ b/plugins/modules/k8s.py @@ -238,11 +238,19 @@ EXAMPLES = r''' reason: DeploymentPaused # Wait for this service to have acquired an External IP -- name: Deploy the dashboard service (lb) +- name: Create ingress and wait for ip to be assigned kubernetes.core.k8s: template: dash-service.yaml wait: yes - wait_for: .status.loadBalancer.ingress[*].ip + wait_property: + property: status.loadBalancer.ingress[*].ip + +- name: Create Pod and wait for containers for be running + kubernetes.core.k8s: + template: pod.yaml + wait: yes + wait_property: + property: status.containerStatuses[*].state.running ''' RETURN = r''' diff --git a/tests/unit/module_utils/test_jsonpath.py b/tests/unit/module_utils/test_jsonpath.py new file mode 100644 index 00000000..59be299a --- /dev/null +++ b/tests/unit/module_utils/test_jsonpath.py @@ -0,0 +1,69 @@ +# Copyright [2021] [Red Hat, Inc.] +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + + +from __future__ import absolute_import, division, print_function +__metaclass__ = type + +from ansible_collections.kubernetes.core.plugins.module_utils.jsonpath import match_json_property + +import pytest +jmespath = pytest.importorskip("jmespath") + + +def test_property_present(): + data = { + "Kind": "Pod", + "containers": [ + {"name": "t0", "image": "nginx"}, + {"name": "t1", "image": "python"}, + {"name": "t2", "image": "mongo", "state": "running"} + ] + } + assert match_json_property(None, data, "containers[*].state") + assert not match_json_property(None, data, "containers[*].status") + + +def test_property_value(): + data = { + "Kind": "Pod", + "containers": [ + {"name": "t0", "image": "nginx"}, + {"name": "t1", "image": "python"}, + {"name": "t2", "image": "mongo", "state": "running"} + ] + } + assert match_json_property(None, data, "containers[*].state", "running") + assert match_json_property(None, data, "containers[*].state", "Running") + assert not match_json_property(None, data, "containers[*].state", "off") + + +def test_boolean_value(): + data = { + "containers": [ + {"image": "nginx"}, + {"image": "python"}, + {"image": "mongo", "connected": True} + ] + } + assert match_json_property(None, data, "containers[*].connected", "true") + assert match_json_property(None, data, "containers[*].connected", "True") + assert match_json_property(None, data, "containers[*].connected", "TRUE") + + +def test_valid_expression(): + data = dict(key="ansible", value="unit-test") + with pytest.raises(jmespath.exceptions.ParseError) as parsing_err: + match_json_property(None, data, ".ansible") + assert "Parse error" in str(parsing_err.value) diff --git a/tests/unit/requirements.txt b/tests/unit/requirements.txt index 55c7255f..f46d016c 100644 --- a/tests/unit/requirements.txt +++ b/tests/unit/requirements.txt @@ -1,3 +1,4 @@ pytest PyYAML kubernetes +jmespath