diff --git a/changelogs/fragments/498-k8s-honor-aliases.yaml b/changelogs/fragments/498-k8s-honor-aliases.yaml new file mode 100644 index 0000000000..2d1148640c --- /dev/null +++ b/changelogs/fragments/498-k8s-honor-aliases.yaml @@ -0,0 +1,3 @@ +bugfixes: +- common - handle ``aliases`` passed from inventory and lookup plugins. +- module_utils/k8s/client.py - fix issue when trying to authenticate with host, client_cert and client_key parameters only. diff --git a/plugins/inventory/k8s.py b/plugins/inventory/k8s.py index fb3bf5defc..099730f1b0 100644 --- a/plugins/inventory/k8s.py +++ b/plugins/inventory/k8s.py @@ -118,9 +118,10 @@ from ansible.errors import AnsibleError from ansible_collections.kubernetes.core.plugins.module_utils.common import ( - K8sAnsibleMixin, HAS_K8S_MODULE_HELPER, k8s_import_exception, +) +from ansible_collections.kubernetes.core.plugins.module_utils.k8s.client import ( get_api_client, ) from ansible.plugins.inventory import BaseInventoryPlugin, Constructable, Cacheable @@ -146,7 +147,7 @@ class K8sInventoryException(Exception): pass -class InventoryModule(BaseInventoryPlugin, Constructable, Cacheable, K8sAnsibleMixin): +class InventoryModule(BaseInventoryPlugin, Constructable, Cacheable): NAME = "kubernetes.core.k8s" connection_plugin = "kubernetes.core.kubectl" diff --git a/plugins/lookup/k8s.py b/plugins/lookup/k8s.py index d3a74c4f56..bd69a99238 100644 --- a/plugins/lookup/k8s.py +++ b/plugins/lookup/k8s.py @@ -180,10 +180,12 @@ from ansible.module_utils.common._collections_compat import KeysView from ansible.module_utils.common.validation import check_type_bool -from ansible_collections.kubernetes.core.plugins.module_utils.common import ( - K8sAnsibleMixin, +from ansible_collections.kubernetes.core.plugins.module_utils.k8s.client import ( get_api_client, ) +from ansible_collections.kubernetes.core.plugins.module_utils.k8s.resource import ( + create_definitions, +) try: enable_turbo_mode = check_type_bool(os.environ.get("ENABLE_TURBO_MODE")) @@ -210,7 +212,7 @@ k8s_import_exception = e -class KubernetesLookup(K8sAnsibleMixin): +class KubernetesLookup(object): def __init__(self): if not HAS_K8S_MODULE_HELPER: @@ -240,7 +242,7 @@ def run(self, terms, variables=None, **kwargs): cluster_info = kwargs.get("cluster_info") if cluster_info == "version": - return [self.client.version] + return [self.client.client.version] if cluster_info == "api_groups": if isinstance(self.client.resources.api_groups, KeysView): return [list(self.client.resources.api_groups)] @@ -257,7 +259,12 @@ def run(self, terms, variables=None, **kwargs): resource_definition = kwargs.get("resource_definition") src = kwargs.get("src") if src: - resource_definition = self.load_resource_definitions(src)[0] + definitions = create_definitions(params=dict(src=src)) + if definitions: + self.kind = definitions[0].kind + self.name = definitions[0].name + self.namespace = definitions[0].namespace + self.api_version = definitions[0].api_version or "v1" if resource_definition: self.kind = resource_definition.get("kind", self.kind) self.api_version = resource_definition.get("apiVersion", self.api_version) @@ -272,14 +279,15 @@ def run(self, terms, variables=None, **kwargs): "using the 'resource_definition' parameter." ) - resource = self.find_resource(self.kind, self.api_version, fail=True) + resource = self.client.resource(self.kind, self.api_version) try: - k8s_obj = resource.get( + params = dict( name=self.name, namespace=self.namespace, label_selector=self.label_selector, field_selector=self.field_selector, ) + k8s_obj = self.client.get(resource, **params) except NotFoundError: return [] diff --git a/plugins/module_utils/k8s/client.py b/plugins/module_utils/k8s/client.py index c9986d3cda..70442bec4c 100644 --- a/plugins/module_utils/k8s/client.py +++ b/plugins/module_utils/k8s/client.py @@ -81,6 +81,9 @@ def _create_auth_spec(module=None, **kwargs) -> Dict: auth[true_name] = module.params.get(arg_name) elif arg_name in kwargs and kwargs.get(arg_name) is not None: auth[true_name] = kwargs.get(arg_name) + elif true_name in kwargs and kwargs.get(true_name) is not None: + # Aliases in kwargs + auth[true_name] = kwargs.get(true_name) elif arg_name == "proxy_headers": # specific case for 'proxy_headers' which is a dictionary proxy_headers = {} @@ -131,7 +134,11 @@ def auth_set(*names: list) -> bool: # Removing trailing slashes if any from hostname auth["host"] = auth.get("host").rstrip("/") - if auth_set("username", "password", "host") or auth_set("api_key", "host"): + if ( + auth_set("username", "password", "host") + or auth_set("api_key", "host") + or auth_set("cert_file", "key_file", "host") + ): # We have enough in the parameters to authenticate, no need to load incluster or kubeconfig pass elif auth_set("kubeconfig") or auth_set("context"): @@ -346,10 +353,14 @@ def get_api_client(module=None, **kwargs: Optional[Any]) -> K8SClient: msg = "Could not create API client: {0}".format(e) raise CoreException(msg) from e + dry_run = False + if module: + dry_run = module.params.get("dry_run", False) + k8s_client = K8SClient( configuration=configuration, client=client, - dry_run=module.params.get("dry_run", False), + dry_run=dry_run, ) return k8s_client diff --git a/tests/integration/targets/inventory_k8s/playbooks/create_resources.yml b/tests/integration/targets/inventory_k8s/playbooks/create_resources.yml new file mode 100644 index 0000000000..7e16f94271 --- /dev/null +++ b/tests/integration/targets/inventory_k8s/playbooks/create_resources.yml @@ -0,0 +1,46 @@ +--- +- name: Create inventory files + hosts: localhost + gather_facts: false + + collections: + - kubernetes.core + + roles: + - role: setup_kubeconfig + kubeconfig_operation: 'save' + + tasks: + - name: Create inventory files + copy: + content: "{{ item.content }}" + dest: "{{ item.path }}" + vars: + hostname: "{{ lookup('file', user_credentials_dir + '/host_data.txt') }}" + test_cert_file: "{{ user_credentials_dir | realpath + '/cert_file_data.txt' }}" + test_key_file: "{{ user_credentials_dir | realpath + '/key_file_data.txt' }}" + test_ca_cert: "{{ user_credentials_dir | realpath + '/ssl_ca_cert_data.txt' }}" + with_items: + - path: "test_inventory_aliases_with_ssl_k8s.yml" + content: | + --- + plugin: kubernetes.core.k8s + connections: + - namespaces: + - inventory + host: "{{ hostname }}" + cert_file: "{{ test_cert_file }}" + key_file: "{{ test_key_file }}" + verify_ssl: true + ssl_ca_cert: "{{ test_ca_cert }}" + - path: "test_inventory_aliases_no_ssl_k8s.yml" + content: | + --- + plugin: kubernetes.core.k8s + connections: + - namespaces: + - inventory + host: "{{ hostname }}" + cert_file: "{{ test_cert_file }}" + key_file: "{{ test_key_file }}" + verify_ssl: false diff --git a/tests/integration/targets/inventory_k8s/playbooks/delete_resources.yml b/tests/integration/targets/inventory_k8s/playbooks/delete_resources.yml new file mode 100644 index 0000000000..ec09ec5cf2 --- /dev/null +++ b/tests/integration/targets/inventory_k8s/playbooks/delete_resources.yml @@ -0,0 +1,30 @@ +--- +- name: Delete inventory namespace + hosts: localhost + connection: local + gather_facts: true + + roles: + - role: setup_kubeconfig + kubeconfig_operation: 'revert' + + tasks: + - name: Delete temporary files + file: + state: absent + path: "{{ user_credentials_dir ~ '/' ~ item }}" + ignore_errors: true + with_items: + - test_inventory_aliases_with_ssl_k8s.yml + - test_inventory_aliases_no_ssl_k8s.yml + - ssl_ca_cert_data.txt + - key_file_data.txt + - cert_file_data.txt + - host_data.txt + + - name: Remove inventory namespace + k8s: + api_version: v1 + kind: Namespace + name: inventory + state: absent diff --git a/tests/integration/targets/inventory_k8s/playbooks/play.yml b/tests/integration/targets/inventory_k8s/playbooks/play.yml index c62ffaebeb..07baf1a3ab 100644 --- a/tests/integration/targets/inventory_k8s/playbooks/play.yml +++ b/tests/integration/targets/inventory_k8s/playbooks/play.yml @@ -88,15 +88,3 @@ - name: Assert the file content matches expectations assert: that: (slurped_file.content|b64decode) == file_content - -- name: Delete inventory namespace - hosts: localhost - connection: local - gather_facts: no - tasks: - - name: Remove inventory namespace - k8s: - api_version: v1 - kind: Namespace - name: inventory - state: absent diff --git a/tests/integration/targets/inventory_k8s/runme.sh b/tests/integration/targets/inventory_k8s/runme.sh index 1ec40e26de..4548f4bb97 100755 --- a/tests/integration/targets/inventory_k8s/runme.sh +++ b/tests/integration/targets/inventory_k8s/runme.sh @@ -2,7 +2,28 @@ set -eux +export ANSIBLE_ROLES_PATH="../" +USER_CREDENTIALS_DIR=$(pwd) + +ansible-playbook playbooks/delete_resources.yml -e "user_credentials_dir=${USER_CREDENTIALS_DIR}" "$@" + +{ export ANSIBLE_INVENTORY_ENABLED=kubernetes.core.k8s,yaml export ANSIBLE_PYTHON_INTERPRETER=auto_silent -ansible-playbook playbooks/play.yml -i playbooks/test.inventory_k8s.yml "$@" +ansible-playbook playbooks/play.yml -i playbooks/test.inventory_k8s.yml "$@" && + +ansible-playbook playbooks/create_resources.yml -e "user_credentials_dir=${USER_CREDENTIALS_DIR}" "$@" && + +ansible-inventory -i playbooks/test_inventory_aliases_with_ssl_k8s.yml --list "$@" && + +ansible-inventory -i playbooks/test_inventory_aliases_no_ssl_k8s.yml --list "$@" && + +unset ANSIBLE_INVENTORY_ENABLED && + +ansible-playbook playbooks/delete_resources.yml -e "user_credentials_dir=${USER_CREDENTIALS_DIR}" "$@" + +} || { + ansible-playbook playbooks/delete_resources.yml -e "user_credentials_dir=${USER_CREDENTIALS_DIR}" "$@" + exit 1 +} \ No newline at end of file diff --git a/tests/integration/targets/lookup_k8s/defaults/main.yml b/tests/integration/targets/lookup_k8s/defaults/main.yml index 13a3b3c4ff..96128d9bfe 100644 --- a/tests/integration/targets/lookup_k8s/defaults/main.yml +++ b/tests/integration/targets/lookup_k8s/defaults/main.yml @@ -2,3 +2,6 @@ test_namespace: - app-development-one - app-development-two + - app-development-three +configmap_data: "This is a simple config map data." +configmap_name: "test-configmap" diff --git a/tests/integration/targets/lookup_k8s/tasks/main.yml b/tests/integration/targets/lookup_k8s/tasks/main.yml index 4ef7ce43d1..63723b1d51 100644 --- a/tests/integration/targets/lookup_k8s/tasks/main.yml +++ b/tests/integration/targets/lookup_k8s/tasks/main.yml @@ -5,6 +5,8 @@ pre_test2: "{{ lookup('kubernetes.core.k8s', kind='Namespace', resource_name=test_namespace[0]) }}" pre_test3: "{{ query('kubernetes.core.k8s', kind='Namespace', label_selector='namespace_label=app_development') }}" pre_test4: "{{ query('kubernetes.core.k8s', kind='Namespace', resource_name=test_namespace[0]) }}" + cluster_version: "{{ query('kubernetes.core.k8s', cluster_info='version') }}" + cluster_api_groups: "{{ query('kubernetes.core.k8s', cluster_info='api_groups') }}" # https://github.com/ansible-collections/kubernetes.core/issues/147 - name: Create a namespace with label @@ -101,6 +103,130 @@ - test8 is mapping - test9 is mapping + # test using resource_definition + - k8s: + name: "{{ test_namespace[2] }}" + kind: Namespace + + - set_fact: + configmap_def: + apiVersion: v1 + kind: ConfigMap + metadata: + name: "{{ configmap_name }}" + namespace: "{{ test_namespace[2] }}" + data: + value: "{{ configmap_data }}" + + - name: Create simple configmap + k8s: + definition: "{{ configmap_def }}" + + - name: Retrieve configmap using resource_definition parameter + set_fact: + result_configmap: "{{ lookup('kubernetes.core.k8s', resource_definition=configmap_def) }}" + + - name: Validate configmap result + assert: + that: + - result_configmap.apiVersion == 'v1' + - result_configmap.metadata.name == "{{ configmap_name }}" + - result_configmap.metadata.namespace == "{{ test_namespace[2] }}" + - result_configmap.data.value == "{{ configmap_data }}" + + # test lookup plugin using src parameter + - block: + - name: Create temporary file to store content + tempfile: + suffix: ".yaml" + register: tmpfile + + - name: Copy content into file + copy: + content: | + kind: ConfigMap + apiVersion: v1 + metadata: + name: "{{ configmap_name }}" + namespace: "{{ test_namespace[2] }}" + dest: "{{ tmpfile.path }}" + + - name: Retrieve configmap using src parameter + set_fact: + src_configmap: "{{ lookup('kubernetes.core.k8s', src=tmpfile.path) }}" + + - name: Validate configmap result + assert: + that: + - src_configmap.apiVersion == 'v1' + - src_configmap.metadata.name == "{{ configmap_name }}" + - src_configmap.metadata.namespace == "{{ test_namespace[2] }}" + - src_configmap.data.value == "{{ configmap_data }}" + + always: + - name: Delete temporary file created + file: + state: absent + path: "{{ tmpfile.path }}" + ignore_errors: true + + # test using aliases for user authentication + - block: + - name: Create temporary directory to save user credentials + tempfile: + state: directory + suffix: ".config" + register: tmpdir + + - include_role: + name: setup_kubeconfig + vars: + user_credentials_dir: "{{ tmpdir.path }}" + kubeconfig_operation: "save" + + - set_fact: + cluster_host: "{{ lookup('file', tmpdir.path + '/host_data.txt') }}" + user_cert_file: "{{ tmpdir.path }}/cert_file_data.txt" + user_key_file: "{{ tmpdir.path }}/key_file_data.txt" + ssl_ca_cert: "{{ tmpdir.path }}/ssl_ca_cert_data.txt" + + - name: Retrieve configmap using authentication aliases (validate_certs=false) + set_fact: + configmap_no_ssl: "{{ lookup('kubernetes.core.k8s', host=cluster_host, cert_file=user_cert_file, key_file=user_key_file, verify_ssl=false, resource_definition=configmap_def) }}" + + - name: Validate configmap result + assert: + that: + - configmap_no_ssl.apiVersion == 'v1' + - configmap_no_ssl.metadata.name == "{{ configmap_name }}" + - configmap_no_ssl.metadata.namespace == "{{ test_namespace[2] }}" + - configmap_no_ssl.data.value == "{{ configmap_data }}" + + - name: Retrieve configmap using authentication aliases (validate_certs=true) + set_fact: + configmap_with_ssl: "{{ lookup('kubernetes.core.k8s', host=cluster_host, cert_file=user_cert_file, key_file=user_key_file, ssl_ca_cert=ssl_ca_cert, verify_ssl=true, resource_definition=configmap_def) }}" + + - name: Validate configmap result + assert: + that: + - configmap_with_ssl.apiVersion == 'v1' + - configmap_with_ssl.metadata.name == "{{ configmap_name }}" + - configmap_with_ssl.metadata.namespace == "{{ test_namespace[2] }}" + - configmap_with_ssl.data.value == "{{ configmap_data }}" + + always: + - name: Delete temporary directory + file: + state: absent + path: "{{ tmpdir.path }}" + ignore_errors: true + + - include_role: + name: setup_kubeconfig + ignore_errors: true + vars: + kubeconfig_operation: revert + always: - name: Ensure that namespace is removed k8s: @@ -110,4 +236,5 @@ with_items: - one - two + - three ignore_errors: true diff --git a/tests/integration/targets/setup_kubeconfig/aliases b/tests/integration/targets/setup_kubeconfig/aliases new file mode 100644 index 0000000000..7a68b11da8 --- /dev/null +++ b/tests/integration/targets/setup_kubeconfig/aliases @@ -0,0 +1 @@ +disabled diff --git a/tests/integration/targets/setup_kubeconfig/defaults/main.yml b/tests/integration/targets/setup_kubeconfig/defaults/main.yml new file mode 100644 index 0000000000..35a2f093bf --- /dev/null +++ b/tests/integration/targets/setup_kubeconfig/defaults/main.yml @@ -0,0 +1,6 @@ +--- +# When set to 'revert', the role will copy saved kubeconfig to the default location +# When set to 'save', the role will copy default kubeconfig to the custom location +kubeconfig_operation: "revert" +kubeconfig_default_path: "~/.kube/config" +kubeconfig_custom_path: "~/.kube/customconfig" diff --git a/tests/integration/targets/setup_kubeconfig/library/test_inventory_read_credentials.py b/tests/integration/targets/setup_kubeconfig/library/test_inventory_read_credentials.py new file mode 100644 index 0000000000..750d8de5b9 --- /dev/null +++ b/tests/integration/targets/setup_kubeconfig/library/test_inventory_read_credentials.py @@ -0,0 +1,140 @@ +#!/usr/bin/python +# -*- coding: utf-8 -*- +# Copyright: (c) 2022, Aubin Bikouo <@abikouo> +# GNU General Public License v3.0+ (see COPYING or https://www.gnu.org/licenses/gpl-3.0.txt) + +from __future__ import absolute_import, division, print_function + +__metaclass__ = type + + +DOCUMENTATION = r""" + +module: test_inventory_read_credentials + +short_description: Generate cert_file, key_file, host and server certificate + +author: + - Aubin Bikouo (@abikouo) + +description: + - This module is used for integration testing only for this collection + - The module load a kube_config file and generate parameters used to authenticate the client. + +options: + kube_config: + description: + - Path to a valid kube config file to test. + type: path + required: yes + dest_dir: + description: + - Path to a directory where file will be generated. + type: path + required: yes +""" + +EXAMPLES = r""" +- name: Generate authentication parameters for current context + test_inventory_read_credentials: + kube_config: ~/.kube/config + dest_dir: /tmp +""" + + +RETURN = """ +auth: + description: + - User information used to authenticate to the cluster. + returned: always + type: complex + contains: + cert_file: + description: + - Path to the generated user certificate file. + type: str + key_file: + description: + - Path to the generated user key file. + type: str + ssl_ca_cert: + description: + - Path to the generated server certificate file. + type: str + host: + description: + - Path to the file containing cluster host. + type: str +""" + +import os +import shutil + +from ansible.module_utils.basic import AnsibleModule, missing_required_lib + +try: + from kubernetes import client, config + from kubernetes.dynamic import DynamicClient, LazyDiscoverer + + HAS_KUBERNETES_MODULE = True + +except ImportError: + HAS_KUBERNETES_MODULE = False + + +class K8SInventoryTestModule(AnsibleModule): + def __init__(self): + + argument_spec = dict( + kube_config=dict(required=True, type="path"), + dest_dir=dict(required=True, type="path"), + ) + + super(K8SInventoryTestModule, self).__init__(argument_spec=argument_spec) + + if not HAS_KUBERNETES_MODULE: + self.fail_json(msg=missing_required_lib("kubernetes")) + + self.execute_module() + + def execute_module(self): + + dest_dir = os.path.abspath(self.params.get("dest_dir")) + kubeconfig_path = self.params.get("kube_config") + if not os.path.isdir(dest_dir): + self.fail_json( + msg="The following {0} does not exist or is not a directory.".format( + dest_dir + ) + ) + if not os.path.isfile(kubeconfig_path): + self.fail_json( + msg="The following {0} does not exist or is not a valid file.".format( + kubeconfig_path + ) + ) + + client_config = type.__call__(client.Configuration) + config.load_kube_config( + config_file=kubeconfig_path, client_configuration=client_config + ) + DynamicClient(client.ApiClient(client_config), discoverer=LazyDiscoverer) + + result = dict(host=os.path.join(dest_dir, "host_data.txt")) + # create file containing host information + with open(result["host"], "w") as fd: + fd.write(client_config.host) + for key in ("cert_file", "key_file", "ssl_ca_cert"): + dest_file = os.path.join(dest_dir, "{0}_data.txt".format(key)) + shutil.copyfile(getattr(client_config, key), dest_file) + result[key] = dest_file + + self.exit_json(auth=result) + + +def main(): + K8SInventoryTestModule() + + +if __name__ == "__main__": + main() diff --git a/tests/integration/targets/setup_kubeconfig/tasks/main.yml b/tests/integration/targets/setup_kubeconfig/tasks/main.yml new file mode 100644 index 0000000000..fcde7a72aa --- /dev/null +++ b/tests/integration/targets/setup_kubeconfig/tasks/main.yml @@ -0,0 +1,46 @@ +--- +- fail: + msg: "kubeconfig_operation must be one of 'revert' or 'save'" + when: kubeconfig_operation not in ["revert", "save"] + +- set_fact: + src_kubeconfig: "{{ (kubeconfig_operation == 'save') | ternary(kubeconfig_default_path, kubeconfig_custom_path) }}" + dest_kubeconfig: "{{ (kubeconfig_operation == 'save') | ternary(kubeconfig_custom_path, kubeconfig_default_path) }}" + +- name: check if source kubeconfig exists + stat: + path: "{{ src_kubeconfig }}" + register: _src + +- name: check if destination kubeconfig exists + stat: + path: "{{ dest_kubeconfig }}" + register: _dest + +- fail: + msg: "Both {{ src_kubeconfig }} and {{ dest_kubeconfig }} do not exist." + when: + - not _src.stat.exists + - not _dest.stat.exists + +- name: Generate user cert_file, key_file, and hostname + block: + - name: Generate user credentials files + test_inventory_read_credentials: + kube_config: "{{ (_src.stat.exists) | ternary(src_kubeconfig, dest_kubeconfig) }}" + dest_dir: "{{ user_credentials_dir }}" + when: user_credentials_dir is defined + +- block: + - name: "Copy {{ src_kubeconfig }} into {{ dest_kubeconfig }}" + copy: + remote_src: true + src: "{{ src_kubeconfig }}" + dest: "{{ dest_kubeconfig }}" + + - name: "Delete {{ src_kubeconfig }}" + file: + state: absent + path: "{{ src_kubeconfig }}" + + when: _src.stat.exists diff --git a/tests/unit/module_utils/test_client.py b/tests/unit/module_utils/test_client.py index 8265b76893..bba03589e7 100644 --- a/tests/unit/module_utils/test_client.py +++ b/tests/unit/module_utils/test_client.py @@ -160,3 +160,25 @@ def test_load_kube_config_from_dict(): assert expected_configuration.items() <= actual_configuration.__dict__.items() _remove_temp_file() + + +def test_create_auth_spec_with_aliases_in_kwargs(): + auth_options = { + "host": TEST_HOST, + "cert_file": TEST_CLIENT_CERT, + "ssl_ca_cert": TEST_CERTIFICATE_AUTH, + "key_file": TEST_CLIENT_KEY, + "verify_ssl": True, + } + + expected_auth_spec = { + "host": TEST_HOST, + "cert_file": TEST_CLIENT_CERT, + "ssl_ca_cert": TEST_CERTIFICATE_AUTH, + "key_file": TEST_CLIENT_KEY, + "verify_ssl": True, + } + + actual_auth_spec = _create_auth_spec(module=None, **auth_options) + for key, value in expected_auth_spec.items(): + assert value == actual_auth_spec.get(key)