From a6231864b2cf440f1bac55ed6ff4d073e618ebe2 Mon Sep 17 00:00:00 2001 From: abikouo Date: Wed, 30 Jun 2021 17:40:03 +0200 Subject: [PATCH 1/9] add label_selectors for k8s --- .../157-k8s-add-support-label_selectors.yml | 3 + molecule/default/converge.yml | 8 + molecule/default/tasks/label_selectors.yml | 243 ++++++++++++++++++ plugins/module_utils/common.py | 44 +++- plugins/module_utils/selector.py | 59 +++++ plugins/modules/k8s.py | 7 + tests/unit/module_utils/test_selector.py | 131 ++++++++++ 7 files changed, 484 insertions(+), 11 deletions(-) create mode 100644 changelogs/fragments/157-k8s-add-support-label_selectors.yml create mode 100644 molecule/default/tasks/label_selectors.yml create mode 100644 plugins/module_utils/selector.py create mode 100644 tests/unit/module_utils/test_selector.py diff --git a/changelogs/fragments/157-k8s-add-support-label_selectors.yml b/changelogs/fragments/157-k8s-add-support-label_selectors.yml new file mode 100644 index 0000000000..a4bd473d9f --- /dev/null +++ b/changelogs/fragments/157-k8s-add-support-label_selectors.yml @@ -0,0 +1,3 @@ +--- +minor_changes: +- k8s - add support for label_selectors options (https://github.com/ansible-collections/kubernetes.core/issues/43). diff --git a/molecule/default/converge.yml b/molecule/default/converge.yml index 730081e6a8..118dc16e8b 100644 --- a/molecule/default/converge.yml +++ b/molecule/default/converge.yml @@ -164,6 +164,14 @@ tags: - always + - name: Include label_selectors.yml + include_tasks: + file: tasks/label_selectors.yml + apply: + tags: [ label_selectors, k8s ] + tags: + - always + roles: - role: helm tags: diff --git a/molecule/default/tasks/label_selectors.yml b/molecule/default/tasks/label_selectors.yml new file mode 100644 index 0000000000..62fad3c84c --- /dev/null +++ b/molecule/default/tasks/label_selectors.yml @@ -0,0 +1,243 @@ +--- +- block: + - set_fact: + selector_namespace: "selector" + selector_pod_delete: "pod-selector-delete" + selector_pod_apply: "pod-selector-apply" + selector_pod_create: + - "pod-selector-apply-00" + - "pod-selector-apply-01" + + - name: Ensure namespace selector + k8s: + kind: namespace + name: '{{ selector_namespace }}' + + # Resource deletion using label selector + - name: Create simple pod + k8s: + namespace: '{{ selector_namespace }}' + definition: + apiVersion: v1 + kind: Pod + metadata: + name: '{{ selector_pod_delete }}' + labels: + ansible.dev/team: "cloud" + ansible.release/version: upstream + ansible.dev/test: "true" + spec: + containers: + - name: c0 + image: busybox + command: + - /bin/sh + - -c + - while true;do date;sleep 5; done + + - name: Delete all resource using selector + k8s: + state: absent + kind: Pod + namespace: '{{ selector_namespace }}' + label_selectors: + - ansible.dev/team=cloud + wait: yes + wait_timeout: 180 + + - name: Ensure resources have been deleted + k8s_info: + kind: Pod + namespace: '{{ selector_namespace }}' + label_selectors: + - ansible.dev/team=cloud + register: result + + - assert: + that: + - result.resources == [] + + # Resource creation using label selector + - name: Create simple pod using label_selectors option (no resource matching label) + k8s: + namespace: '{{ selector_namespace }}' + label_selectors: + - ansible.pod/created=true + definition: | + --- + apiVersion: v1 + kind: Pod + metadata: + name: '{{ selector_pod_create[0] }}' + labels: + ansible.pod/created: "false" + spec: + containers: + - name: c0 + image: busybox + command: + - /bin/sh + - -c + - while true;do date;sleep 5; done + --- + apiVersion: v1 + kind: Pod + metadata: + name: '{{ selector_pod_create[1] }}' + labels: + ansible.pod/created: "false" + spec: + containers: + - name: c0 + image: busybox + command: + - /bin/sh + - -c + - while true;do date;sleep 5; done + register: result + + - assert: + that: + - result is not changed + + - name: Create simple pod using label_selectors option + k8s: + namespace: '{{ selector_namespace }}' + label_selectors: + - ansible.pod/created=true + definition: | + --- + apiVersion: v1 + kind: Pod + metadata: + name: '{{ selector_pod_create[0] }}' + labels: + ansible.pod/created: "false" + spec: + containers: + - name: c0 + image: busybox + command: + - /bin/sh + - -c + - while true;do date;sleep 5; done + --- + apiVersion: v1 + kind: Pod + metadata: + name: '{{ selector_pod_create[1] }}' + labels: + ansible.pod/created: "true" + spec: + containers: + - name: c0 + image: busybox + command: + - /bin/sh + - -c + - while true;do date;sleep 5; done + register: result + + - assert: + that: + - result is changed + + - name: list pod created + k8s_info: + namespace: '{{ selector_namespace }}' + kind: Pod + register: pod_created + + - name: Validate that pod with matching label was created + assert: + that: + - selector_pod_create[0] not in pods_created + - selector_pod_create[1] in pods_created + vars: + pods_created: '{{ pod_created.resources | map(attribute="metadata.name") | list }}' + + # Resource update using apply + - name: Create simple pod using apply + k8s: + namespace: '{{ selector_namespace }}' + apply: yes + definition: + apiVersion: v1 + kind: Pod + metadata: + name: '{{ selector_pod_apply }}' + labels: + ansible.dev/test: "false" + spec: + containers: + - name: c0 + image: busybox:1.31.0 + command: + - /bin/sh + - -c + - while true;do date;sleep 5; done + + - name: Apply new pod definition using label_selectors (no match) + k8s: + namespace: '{{ selector_namespace }}' + apply: yes + definition: + apiVersion: v1 + kind: Pod + metadata: + name: '{{ selector_pod_apply }}' + labels: + ansible.dev/test: "false" + spec: + containers: + - name: c0 + image: busybox:1.33.0 + command: + - /bin/sh + - -c + - while true;do date;sleep 5; done + label_selectors: + - ansible.dev/test=true + register: result + + - name: check task output + assert: + that: + - result is not changed + - '"filtered by label_selectors" in result.warning' + + - name: Apply new pod definition using label_selectors + k8s: + namespace: '{{ selector_namespace }}' + apply: yes + definition: + apiVersion: v1 + kind: Pod + metadata: + name: '{{ selector_pod_apply }}' + labels: + ansible.dev/test: "false" + spec: + containers: + - name: c0 + image: busybox:1.33.0 + command: + - /bin/sh + - -c + - while true;do date;sleep 5; done + label_selectors: + - ansible.dev/test=false + register: result + + - name: check task output + assert: + that: + - result is changed + + always: + - name: Ensure namespace is deleted + k8s: + kind: Namespace + name: '{{ selector_namespace }}' + state: absent + ignore_errors: true diff --git a/plugins/module_utils/common.py b/plugins/module_utils/common.py index 48e55ec044..feb8a0b994 100644 --- a/plugins/module_utils/common.py +++ b/plugins/module_utils/common.py @@ -29,6 +29,7 @@ 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.selector import LabelSelectorFilter from ansible.module_utils.basic import missing_required_lib from ansible.module_utils.six import iteritems, string_types @@ -349,7 +350,7 @@ def diff_objects(self, existing, new): def fail(self, msg=None): self.fail_json(msg=msg) - def _wait_for(self, resource, name, namespace, predicate, sleep, timeout, state): + def _wait_for(self, resource, name, namespace, predicate, sleep, timeout, state, label_selectors): start = datetime.now() def _wait_for_elapsed(): @@ -358,7 +359,10 @@ def _wait_for_elapsed(): response = None while _wait_for_elapsed() < timeout: try: - response = resource.get(name=name, namespace=namespace) + params = dict(name=name, namespace=namespace) + if label_selectors: + params['label_selector'] = ','.join(label_selectors) + response = resource.get(**params) if predicate(response): if response: return True, response.to_dict(), _wait_for_elapsed() @@ -371,7 +375,7 @@ def _wait_for_elapsed(): response = response.to_dict() return False, response, _wait_for_elapsed() - def wait(self, resource, definition, sleep, timeout, state='present', condition=None): + def wait(self, resource, definition, sleep, timeout, state='present', condition=None, label_selectors=None): def _deployment_ready(deployment): # FIXME: frustratingly bool(deployment.status) is True even if status is empty @@ -420,7 +424,7 @@ def _custom_condition(resource): return False def _resource_absent(resource): - return not resource + return not resource or (resource.kind.endswith('List') and resource.items == []) waiter = dict( Deployment=_deployment_ready, @@ -428,13 +432,13 @@ def _resource_absent(resource): Pod=_pod_ready ) kind = definition['kind'] - if state == 'present' and not condition: - predicate = waiter.get(kind, lambda x: x) - elif state == 'present' and condition: - predicate = _custom_condition + if state == 'present': + predicate = waiter.get(kind, lambda x: x) if not condition else _custom_condition else: predicate = _resource_absent - return self._wait_for(resource, definition['metadata']['name'], definition['metadata'].get('namespace'), predicate, sleep, timeout, state) + name = definition['metadata']['name'] + namespace = definition['metadata'].get('namespace') + return self._wait_for(resource, name, namespace, predicate, sleep, timeout, state, label_selectors) def set_resource_definitions(self, module): resource_definition = module.params.get('resource_definition') @@ -575,6 +579,7 @@ def perform_action(self, resource, definition): wait_timeout = self.params.get('wait_timeout') wait_condition = None continue_on_error = self.params.get('continue_on_error') + label_selectors = self.params.get('label_selectors') if self.params.get('wait_condition') and self.params['wait_condition'].get('type'): wait_condition = self.params['wait_condition'] @@ -591,6 +596,8 @@ def build_error_msg(kind, name, msg): params = dict(name=name) if namespace: params['namespace'] = namespace + if label_selectors: + params['label_selector'] = ','.join(label_selectors) existing = resource.get(**params) except (NotFoundError, MethodNotAllowedError): # Remove traceback so that it doesn't show up in later failures @@ -625,8 +632,15 @@ def build_error_msg(kind, name, msg): if state == 'absent': result['method'] = "delete" - if not existing: + + def _empty_resource_list(): + if existing and existing.kind.endswith('List'): + return existing.items == [] + return False + + if not existing or _empty_resource_list(): # The object already does not exist + result['result']['warning'] = 'No resources found' return result else: # Delete the object @@ -651,7 +665,7 @@ def build_error_msg(kind, name, msg): else: self.fail_json(msg=build_error_msg(definition['kind'], origin_name, msg), error=exc.status, status=exc.status, reason=exc.reason) if wait: - success, resource, duration = self.wait(resource, definition, wait_sleep, wait_timeout, 'absent') + success, resource, duration = self.wait(resource, definition, wait_sleep, wait_timeout, 'absent', label_selectors=label_selectors) result['duration'] = duration if not success: msg = "Resource deletion timed out" @@ -663,6 +677,14 @@ def build_error_msg(kind, name, msg): return result else: + if label_selectors: + filter_selector = LabelSelectorFilter(label_selectors) + if not filter_selector.isMatching(definition): + result['changed'] = False + del result['result'] + result['warning'] = "resource 'kind={kind},name={name},namespace={namespace}' filtered by label_selectors.".format( + kind=definition['kind'], name=origin_name, namespace=namespace) + return result if apply: if self.check_mode: ignored, patch = apply_object(resource, _encode_stringdata(definition)) diff --git a/plugins/module_utils/selector.py b/plugins/module_utils/selector.py new file mode 100644 index 0000000000..ce6050afbe --- /dev/null +++ b/plugins/module_utils/selector.py @@ -0,0 +1,59 @@ +# 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 + + +class Selector(object): + + def __init__(self, data): + self._operator = None + self._data = '' + for sep in ('==', '!=', '='): + pos = data.find(sep) + if pos != -1: + break + if pos == -1: + self._key = data + else: + self._key = data[0:pos] + self._operator = sep + self._data = data[pos + len(sep):] + + def isMatch(self, labels): + if self._key not in labels: + return False + if self._operator in ('=', '=='): + return self._data == labels.get(self._key) + elif self._operator == '!=': + print("'{}' and {} (differs)".format(self._data, labels.get(self._key))) + return self._data != labels.get(self._key) + # operator not defined + return True + + +class LabelSelectorFilter(object): + + def __init__(self, label_selectors): + self.selectors = [Selector(data) for data in label_selectors] + + def isMatching(self, definition): + if "metadata" not in definition or "labels" not in definition['metadata']: + return False + labels = definition['metadata']['labels'] + if not isinstance(labels, dict): + return None + return all([sel.isMatch(labels) for sel in self.selectors]) diff --git a/plugins/modules/k8s.py b/plugins/modules/k8s.py index 5bcb51cff2..d2fb02a56d 100644 --- a/plugins/modules/k8s.py +++ b/plugins/modules/k8s.py @@ -135,6 +135,12 @@ type: bool default: False version_added: 2.0.0 + label_selectors: + description: + - Selector (label query) to filter on. + type: list + elements: str + version_added: 2.2.0 requirements: - "python >= 3.6" @@ -335,6 +341,7 @@ def argspec(): argument_spec['continue_on_error'] = dict(type='bool', default=False) argument_spec['state'] = dict(default='present', choices=['present', 'absent', 'patched']) argument_spec['force'] = dict(type='bool', default=False) + argument_spec['label_selectors'] = dict(type='list', elements='str') return argument_spec diff --git a/tests/unit/module_utils/test_selector.py b/tests/unit/module_utils/test_selector.py new file mode 100644 index 0000000000..e48564459b --- /dev/null +++ b/tests/unit/module_utils/test_selector.py @@ -0,0 +1,131 @@ +# 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.selector import LabelSelectorFilter + +prod_definition = { + 'apiVersion': 'v1', + 'kind': 'Pod', + 'metadata': { + 'name': 'test', + 'labels': { + 'environment': 'production', + 'app': 'nginx', + } + }, + 'spec': { + 'containers': [ + {'name': 'nginx', 'image': 'nginx:1.14.2', 'command': ['/bin/sh', '-c', 'sleep 10']} + ] + } +} + +no_label_definition = { + 'apiVersion': 'v1', + 'kind': 'Pod', + 'metadata': { + 'name': 'test', + 'labels': {} + }, + 'spec': { + 'containers': [ + {'name': 'nginx', 'image': 'nginx:1.14.2', 'command': ['/bin/sh', '-c', 'sleep 10']} + ] + } +} + +test_definition = { + 'apiVersion': 'v1', + 'kind': 'Pod', + 'metadata': { + 'name': 'test', + 'labels': { + 'environment': 'test', + 'app': 'nginx', + } + }, + 'spec': { + 'containers': [ + {'name': 'nginx', 'image': 'nginx:1.15.2', 'command': ['/bin/sh', '-c', 'sleep 10']} + ] + } +} + + +def test_label_selector_without_operator(): + label_selector = ['environment', 'app'] + assert LabelSelectorFilter(label_selector).isMatching(prod_definition) + assert not LabelSelectorFilter(label_selector).isMatching(no_label_definition) + assert LabelSelectorFilter(label_selector).isMatching(test_definition) + + +def test_label_selector_equal_operator(): + label_selector = ['environment==test'] + assert not LabelSelectorFilter(label_selector).isMatching(prod_definition) + assert not LabelSelectorFilter(label_selector).isMatching(no_label_definition) + assert LabelSelectorFilter(label_selector).isMatching(test_definition) + label_selector = ['environment=production'] + assert LabelSelectorFilter(label_selector).isMatching(prod_definition) + assert not LabelSelectorFilter(label_selector).isMatching(no_label_definition) + assert not LabelSelectorFilter(label_selector).isMatching(test_definition) + label_selector = ['environment=production', 'app==mongodb'] + assert not LabelSelectorFilter(label_selector).isMatching(prod_definition) + assert not LabelSelectorFilter(label_selector).isMatching(no_label_definition) + assert not LabelSelectorFilter(label_selector).isMatching(test_definition) + label_selector = ['environment=production', 'app==nginx'] + assert LabelSelectorFilter(label_selector).isMatching(prod_definition) + assert not LabelSelectorFilter(label_selector).isMatching(no_label_definition) + assert not LabelSelectorFilter(label_selector).isMatching(test_definition) + label_selector = ['environment', 'app==nginx'] + assert LabelSelectorFilter(label_selector).isMatching(prod_definition) + assert not LabelSelectorFilter(label_selector).isMatching(no_label_definition) + assert LabelSelectorFilter(label_selector).isMatching(test_definition) + + +def test_label_selector_notequal_operator(): + label_selector = ['environment!=test'] + assert LabelSelectorFilter(label_selector).isMatching(prod_definition) + assert not LabelSelectorFilter(label_selector).isMatching(no_label_definition) + assert not LabelSelectorFilter(label_selector).isMatching(test_definition) + label_selector = ['environment!=production'] + assert not LabelSelectorFilter(label_selector).isMatching(prod_definition) + assert not LabelSelectorFilter(label_selector).isMatching(no_label_definition) + assert LabelSelectorFilter(label_selector).isMatching(test_definition) + label_selector = ['environment=production', 'app!=mongodb'] + assert LabelSelectorFilter(label_selector).isMatching(prod_definition) + assert not LabelSelectorFilter(label_selector).isMatching(no_label_definition) + assert not LabelSelectorFilter(label_selector).isMatching(test_definition) + label_selector = ['environment=production', 'app!=nginx'] + assert not LabelSelectorFilter(label_selector).isMatching(prod_definition) + assert not LabelSelectorFilter(label_selector).isMatching(no_label_definition) + assert not LabelSelectorFilter(label_selector).isMatching(test_definition) + label_selector = ['environment', 'app!=nginx'] + assert not LabelSelectorFilter(label_selector).isMatching(prod_definition) + assert not LabelSelectorFilter(label_selector).isMatching(no_label_definition) + assert not LabelSelectorFilter(label_selector).isMatching(test_definition) + + +def test_label_selector_conflicting_definition(): + label_selector = ['environment==test', 'environment!=test'] + assert not LabelSelectorFilter(label_selector).isMatching(prod_definition) + assert not LabelSelectorFilter(label_selector).isMatching(no_label_definition) + assert not LabelSelectorFilter(label_selector).isMatching(test_definition) + label_selector = ['environment==test', 'environment==production'] + assert not LabelSelectorFilter(label_selector).isMatching(prod_definition) + assert not LabelSelectorFilter(label_selector).isMatching(no_label_definition) + assert not LabelSelectorFilter(label_selector).isMatching(test_definition) From 2367612b7a57b6f349a324d144f1d4bacd8ea620 Mon Sep 17 00:00:00 2001 From: abikouo <79859644+abikouo@users.noreply.github.com> Date: Wed, 30 Jun 2021 17:42:33 +0200 Subject: [PATCH 2/9] Rename 157-k8s-add-support-label_selectors.yml to 158-k8s-add-support-label_selectors.yml --- ...abel_selectors.yml => 158-k8s-add-support-label_selectors.yml} | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename changelogs/fragments/{157-k8s-add-support-label_selectors.yml => 158-k8s-add-support-label_selectors.yml} (100%) diff --git a/changelogs/fragments/157-k8s-add-support-label_selectors.yml b/changelogs/fragments/158-k8s-add-support-label_selectors.yml similarity index 100% rename from changelogs/fragments/157-k8s-add-support-label_selectors.yml rename to changelogs/fragments/158-k8s-add-support-label_selectors.yml From 9e22677dadcbceb440f6bf601da096cb3c77dc47 Mon Sep 17 00:00:00 2001 From: abikouo <79859644+abikouo@users.noreply.github.com> Date: Wed, 30 Jun 2021 18:13:40 +0200 Subject: [PATCH 3/9] Update selector.py --- plugins/module_utils/selector.py | 1 - 1 file changed, 1 deletion(-) diff --git a/plugins/module_utils/selector.py b/plugins/module_utils/selector.py index ce6050afbe..80b8d7a8ae 100644 --- a/plugins/module_utils/selector.py +++ b/plugins/module_utils/selector.py @@ -39,7 +39,6 @@ def isMatch(self, labels): if self._operator in ('=', '=='): return self._data == labels.get(self._key) elif self._operator == '!=': - print("'{}' and {} (differs)".format(self._data, labels.get(self._key))) return self._data != labels.get(self._key) # operator not defined return True From c530cc66624d6fafddf13d2cdd800bae90dc403e Mon Sep 17 00:00:00 2001 From: abikouo Date: Wed, 30 Jun 2021 17:40:03 +0200 Subject: [PATCH 4/9] add label_selectors for k8s --- .../157-k8s-add-support-label_selectors.yml | 3 + molecule/default/converge.yml | 8 + molecule/default/tasks/label_selectors.yml | 243 ++++++++++++++++++ plugins/module_utils/common.py | 44 +++- plugins/module_utils/selector.py | 59 +++++ plugins/modules/k8s.py | 7 + tests/unit/module_utils/test_selector.py | 131 ++++++++++ 7 files changed, 484 insertions(+), 11 deletions(-) create mode 100644 changelogs/fragments/157-k8s-add-support-label_selectors.yml create mode 100644 molecule/default/tasks/label_selectors.yml create mode 100644 plugins/module_utils/selector.py create mode 100644 tests/unit/module_utils/test_selector.py diff --git a/changelogs/fragments/157-k8s-add-support-label_selectors.yml b/changelogs/fragments/157-k8s-add-support-label_selectors.yml new file mode 100644 index 0000000000..a4bd473d9f --- /dev/null +++ b/changelogs/fragments/157-k8s-add-support-label_selectors.yml @@ -0,0 +1,3 @@ +--- +minor_changes: +- k8s - add support for label_selectors options (https://github.com/ansible-collections/kubernetes.core/issues/43). diff --git a/molecule/default/converge.yml b/molecule/default/converge.yml index 730081e6a8..118dc16e8b 100644 --- a/molecule/default/converge.yml +++ b/molecule/default/converge.yml @@ -164,6 +164,14 @@ tags: - always + - name: Include label_selectors.yml + include_tasks: + file: tasks/label_selectors.yml + apply: + tags: [ label_selectors, k8s ] + tags: + - always + roles: - role: helm tags: diff --git a/molecule/default/tasks/label_selectors.yml b/molecule/default/tasks/label_selectors.yml new file mode 100644 index 0000000000..62fad3c84c --- /dev/null +++ b/molecule/default/tasks/label_selectors.yml @@ -0,0 +1,243 @@ +--- +- block: + - set_fact: + selector_namespace: "selector" + selector_pod_delete: "pod-selector-delete" + selector_pod_apply: "pod-selector-apply" + selector_pod_create: + - "pod-selector-apply-00" + - "pod-selector-apply-01" + + - name: Ensure namespace selector + k8s: + kind: namespace + name: '{{ selector_namespace }}' + + # Resource deletion using label selector + - name: Create simple pod + k8s: + namespace: '{{ selector_namespace }}' + definition: + apiVersion: v1 + kind: Pod + metadata: + name: '{{ selector_pod_delete }}' + labels: + ansible.dev/team: "cloud" + ansible.release/version: upstream + ansible.dev/test: "true" + spec: + containers: + - name: c0 + image: busybox + command: + - /bin/sh + - -c + - while true;do date;sleep 5; done + + - name: Delete all resource using selector + k8s: + state: absent + kind: Pod + namespace: '{{ selector_namespace }}' + label_selectors: + - ansible.dev/team=cloud + wait: yes + wait_timeout: 180 + + - name: Ensure resources have been deleted + k8s_info: + kind: Pod + namespace: '{{ selector_namespace }}' + label_selectors: + - ansible.dev/team=cloud + register: result + + - assert: + that: + - result.resources == [] + + # Resource creation using label selector + - name: Create simple pod using label_selectors option (no resource matching label) + k8s: + namespace: '{{ selector_namespace }}' + label_selectors: + - ansible.pod/created=true + definition: | + --- + apiVersion: v1 + kind: Pod + metadata: + name: '{{ selector_pod_create[0] }}' + labels: + ansible.pod/created: "false" + spec: + containers: + - name: c0 + image: busybox + command: + - /bin/sh + - -c + - while true;do date;sleep 5; done + --- + apiVersion: v1 + kind: Pod + metadata: + name: '{{ selector_pod_create[1] }}' + labels: + ansible.pod/created: "false" + spec: + containers: + - name: c0 + image: busybox + command: + - /bin/sh + - -c + - while true;do date;sleep 5; done + register: result + + - assert: + that: + - result is not changed + + - name: Create simple pod using label_selectors option + k8s: + namespace: '{{ selector_namespace }}' + label_selectors: + - ansible.pod/created=true + definition: | + --- + apiVersion: v1 + kind: Pod + metadata: + name: '{{ selector_pod_create[0] }}' + labels: + ansible.pod/created: "false" + spec: + containers: + - name: c0 + image: busybox + command: + - /bin/sh + - -c + - while true;do date;sleep 5; done + --- + apiVersion: v1 + kind: Pod + metadata: + name: '{{ selector_pod_create[1] }}' + labels: + ansible.pod/created: "true" + spec: + containers: + - name: c0 + image: busybox + command: + - /bin/sh + - -c + - while true;do date;sleep 5; done + register: result + + - assert: + that: + - result is changed + + - name: list pod created + k8s_info: + namespace: '{{ selector_namespace }}' + kind: Pod + register: pod_created + + - name: Validate that pod with matching label was created + assert: + that: + - selector_pod_create[0] not in pods_created + - selector_pod_create[1] in pods_created + vars: + pods_created: '{{ pod_created.resources | map(attribute="metadata.name") | list }}' + + # Resource update using apply + - name: Create simple pod using apply + k8s: + namespace: '{{ selector_namespace }}' + apply: yes + definition: + apiVersion: v1 + kind: Pod + metadata: + name: '{{ selector_pod_apply }}' + labels: + ansible.dev/test: "false" + spec: + containers: + - name: c0 + image: busybox:1.31.0 + command: + - /bin/sh + - -c + - while true;do date;sleep 5; done + + - name: Apply new pod definition using label_selectors (no match) + k8s: + namespace: '{{ selector_namespace }}' + apply: yes + definition: + apiVersion: v1 + kind: Pod + metadata: + name: '{{ selector_pod_apply }}' + labels: + ansible.dev/test: "false" + spec: + containers: + - name: c0 + image: busybox:1.33.0 + command: + - /bin/sh + - -c + - while true;do date;sleep 5; done + label_selectors: + - ansible.dev/test=true + register: result + + - name: check task output + assert: + that: + - result is not changed + - '"filtered by label_selectors" in result.warning' + + - name: Apply new pod definition using label_selectors + k8s: + namespace: '{{ selector_namespace }}' + apply: yes + definition: + apiVersion: v1 + kind: Pod + metadata: + name: '{{ selector_pod_apply }}' + labels: + ansible.dev/test: "false" + spec: + containers: + - name: c0 + image: busybox:1.33.0 + command: + - /bin/sh + - -c + - while true;do date;sleep 5; done + label_selectors: + - ansible.dev/test=false + register: result + + - name: check task output + assert: + that: + - result is changed + + always: + - name: Ensure namespace is deleted + k8s: + kind: Namespace + name: '{{ selector_namespace }}' + state: absent + ignore_errors: true diff --git a/plugins/module_utils/common.py b/plugins/module_utils/common.py index 48e55ec044..feb8a0b994 100644 --- a/plugins/module_utils/common.py +++ b/plugins/module_utils/common.py @@ -29,6 +29,7 @@ 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.selector import LabelSelectorFilter from ansible.module_utils.basic import missing_required_lib from ansible.module_utils.six import iteritems, string_types @@ -349,7 +350,7 @@ def diff_objects(self, existing, new): def fail(self, msg=None): self.fail_json(msg=msg) - def _wait_for(self, resource, name, namespace, predicate, sleep, timeout, state): + def _wait_for(self, resource, name, namespace, predicate, sleep, timeout, state, label_selectors): start = datetime.now() def _wait_for_elapsed(): @@ -358,7 +359,10 @@ def _wait_for_elapsed(): response = None while _wait_for_elapsed() < timeout: try: - response = resource.get(name=name, namespace=namespace) + params = dict(name=name, namespace=namespace) + if label_selectors: + params['label_selector'] = ','.join(label_selectors) + response = resource.get(**params) if predicate(response): if response: return True, response.to_dict(), _wait_for_elapsed() @@ -371,7 +375,7 @@ def _wait_for_elapsed(): response = response.to_dict() return False, response, _wait_for_elapsed() - def wait(self, resource, definition, sleep, timeout, state='present', condition=None): + def wait(self, resource, definition, sleep, timeout, state='present', condition=None, label_selectors=None): def _deployment_ready(deployment): # FIXME: frustratingly bool(deployment.status) is True even if status is empty @@ -420,7 +424,7 @@ def _custom_condition(resource): return False def _resource_absent(resource): - return not resource + return not resource or (resource.kind.endswith('List') and resource.items == []) waiter = dict( Deployment=_deployment_ready, @@ -428,13 +432,13 @@ def _resource_absent(resource): Pod=_pod_ready ) kind = definition['kind'] - if state == 'present' and not condition: - predicate = waiter.get(kind, lambda x: x) - elif state == 'present' and condition: - predicate = _custom_condition + if state == 'present': + predicate = waiter.get(kind, lambda x: x) if not condition else _custom_condition else: predicate = _resource_absent - return self._wait_for(resource, definition['metadata']['name'], definition['metadata'].get('namespace'), predicate, sleep, timeout, state) + name = definition['metadata']['name'] + namespace = definition['metadata'].get('namespace') + return self._wait_for(resource, name, namespace, predicate, sleep, timeout, state, label_selectors) def set_resource_definitions(self, module): resource_definition = module.params.get('resource_definition') @@ -575,6 +579,7 @@ def perform_action(self, resource, definition): wait_timeout = self.params.get('wait_timeout') wait_condition = None continue_on_error = self.params.get('continue_on_error') + label_selectors = self.params.get('label_selectors') if self.params.get('wait_condition') and self.params['wait_condition'].get('type'): wait_condition = self.params['wait_condition'] @@ -591,6 +596,8 @@ def build_error_msg(kind, name, msg): params = dict(name=name) if namespace: params['namespace'] = namespace + if label_selectors: + params['label_selector'] = ','.join(label_selectors) existing = resource.get(**params) except (NotFoundError, MethodNotAllowedError): # Remove traceback so that it doesn't show up in later failures @@ -625,8 +632,15 @@ def build_error_msg(kind, name, msg): if state == 'absent': result['method'] = "delete" - if not existing: + + def _empty_resource_list(): + if existing and existing.kind.endswith('List'): + return existing.items == [] + return False + + if not existing or _empty_resource_list(): # The object already does not exist + result['result']['warning'] = 'No resources found' return result else: # Delete the object @@ -651,7 +665,7 @@ def build_error_msg(kind, name, msg): else: self.fail_json(msg=build_error_msg(definition['kind'], origin_name, msg), error=exc.status, status=exc.status, reason=exc.reason) if wait: - success, resource, duration = self.wait(resource, definition, wait_sleep, wait_timeout, 'absent') + success, resource, duration = self.wait(resource, definition, wait_sleep, wait_timeout, 'absent', label_selectors=label_selectors) result['duration'] = duration if not success: msg = "Resource deletion timed out" @@ -663,6 +677,14 @@ def build_error_msg(kind, name, msg): return result else: + if label_selectors: + filter_selector = LabelSelectorFilter(label_selectors) + if not filter_selector.isMatching(definition): + result['changed'] = False + del result['result'] + result['warning'] = "resource 'kind={kind},name={name},namespace={namespace}' filtered by label_selectors.".format( + kind=definition['kind'], name=origin_name, namespace=namespace) + return result if apply: if self.check_mode: ignored, patch = apply_object(resource, _encode_stringdata(definition)) diff --git a/plugins/module_utils/selector.py b/plugins/module_utils/selector.py new file mode 100644 index 0000000000..ce6050afbe --- /dev/null +++ b/plugins/module_utils/selector.py @@ -0,0 +1,59 @@ +# 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 + + +class Selector(object): + + def __init__(self, data): + self._operator = None + self._data = '' + for sep in ('==', '!=', '='): + pos = data.find(sep) + if pos != -1: + break + if pos == -1: + self._key = data + else: + self._key = data[0:pos] + self._operator = sep + self._data = data[pos + len(sep):] + + def isMatch(self, labels): + if self._key not in labels: + return False + if self._operator in ('=', '=='): + return self._data == labels.get(self._key) + elif self._operator == '!=': + print("'{}' and {} (differs)".format(self._data, labels.get(self._key))) + return self._data != labels.get(self._key) + # operator not defined + return True + + +class LabelSelectorFilter(object): + + def __init__(self, label_selectors): + self.selectors = [Selector(data) for data in label_selectors] + + def isMatching(self, definition): + if "metadata" not in definition or "labels" not in definition['metadata']: + return False + labels = definition['metadata']['labels'] + if not isinstance(labels, dict): + return None + return all([sel.isMatch(labels) for sel in self.selectors]) diff --git a/plugins/modules/k8s.py b/plugins/modules/k8s.py index 5bcb51cff2..d2fb02a56d 100644 --- a/plugins/modules/k8s.py +++ b/plugins/modules/k8s.py @@ -135,6 +135,12 @@ type: bool default: False version_added: 2.0.0 + label_selectors: + description: + - Selector (label query) to filter on. + type: list + elements: str + version_added: 2.2.0 requirements: - "python >= 3.6" @@ -335,6 +341,7 @@ def argspec(): argument_spec['continue_on_error'] = dict(type='bool', default=False) argument_spec['state'] = dict(default='present', choices=['present', 'absent', 'patched']) argument_spec['force'] = dict(type='bool', default=False) + argument_spec['label_selectors'] = dict(type='list', elements='str') return argument_spec diff --git a/tests/unit/module_utils/test_selector.py b/tests/unit/module_utils/test_selector.py new file mode 100644 index 0000000000..e48564459b --- /dev/null +++ b/tests/unit/module_utils/test_selector.py @@ -0,0 +1,131 @@ +# 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.selector import LabelSelectorFilter + +prod_definition = { + 'apiVersion': 'v1', + 'kind': 'Pod', + 'metadata': { + 'name': 'test', + 'labels': { + 'environment': 'production', + 'app': 'nginx', + } + }, + 'spec': { + 'containers': [ + {'name': 'nginx', 'image': 'nginx:1.14.2', 'command': ['/bin/sh', '-c', 'sleep 10']} + ] + } +} + +no_label_definition = { + 'apiVersion': 'v1', + 'kind': 'Pod', + 'metadata': { + 'name': 'test', + 'labels': {} + }, + 'spec': { + 'containers': [ + {'name': 'nginx', 'image': 'nginx:1.14.2', 'command': ['/bin/sh', '-c', 'sleep 10']} + ] + } +} + +test_definition = { + 'apiVersion': 'v1', + 'kind': 'Pod', + 'metadata': { + 'name': 'test', + 'labels': { + 'environment': 'test', + 'app': 'nginx', + } + }, + 'spec': { + 'containers': [ + {'name': 'nginx', 'image': 'nginx:1.15.2', 'command': ['/bin/sh', '-c', 'sleep 10']} + ] + } +} + + +def test_label_selector_without_operator(): + label_selector = ['environment', 'app'] + assert LabelSelectorFilter(label_selector).isMatching(prod_definition) + assert not LabelSelectorFilter(label_selector).isMatching(no_label_definition) + assert LabelSelectorFilter(label_selector).isMatching(test_definition) + + +def test_label_selector_equal_operator(): + label_selector = ['environment==test'] + assert not LabelSelectorFilter(label_selector).isMatching(prod_definition) + assert not LabelSelectorFilter(label_selector).isMatching(no_label_definition) + assert LabelSelectorFilter(label_selector).isMatching(test_definition) + label_selector = ['environment=production'] + assert LabelSelectorFilter(label_selector).isMatching(prod_definition) + assert not LabelSelectorFilter(label_selector).isMatching(no_label_definition) + assert not LabelSelectorFilter(label_selector).isMatching(test_definition) + label_selector = ['environment=production', 'app==mongodb'] + assert not LabelSelectorFilter(label_selector).isMatching(prod_definition) + assert not LabelSelectorFilter(label_selector).isMatching(no_label_definition) + assert not LabelSelectorFilter(label_selector).isMatching(test_definition) + label_selector = ['environment=production', 'app==nginx'] + assert LabelSelectorFilter(label_selector).isMatching(prod_definition) + assert not LabelSelectorFilter(label_selector).isMatching(no_label_definition) + assert not LabelSelectorFilter(label_selector).isMatching(test_definition) + label_selector = ['environment', 'app==nginx'] + assert LabelSelectorFilter(label_selector).isMatching(prod_definition) + assert not LabelSelectorFilter(label_selector).isMatching(no_label_definition) + assert LabelSelectorFilter(label_selector).isMatching(test_definition) + + +def test_label_selector_notequal_operator(): + label_selector = ['environment!=test'] + assert LabelSelectorFilter(label_selector).isMatching(prod_definition) + assert not LabelSelectorFilter(label_selector).isMatching(no_label_definition) + assert not LabelSelectorFilter(label_selector).isMatching(test_definition) + label_selector = ['environment!=production'] + assert not LabelSelectorFilter(label_selector).isMatching(prod_definition) + assert not LabelSelectorFilter(label_selector).isMatching(no_label_definition) + assert LabelSelectorFilter(label_selector).isMatching(test_definition) + label_selector = ['environment=production', 'app!=mongodb'] + assert LabelSelectorFilter(label_selector).isMatching(prod_definition) + assert not LabelSelectorFilter(label_selector).isMatching(no_label_definition) + assert not LabelSelectorFilter(label_selector).isMatching(test_definition) + label_selector = ['environment=production', 'app!=nginx'] + assert not LabelSelectorFilter(label_selector).isMatching(prod_definition) + assert not LabelSelectorFilter(label_selector).isMatching(no_label_definition) + assert not LabelSelectorFilter(label_selector).isMatching(test_definition) + label_selector = ['environment', 'app!=nginx'] + assert not LabelSelectorFilter(label_selector).isMatching(prod_definition) + assert not LabelSelectorFilter(label_selector).isMatching(no_label_definition) + assert not LabelSelectorFilter(label_selector).isMatching(test_definition) + + +def test_label_selector_conflicting_definition(): + label_selector = ['environment==test', 'environment!=test'] + assert not LabelSelectorFilter(label_selector).isMatching(prod_definition) + assert not LabelSelectorFilter(label_selector).isMatching(no_label_definition) + assert not LabelSelectorFilter(label_selector).isMatching(test_definition) + label_selector = ['environment==test', 'environment==production'] + assert not LabelSelectorFilter(label_selector).isMatching(prod_definition) + assert not LabelSelectorFilter(label_selector).isMatching(no_label_definition) + assert not LabelSelectorFilter(label_selector).isMatching(test_definition) From 38b5848f2361b5521ef7d5a70b1568bbd047320f Mon Sep 17 00:00:00 2001 From: abikouo <79859644+abikouo@users.noreply.github.com> Date: Tue, 13 Jul 2021 16:30:04 +0200 Subject: [PATCH 5/9] Delete 157-k8s-add-support-label_selectors.yml --- changelogs/fragments/157-k8s-add-support-label_selectors.yml | 3 --- 1 file changed, 3 deletions(-) delete mode 100644 changelogs/fragments/157-k8s-add-support-label_selectors.yml diff --git a/changelogs/fragments/157-k8s-add-support-label_selectors.yml b/changelogs/fragments/157-k8s-add-support-label_selectors.yml deleted file mode 100644 index a4bd473d9f..0000000000 --- a/changelogs/fragments/157-k8s-add-support-label_selectors.yml +++ /dev/null @@ -1,3 +0,0 @@ ---- -minor_changes: -- k8s - add support for label_selectors options (https://github.com/ansible-collections/kubernetes.core/issues/43). From cac1749e50f33da50ce7305183d5622abf5b24b8 Mon Sep 17 00:00:00 2001 From: abikouo <79859644+abikouo@users.noreply.github.com> Date: Tue, 13 Jul 2021 16:34:27 +0200 Subject: [PATCH 6/9] Update common.py --- plugins/module_utils/common.py | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/plugins/module_utils/common.py b/plugins/module_utils/common.py index feb8a0b994..7d2acb4fa9 100644 --- a/plugins/module_utils/common.py +++ b/plugins/module_utils/common.py @@ -640,7 +640,6 @@ def _empty_resource_list(): if not existing or _empty_resource_list(): # The object already does not exist - result['result']['warning'] = 'No resources found' return result else: # Delete the object @@ -681,8 +680,7 @@ def _empty_resource_list(): filter_selector = LabelSelectorFilter(label_selectors) if not filter_selector.isMatching(definition): result['changed'] = False - del result['result'] - result['warning'] = "resource 'kind={kind},name={name},namespace={namespace}' filtered by label_selectors.".format( + result['msg'] = "resource 'kind={kind},name={name},namespace={namespace}' filtered by label_selectors.".format( kind=definition['kind'], name=origin_name, namespace=namespace) return result if apply: From 8f093795443c53854a822e2afc75209bb53d4136 Mon Sep 17 00:00:00 2001 From: abikouo Date: Tue, 13 Jul 2021 16:58:37 +0200 Subject: [PATCH 7/9] sanity --- plugins/module_utils/common.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/plugins/module_utils/common.py b/plugins/module_utils/common.py index c9482a43f4..6887c6ca73 100644 --- a/plugins/module_utils/common.py +++ b/plugins/module_utils/common.py @@ -681,7 +681,7 @@ def _empty_resource_list(): if not filter_selector.isMatching(definition): result['changed'] = False result['msg'] = "resource 'kind={kind},name={name},namespace={namespace}' filtered by label_selectors.".format( - kind=definition['kind'], name=origin_name, namespace=namespace) + kind=definition['kind'], name=origin_name, namespace=namespace) return result if apply: if self.check_mode: From 6e0d5ce9020a3ceda23103741ad669cb80dbea8f Mon Sep 17 00:00:00 2001 From: abikouo Date: Tue, 20 Jul 2021 11:13:38 +0200 Subject: [PATCH 8/9] add support for set-based requirement --- molecule/default/tasks/label_selectors.yml | 450 ++++++++++++++++++++- plugins/module_utils/selector.py | 52 ++- tests/unit/module_utils/test_selector.py | 27 ++ 3 files changed, 504 insertions(+), 25 deletions(-) diff --git a/molecule/default/tasks/label_selectors.yml b/molecule/default/tasks/label_selectors.yml index 62fad3c84c..3d7e55919f 100644 --- a/molecule/default/tasks/label_selectors.yml +++ b/molecule/default/tasks/label_selectors.yml @@ -7,13 +7,15 @@ selector_pod_create: - "pod-selector-apply-00" - "pod-selector-apply-01" + - "pod-selector-apply-02" + - "pod-selector-apply-03" - name: Ensure namespace selector k8s: kind: namespace name: '{{ selector_namespace }}' - # Resource deletion using label selector + # Resource deletion using label selector (equality-based requirement) - name: Create simple pod k8s: namespace: '{{ selector_namespace }}' @@ -21,7 +23,7 @@ apiVersion: v1 kind: Pod metadata: - name: '{{ selector_pod_delete }}' + name: '{{ selector_pod_delete }}-00' labels: ansible.dev/team: "cloud" ansible.release/version: upstream @@ -57,12 +59,88 @@ that: - result.resources == [] + # Resource deletion using label selector (set-based requirement) + - name: Create simple pod + k8s: + namespace: '{{ selector_namespace }}' + definition: + apiVersion: v1 + kind: Pod + metadata: + name: '{{ selector_pod_delete }}-01' + labels: + environment: production + spec: + containers: + - name: c0 + image: alpine:3.14.0 + command: + - /bin/sh + - -c + - while true;do date;sleep 5; done + + - name: Delete all resource using selector + k8s: + state: absent + kind: Pod + namespace: '{{ selector_namespace }}' + label_selectors: + - environment in (test, qa) + wait: yes + wait_timeout: 180 + register: result + + - name: check that no resources were deleted + assert: + that: + - result is not changed + + - name: Ensure resources have not been deleted + k8s_info: + kind: Pod + namespace: '{{ selector_namespace }}' + label_selectors: + - environment in (production) + register: result + + - assert: + that: + - result.resources | list | length > 0 + + - name: Delete all resource using selector + k8s: + state: absent + kind: Pod + namespace: '{{ selector_namespace }}' + label_selectors: + - environment in (production) + wait: yes + wait_timeout: 180 + register: result + + - name: check result is changed + assert: + that: + - result is changed + + - name: Ensure resources have not been deleted + k8s_info: + kind: Pod + namespace: '{{ selector_namespace }}' + label_selectors: + - environment in (production) + register: result + + - assert: + that: + - result.resources | list | length == 0 + # Resource creation using label selector - - name: Create simple pod using label_selectors option (no resource matching label) + - name: Create simple pod using label_selectors option (equality-based requirement) k8s: namespace: '{{ selector_namespace }}' label_selectors: - - ansible.pod/created=true + - container.image=fedora definition: | --- apiVersion: v1 @@ -70,7 +148,7 @@ metadata: name: '{{ selector_pod_create[0] }}' labels: - ansible.pod/created: "false" + container.image: busybox spec: containers: - name: c0 @@ -85,11 +163,43 @@ metadata: name: '{{ selector_pod_create[1] }}' labels: - ansible.pod/created: "false" + container.image: alpine spec: containers: - name: c0 - image: busybox + image: alpine + command: + - /bin/sh + - -c + - while true;do date;sleep 5; done + --- + apiVersion: v1 + kind: Pod + metadata: + name: '{{ selector_pod_create[2] }}' + labels: + container.image: python + release: dev + spec: + containers: + - name: c0 + image: python + command: + - /bin/sh + - -c + - while true;do date;sleep 5; done + --- + apiVersion: v1 + kind: Pod + metadata: + name: '{{ selector_pod_create[3] }}' + labels: + container.image: python + release: dev + spec: + containers: + - name: c0 + image: python command: - /bin/sh - -c @@ -104,7 +214,7 @@ k8s: namespace: '{{ selector_namespace }}' label_selectors: - - ansible.pod/created=true + - container.image==alpine definition: | --- apiVersion: v1 @@ -112,7 +222,7 @@ metadata: name: '{{ selector_pod_create[0] }}' labels: - ansible.pod/created: "false" + container.image: busybox spec: containers: - name: c0 @@ -127,11 +237,43 @@ metadata: name: '{{ selector_pod_create[1] }}' labels: - ansible.pod/created: "true" + container.image: alpine spec: containers: - name: c0 - image: busybox + image: alpine + command: + - /bin/sh + - -c + - while true;do date;sleep 5; done + --- + apiVersion: v1 + kind: Pod + metadata: + name: '{{ selector_pod_create[2] }}' + labels: + container.image: python + environment: test + spec: + containers: + - name: c0 + image: python + command: + - /bin/sh + - -c + - while true;do date;sleep 5; done + --- + apiVersion: v1 + kind: Pod + metadata: + name: '{{ selector_pod_create[3] }}' + labels: + container.image: python + environment: production + spec: + containers: + - name: c0 + image: python command: - /bin/sh - -c @@ -146,16 +288,288 @@ k8s_info: namespace: '{{ selector_namespace }}' kind: Pod + label_selectors: + - container.image register: pod_created - name: Validate that pod with matching label was created assert: that: - - selector_pod_create[0] not in pods_created + - pods_created | length == 1 - selector_pod_create[1] in pods_created vars: pods_created: '{{ pod_created.resources | map(attribute="metadata.name") | list }}' + - name: Create simple pod using label_selectors option (set-based requirement) + k8s: + namespace: '{{ selector_namespace }}' + label_selectors: + - "!environment" + definition: | + --- + apiVersion: v1 + kind: Pod + metadata: + name: '{{ selector_pod_create[0] }}' + labels: + container.image: busybox + spec: + containers: + - name: c0 + image: busybox + command: + - /bin/sh + - -c + - while true;do date;sleep 5; done + --- + apiVersion: v1 + kind: Pod + metadata: + name: '{{ selector_pod_create[1] }}' + labels: + container.image: alpine + spec: + containers: + - name: c0 + image: alpine + command: + - /bin/sh + - -c + - while true;do date;sleep 5; done + --- + apiVersion: v1 + kind: Pod + metadata: + name: '{{ selector_pod_create[2] }}' + labels: + container.image: python + environment: test + spec: + containers: + - name: c0 + image: python + command: + - /bin/sh + - -c + - while true;do date;sleep 5; done + --- + apiVersion: v1 + kind: Pod + metadata: + name: '{{ selector_pod_create[3] }}' + labels: + container.image: python + environment: production + spec: + containers: + - name: c0 + image: python + command: + - /bin/sh + - -c + - while true;do date;sleep 5; done + register: result + + - assert: + that: + - result is changed + + - name: list pod created + k8s_info: + namespace: '{{ selector_namespace }}' + kind: Pod + label_selectors: + - container.image + register: pod_created + + - name: Validate that pod with matching label was created + assert: + that: + - pods_created | length == 2 + - selector_pod_create[0] in pods_created + vars: + pods_created: '{{ pod_created.resources | map(attribute="metadata.name") | list }}' + + - name: Create simple pod using label_selectors option (set-based requirement) + k8s: + namespace: '{{ selector_namespace }}' + label_selectors: + - environment in (test) + definition: | + --- + apiVersion: v1 + kind: Pod + metadata: + name: '{{ selector_pod_create[0] }}' + labels: + container.image: busybox + spec: + containers: + - name: c0 + image: busybox + command: + - /bin/sh + - -c + - while true;do date;sleep 5; done + --- + apiVersion: v1 + kind: Pod + metadata: + name: '{{ selector_pod_create[1] }}' + labels: + container.image: alpine + spec: + containers: + - name: c0 + image: alpine + command: + - /bin/sh + - -c + - while true;do date;sleep 5; done + --- + apiVersion: v1 + kind: Pod + metadata: + name: '{{ selector_pod_create[2] }}' + labels: + container.image: python + environment: test + spec: + containers: + - name: c0 + image: python + command: + - /bin/sh + - -c + - while true;do date;sleep 5; done + --- + apiVersion: v1 + kind: Pod + metadata: + name: '{{ selector_pod_create[3] }}' + labels: + container.image: python + environment: production + spec: + containers: + - name: c0 + image: python + command: + - /bin/sh + - -c + - while true;do date;sleep 5; done + register: result + + - assert: + that: + - result is changed + + - name: list pod created + k8s_info: + namespace: '{{ selector_namespace }}' + kind: Pod + label_selectors: + - container.image + register: pod_created + + - name: Validate that pod with matching label was created + assert: + that: + - pods_created | length == 3 + - selector_pod_create[2] in pods_created + vars: + pods_created: '{{ pod_created.resources | map(attribute="metadata.name") | list }}' + + - name: Create simple pod using label_selectors option (set-based requirement) + k8s: + namespace: '{{ selector_namespace }}' + label_selectors: + - environment notin (test) + definition: | + --- + apiVersion: v1 + kind: Pod + metadata: + name: '{{ selector_pod_create[0] }}' + labels: + container.image: busybox + spec: + containers: + - name: c0 + image: busybox + command: + - /bin/sh + - -c + - while true;do date;sleep 5; done + --- + apiVersion: v1 + kind: Pod + metadata: + name: '{{ selector_pod_create[1] }}' + labels: + container.image: alpine + spec: + containers: + - name: c0 + image: alpine + command: + - /bin/sh + - -c + - while true;do date;sleep 5; done + --- + apiVersion: v1 + kind: Pod + metadata: + name: '{{ selector_pod_create[2] }}' + labels: + container.image: python + environment: test + spec: + containers: + - name: c0 + image: python + command: + - /bin/sh + - -c + - while true;do date;sleep 5; done + --- + apiVersion: v1 + kind: Pod + metadata: + name: '{{ selector_pod_create[3] }}' + labels: + container.image: python + environment: production + spec: + containers: + - name: c0 + image: python + command: + - /bin/sh + - -c + - while true;do date;sleep 5; done + register: result + + - assert: + that: + - result is changed + + - name: list pod created + k8s_info: + namespace: '{{ selector_namespace }}' + kind: Pod + label_selectors: + - container.image + register: pod_created + + - name: Validate that pod with matching label was created + assert: + that: + - pods_created | length == 4 + - selector_pod_create[3] in pods_created + vars: + pods_created: '{{ pod_created.resources | map(attribute="metadata.name") | list }}' + # Resource update using apply - name: Create simple pod using apply k8s: @@ -167,7 +581,7 @@ metadata: name: '{{ selector_pod_apply }}' labels: - ansible.dev/test: "false" + environment: test spec: containers: - name: c0 @@ -187,7 +601,7 @@ metadata: name: '{{ selector_pod_apply }}' labels: - ansible.dev/test: "false" + environment: test spec: containers: - name: c0 @@ -197,14 +611,14 @@ - -c - while true;do date;sleep 5; done label_selectors: - - ansible.dev/test=true + - environment=qa register: result - name: check task output assert: that: - result is not changed - - '"filtered by label_selectors" in result.warning' + - '"filtered by label_selectors" in result.msg' - name: Apply new pod definition using label_selectors k8s: @@ -216,7 +630,7 @@ metadata: name: '{{ selector_pod_apply }}' labels: - ansible.dev/test: "false" + environment: test spec: containers: - name: c0 @@ -226,7 +640,7 @@ - -c - while true;do date;sleep 5; done label_selectors: - - ansible.dev/test=false + - environment!=qa register: result - name: check task output diff --git a/plugins/module_utils/selector.py b/plugins/module_utils/selector.py index 80b8d7a8ae..616a66402a 100644 --- a/plugins/module_utils/selector.py +++ b/plugins/module_utils/selector.py @@ -16,33 +16,71 @@ from __future__ import absolute_import, division, print_function __metaclass__ = type +import re + class Selector(object): + equality_based_operators = ('==', '!=', '=') + def __init__(self, data): self._operator = None self._data = '' - for sep in ('==', '!=', '='): - pos = data.find(sep) + self.define_set_based_requirement(data) + if not self._set_based_requirement: + self.define_equality_based_requirement(data) + + def define_set_based_requirement(self, data): + self._set_based_requirement = False + m = re.match(r'( *)([a-z0-9A-Z][a-z0-9A-Z\._-]*[a-z0-9A-Z])( *)(notin|in)( *)\((.*)\)( *)', data) + if m: + self._set_based_requirement = True + self._key = m.group(2) + self._operator = m.group(4) + self._data = [x.replace(' ', '') for x in m.group(6).split(',') if x != ''] + elif all([x not in data for x in self.equality_based_operators]): + self._set_based_requirement = True + data = data.replace(" ", "") + self._key = data + if data.startswith("!"): + self._key = data[1:] + self._operator = "!" + + def define_equality_based_requirement(self, data): + data_selector = data.replace(" ", "") + for sep in self.equality_based_operators: + pos = data_selector.find(sep) if pos != -1: break if pos == -1: - self._key = data + self._key = data_selector else: - self._key = data[0:pos] + self._key = data_selector[0:pos] self._operator = sep - self._data = data[pos + len(sep):] + self._data = data_selector[pos + len(sep):] - def isMatch(self, labels): + def filter_equality_based_requirement(self, labels): if self._key not in labels: return False if self._operator in ('=', '=='): return self._data == labels.get(self._key) elif self._operator == '!=': return self._data != labels.get(self._key) - # operator not defined return True + def filter_set_based_requirement(self, labels): + if self._operator == "in": + return self._key in labels and labels.get(self._key) in self._data + elif self._operator == "notin": + return self._key not in labels or labels.get(self._key) not in self._data + else: + return self._key not in labels if self._operator == "!" else self._key in labels + + def isMatch(self, labels): + if self._set_based_requirement: + return self.filter_set_based_requirement(labels) + return self.filter_equality_based_requirement(labels) + class LabelSelectorFilter(object): diff --git a/tests/unit/module_utils/test_selector.py b/tests/unit/module_utils/test_selector.py index e48564459b..228c3d3fd5 100644 --- a/tests/unit/module_utils/test_selector.py +++ b/tests/unit/module_utils/test_selector.py @@ -129,3 +129,30 @@ def test_label_selector_conflicting_definition(): assert not LabelSelectorFilter(label_selector).isMatching(prod_definition) assert not LabelSelectorFilter(label_selector).isMatching(no_label_definition) assert not LabelSelectorFilter(label_selector).isMatching(test_definition) + + +def test_set_based_requirement(): + label_selector = ['environment in (production)'] + assert LabelSelectorFilter(label_selector).isMatching(prod_definition) + assert not LabelSelectorFilter(label_selector).isMatching(no_label_definition) + assert not LabelSelectorFilter(label_selector).isMatching(test_definition) + label_selector = ['environment in (production, test)'] + assert LabelSelectorFilter(label_selector).isMatching(prod_definition) + assert not LabelSelectorFilter(label_selector).isMatching(no_label_definition) + assert LabelSelectorFilter(label_selector).isMatching(test_definition) + label_selector = ['environment notin (production)'] + assert not LabelSelectorFilter(label_selector).isMatching(prod_definition) + assert LabelSelectorFilter(label_selector).isMatching(no_label_definition) + assert LabelSelectorFilter(label_selector).isMatching(test_definition) + label_selector = ['environment notin (production, test)'] + assert not LabelSelectorFilter(label_selector).isMatching(prod_definition) + assert LabelSelectorFilter(label_selector).isMatching(no_label_definition) + assert not LabelSelectorFilter(label_selector).isMatching(test_definition) + label_selector = ['environment'] + assert LabelSelectorFilter(label_selector).isMatching(prod_definition) + assert not LabelSelectorFilter(label_selector).isMatching(no_label_definition) + assert LabelSelectorFilter(label_selector).isMatching(test_definition) + label_selector = ['!environment'] + assert not LabelSelectorFilter(label_selector).isMatching(prod_definition) + assert LabelSelectorFilter(label_selector).isMatching(no_label_definition) + assert not LabelSelectorFilter(label_selector).isMatching(test_definition) From 576b61f66fecd0ca12a00438ad86472766cdd406 Mon Sep 17 00:00:00 2001 From: abikouo Date: Tue, 27 Jul 2021 18:23:42 +0200 Subject: [PATCH 9/9] multiples fixes --- molecule/default/converge.yml | 3 ++ plugins/module_utils/selector.py | 65 ++++++++---------------- tests/sanity/ignore-2.10.txt | 6 ++- tests/sanity/ignore-2.11.txt | 6 ++- tests/sanity/ignore-2.12.txt | 4 +- tests/sanity/ignore-2.9.txt | 6 ++- tests/unit/module_utils/test_selector.py | 43 +++++++++++++--- 7 files changed, 77 insertions(+), 56 deletions(-) diff --git a/molecule/default/converge.yml b/molecule/default/converge.yml index bed63d689d..108e9c82f5 100644 --- a/molecule/default/converge.yml +++ b/molecule/default/converge.yml @@ -169,6 +169,9 @@ file: tasks/label_selectors.yml apply: tags: [ label_selectors, k8s ] + tags: + - always + - name: Include diff.yml include_tasks: file: tasks/diff.yml diff --git a/plugins/module_utils/selector.py b/plugins/module_utils/selector.py index 616a66402a..5401f6a3af 100644 --- a/plugins/module_utils/selector.py +++ b/plugins/module_utils/selector.py @@ -12,10 +12,6 @@ # 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 re @@ -25,50 +21,34 @@ class Selector(object): def __init__(self, data): self._operator = None - self._data = '' - self.define_set_based_requirement(data) - if not self._set_based_requirement: - self.define_equality_based_requirement(data) - - def define_set_based_requirement(self, data): - self._set_based_requirement = False - m = re.match(r'( *)([a-z0-9A-Z][a-z0-9A-Z\._-]*[a-z0-9A-Z])( *)(notin|in)( *)\((.*)\)( *)', data) + self._data = None + if not self.parse_set_based_requirement(data): + no_whitespace_data = data.replace(" ", "") + for op in self.equality_based_operators: + idx = no_whitespace_data.find(op) + if idx != -1: + self._operator = "in" if op == '==' or op == '=' else "notin" + self._key = no_whitespace_data[0:idx] + self._data = [no_whitespace_data[idx + len(op):]] + break + + def parse_set_based_requirement(self, data): + m = re.match(r'( *)([a-z0-9A-Z][a-z0-9A-Z\._-]*[a-z0-9A-Z])( +)(notin|in)( +)\((.*)\)( *)', data) if m: self._set_based_requirement = True self._key = m.group(2) self._operator = m.group(4) self._data = [x.replace(' ', '') for x in m.group(6).split(',') if x != ''] + return True elif all([x not in data for x in self.equality_based_operators]): - self._set_based_requirement = True - data = data.replace(" ", "") - self._key = data - if data.startswith("!"): - self._key = data[1:] + self._key = data.rstrip(" ").lstrip(" ") + if self._key.startswith("!"): + self._key = self._key[1:].lstrip(" ") self._operator = "!" + return True + return False - def define_equality_based_requirement(self, data): - data_selector = data.replace(" ", "") - for sep in self.equality_based_operators: - pos = data_selector.find(sep) - if pos != -1: - break - if pos == -1: - self._key = data_selector - else: - self._key = data_selector[0:pos] - self._operator = sep - self._data = data_selector[pos + len(sep):] - - def filter_equality_based_requirement(self, labels): - if self._key not in labels: - return False - if self._operator in ('=', '=='): - return self._data == labels.get(self._key) - elif self._operator == '!=': - return self._data != labels.get(self._key) - return True - - def filter_set_based_requirement(self, labels): + def isMatch(self, labels): if self._operator == "in": return self._key in labels and labels.get(self._key) in self._data elif self._operator == "notin": @@ -76,11 +56,6 @@ def filter_set_based_requirement(self, labels): else: return self._key not in labels if self._operator == "!" else self._key in labels - def isMatch(self, labels): - if self._set_based_requirement: - return self.filter_set_based_requirement(labels) - return self.filter_equality_based_requirement(labels) - class LabelSelectorFilter(object): diff --git a/tests/sanity/ignore-2.10.txt b/tests/sanity/ignore-2.10.txt index d0c179e569..79e4388009 100644 --- a/tests/sanity/ignore-2.10.txt +++ b/tests/sanity/ignore-2.10.txt @@ -240,4 +240,8 @@ plugins/modules/k8s_cp.py import-2.6!skip plugins/modules/k8s_cp.py import-2.7!skip molecule/default/roles/k8scopy/library/kubectl_file_compare.py compile-2.6!skip molecule/default/roles/k8scopy/library/kubectl_file_compare.py compile-2.7!skip -molecule/default/roles/k8scopy/library/kubectl_file_compare.py compile-3.5!skip \ No newline at end of file +molecule/default/roles/k8scopy/library/kubectl_file_compare.py compile-3.5!skip +tests/unit/module_utils/test_selector.py future-import-boilerplate!skip +tests/unit/module_utils/test_selector.py metaclass-boilerplate!skip +plugins/module_utils/selector.py future-import-boilerplate!skip +plugins/module_utils/selector.py metaclass-boilerplate!skip \ No newline at end of file diff --git a/tests/sanity/ignore-2.11.txt b/tests/sanity/ignore-2.11.txt index d0c179e569..79e4388009 100644 --- a/tests/sanity/ignore-2.11.txt +++ b/tests/sanity/ignore-2.11.txt @@ -240,4 +240,8 @@ plugins/modules/k8s_cp.py import-2.6!skip plugins/modules/k8s_cp.py import-2.7!skip molecule/default/roles/k8scopy/library/kubectl_file_compare.py compile-2.6!skip molecule/default/roles/k8scopy/library/kubectl_file_compare.py compile-2.7!skip -molecule/default/roles/k8scopy/library/kubectl_file_compare.py compile-3.5!skip \ No newline at end of file +molecule/default/roles/k8scopy/library/kubectl_file_compare.py compile-3.5!skip +tests/unit/module_utils/test_selector.py future-import-boilerplate!skip +tests/unit/module_utils/test_selector.py metaclass-boilerplate!skip +plugins/module_utils/selector.py future-import-boilerplate!skip +plugins/module_utils/selector.py metaclass-boilerplate!skip \ No newline at end of file diff --git a/tests/sanity/ignore-2.12.txt b/tests/sanity/ignore-2.12.txt index 3ef6897e05..f9aee54e04 100644 --- a/tests/sanity/ignore-2.12.txt +++ b/tests/sanity/ignore-2.12.txt @@ -238,4 +238,6 @@ tests/sanity/refresh_ignore_files shebang!skip plugins/modules/k8s_cp.py compile-2.6!skip plugins/modules/k8s_cp.py compile-2.7!skip plugins/modules/k8s_cp.py import-2.6!skip -plugins/modules/k8s_cp.py import-2.7!skip \ No newline at end of file +plugins/modules/k8s_cp.py import-2.7!skip +plugins/module_utils/selector.py future-import-boilerplate!skip +plugins/module_utils/selector.py metaclass-boilerplate!skip \ No newline at end of file diff --git a/tests/sanity/ignore-2.9.txt b/tests/sanity/ignore-2.9.txt index 5057968943..0b23ebe1e4 100644 --- a/tests/sanity/ignore-2.9.txt +++ b/tests/sanity/ignore-2.9.txt @@ -234,4 +234,8 @@ plugins/modules/k8s_cp.py import-2.6!skip plugins/modules/k8s_cp.py import-2.7!skip molecule/default/roles/k8scopy/library/kubectl_file_compare.py compile-2.6!skip molecule/default/roles/k8scopy/library/kubectl_file_compare.py compile-2.7!skip -molecule/default/roles/k8scopy/library/kubectl_file_compare.py compile-3.5!skip \ No newline at end of file +molecule/default/roles/k8scopy/library/kubectl_file_compare.py compile-3.5!skip +tests/unit/module_utils/test_selector.py future-import-boilerplate!skip +tests/unit/module_utils/test_selector.py metaclass-boilerplate!skip +plugins/module_utils/selector.py future-import-boilerplate!skip +plugins/module_utils/selector.py metaclass-boilerplate!skip \ No newline at end of file diff --git a/tests/unit/module_utils/test_selector.py b/tests/unit/module_utils/test_selector.py index 228c3d3fd5..9f14b768e3 100644 --- a/tests/unit/module_utils/test_selector.py +++ b/tests/unit/module_utils/test_selector.py @@ -12,11 +12,7 @@ # 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.selector import LabelSelectorFilter +from ansible_collections.kubernetes.core.plugins.module_utils.selector import LabelSelectorFilter, Selector prod_definition = { 'apiVersion': 'v1', @@ -67,6 +63,39 @@ } +def test_selector_parser(): + f_selector = "environment==true" + sel = Selector(f_selector) + assert sel._operator == "in" and sel._data == ["true"] and sel._key == "environment" + f_selector = "environment=true" + sel = Selector(f_selector) + assert sel._operator == "in" and sel._data == ["true"] and sel._key == "environment" + f_selector = " environment == true " + sel = Selector(f_selector) + assert sel._operator == "in" and sel._data == ["true"] and sel._key == "environment" + f_selector = "environment!=false" + sel = Selector(f_selector) + assert sel._operator == "notin" and sel._data == ["false"] and sel._key == "environment" + f_selector = "environment notin (true, false)" + sel = Selector(f_selector) + assert sel._operator == "notin" and "true" in sel._data and "false" in sel._data and sel._key == "environment" + f_selector = "environment in (true, false)" + sel = Selector(f_selector) + assert sel._operator == "in" and "true" in sel._data and "false" in sel._data and sel._key == "environment" + f_selector = "environmentin(true, false)" + sel = Selector(f_selector) + assert not sel._operator and not sel._data and sel._key == f_selector + f_selector = "environment notin (true, false" + sel = Selector(f_selector) + assert not sel._operator and not sel._data and sel._key == f_selector + f_selector = "!environment" + sel = Selector(f_selector) + assert sel._operator == "!" and not sel._data and sel._key == "environment" + f_selector = "! environment " + sel = Selector(f_selector) + assert sel._operator == "!" and not sel._data and sel._key == "environment" + + def test_label_selector_without_operator(): label_selector = ['environment', 'app'] assert LabelSelectorFilter(label_selector).isMatching(prod_definition) @@ -100,11 +129,11 @@ def test_label_selector_equal_operator(): def test_label_selector_notequal_operator(): label_selector = ['environment!=test'] assert LabelSelectorFilter(label_selector).isMatching(prod_definition) - assert not LabelSelectorFilter(label_selector).isMatching(no_label_definition) + assert LabelSelectorFilter(label_selector).isMatching(no_label_definition) assert not LabelSelectorFilter(label_selector).isMatching(test_definition) label_selector = ['environment!=production'] assert not LabelSelectorFilter(label_selector).isMatching(prod_definition) - assert not LabelSelectorFilter(label_selector).isMatching(no_label_definition) + assert LabelSelectorFilter(label_selector).isMatching(no_label_definition) assert LabelSelectorFilter(label_selector).isMatching(test_definition) label_selector = ['environment=production', 'app!=mongodb'] assert LabelSelectorFilter(label_selector).isMatching(prod_definition)