From 53c6c0ee80065de07286213058ee1747939b4add Mon Sep 17 00:00:00 2001 From: Youssef Ali <154611350+YoussefKhalidAli@users.noreply.github.com> Date: Tue, 19 May 2026 17:10:19 +0300 Subject: [PATCH] Kubeconfig module improvement (#1123) * Add kubeconfig module for managing Kubernetes config files * Remove unnecessary requirement & Change version * Move functions to module_utils * Add unit tests * Add kubeconfig module for managing Kubernetes config files * Remove unnecessary requirement & Change version * Move functions to module_utils * Add unit tests * Avoid linter errors * Improve documentation clarity * Redact sensitive kubeconfig information * Imprvoe verbosity * Move import statement for to_native to avoid linters check failure * Fix linting error * Add remove behavior * Add tests for remove behavior * Imporve documentation * Add changelog --------- Co-authored-by: Bianca Henderson --- .../1123-kubeconfig-remove-behavior.yml | 2 + plugins/module_utils/kubeconfig.py | 4 + plugins/modules/kubeconfig.py | 89 ++++++++++++++----- .../targets/kubeconfig/tasks/main.yml | 70 ++++++++++++++- tests/unit/module_utils/test_kubeconfig.py | 33 +++++++ 5 files changed, 175 insertions(+), 23 deletions(-) create mode 100644 changelogs/fragments/1123-kubeconfig-remove-behavior.yml diff --git a/changelogs/fragments/1123-kubeconfig-remove-behavior.yml b/changelogs/fragments/1123-kubeconfig-remove-behavior.yml new file mode 100644 index 00000000..e9d02562 --- /dev/null +++ b/changelogs/fragments/1123-kubeconfig-remove-behavior.yml @@ -0,0 +1,2 @@ +minor_changes: + - kubeconfig - add ``remove`` value to the ``behavior`` option, allowing entries to be deleted from the kubeconfig file by name (https://github.com/ansible-collections/kubernetes.core/pull/1123). diff --git a/plugins/module_utils/kubeconfig.py b/plugins/module_utils/kubeconfig.py index 2abbb242..70321b49 100644 --- a/plugins/module_utils/kubeconfig.py +++ b/plugins/module_utils/kubeconfig.py @@ -53,6 +53,8 @@ def merge_by_name(existing, new): if name in merged: if behavior == "keep": continue + elif behavior == "remove": + del merged[name] elif behavior == "replace": merged[name] = item_copy else: @@ -73,6 +75,8 @@ def merge_by_name(existing, new): result[key] = item_copy[key] merged[name] = result else: + if behavior == "remove": + continue merged[name] = item_copy return list(merged.values()) diff --git a/plugins/modules/kubeconfig.py b/plugins/modules/kubeconfig.py index 6332ec8d..5d3758b3 100644 --- a/plugins/modules/kubeconfig.py +++ b/plugins/modules/kubeconfig.py @@ -29,6 +29,7 @@ notes: - The default is V(merge), which merges nested C(cluster), C(user), and C(context) data so unspecified keys are preserved. - With V(replace), the previous entry for that name is dropped and only the new definition is used. - With V(keep), the existing entry is left unchanged. + - With V(remove), the existing entry is deleted from the kubeconfig entirely. If no entry with that name exists, the operation is silently skipped. - This can be used to move kubeconfig files to a different location with different content. - This module does not validate cluster connectivity or authentication. - The module supports C(check_mode) and will not write files when enabled. @@ -57,7 +58,7 @@ options: - List of cluster definitions to merge into the kubeconfig. - Each cluster is identified by its C(name). - When C(name) matches an existing cluster, the default C(behavior) is V(merge). - - See the C(behavior) suboption for V(replace) and V(keep). + - See the C(behavior) suboption for V(replace), V(keep), and V(remove). type: list elements: dict required: false @@ -74,14 +75,15 @@ options: - C(merge) - Update only the specified fields, preserve others (default). - C(replace) - Replace the entire cluster definition. - C(keep) - Keep existing cluster, skip this entry. + - C(remove) - Remove the cluster entry entirely. Silently skipped if the entry does not exist. type: str - choices: ['merge', 'replace', 'keep'] + choices: ['merge', 'replace', 'keep', 'remove'] default: merge cluster: description: - Cluster configuration details. + - Not required when C(behavior) is V(remove). type: dict - required: true suboptions: server: description: @@ -115,7 +117,7 @@ options: - List of user authentication configurations. - Each user is identified by its C(name). - When C(name) matches an existing user, the default C(behavior) is V(merge). - - See the C(behavior) suboption for V(replace) and V(keep). + - See the C(behavior) suboption for V(replace), V(keep), and V(remove). type: list elements: dict required: false @@ -132,14 +134,15 @@ options: - C(merge) - Update only the specified fields, preserve others (default). - C(replace) - Replace the entire user definition. - C(keep) - Keep existing user, skip this entry. + - C(remove) - Remove the user entry entirely. Silently skipped if the entry does not exist. type: str - choices: ['merge', 'replace', 'keep'] + choices: ['merge', 'replace', 'keep', 'remove'] default: merge user: description: - User authentication configuration. + - Not required when C(behavior) is V(remove). type: dict - required: true suboptions: token: description: @@ -188,7 +191,7 @@ options: - List of context definitions linking users and clusters. - Each context is identified by its C(name). - When C(name) matches an existing context, the default C(behavior) is V(merge). - - See the C(behavior) suboption for V(replace) and V(keep). + - See the C(behavior) suboption for V(replace), V(keep), and V(remove). type: list elements: dict required: false @@ -205,14 +208,15 @@ options: - C(merge) - Update only the specified fields, preserve others (default). - C(replace) - Replace the entire context definition. - C(keep) - Keep existing context, skip this entry. + - C(remove) - Remove the context entry entirely. Silently skipped if the entry does not exist. type: str - choices: ['merge', 'replace', 'keep'] + choices: ['merge', 'replace', 'keep', 'remove'] default: merge context: description: - Context configuration linking cluster and user. + - Not required when C(behavior) is V(remove). type: dict - required: true suboptions: cluster: description: @@ -277,27 +281,72 @@ EXAMPLES = r""" namespace: production current_context: prod-admin -- name: Copy and modify kubeconfig +- name: Add a second cluster to an existing kubeconfig without touching other entries kubernetes.core.kubeconfig: path: /home/user/.kube/config - dest: /home/user/.kube/config-backup clusters: - - name: new-cluster + - name: staging-cluster cluster: - server: https://new.example.com:6443 + server: https://staging.k8s.example.com:6443 + insecure-skip-tls-verify: true + users: + - name: staging-user + user: + client-certificate: /path/to/staging.crt + client-key: /path/to/staging.key + contexts: + - name: staging-admin + context: + cluster: staging-cluster + user: staging-user + namespace: staging -- name: Switch current context +- name: Update only the token for an existing user, preserving all other user fields kubernetes.core.kubeconfig: - path: ~/.kube/config - current_context: prod-context - -- name: Update user credentials - kubernetes.core.kubeconfig: - path: ~/.kube/config + path: /home/user/.kube/config users: - name: admin-user + behavior: merge user: token: "{{ new_admin_token }}" + +- name: Replace a cluster definition entirely. + kubernetes.core.kubeconfig: + path: /home/user/.kube/config + clusters: + - name: production-cluster + behavior: replace + cluster: + server: https://new-prod.k8s.example.com:6443 + certificate-authority-data: LS0tLS1CRUdJTi... + +- name: Remove a decommissioned cluster, user, and context + kubernetes.core.kubeconfig: + path: /home/user/.kube/config + clusters: + - name: old-cluster + behavior: remove + users: + - name: old-user + behavior: remove + contexts: + - name: old-context + behavior: remove + +- name: Switch the active context + kubernetes.core.kubeconfig: + path: /home/user/.kube/config + current_context: staging-admin + +- name: Copy a kubeconfig to a new location with an additional cluster merged in + kubernetes.core.kubeconfig: + path: /home/user/.kube/config + dest: /home/user/.kube/config-ci + clusters: + - name: ci-cluster + cluster: + server: https://ci.k8s.example.com:6443 + insecure-skip-tls-verify: true """ RETURN = r""" diff --git a/tests/integration/targets/kubeconfig/tasks/main.yml b/tests/integration/targets/kubeconfig/tasks/main.yml index 49d6cc44..477d4fe0 100644 --- a/tests/integration/targets/kubeconfig/tasks/main.yml +++ b/tests/integration/targets/kubeconfig/tasks/main.yml @@ -102,7 +102,7 @@ assert: that: - update_result is changed - - update_result.kubeconfig.clusters[0].cluster.server == "https://updated.example.com:6443" + - update_result.kubeconfig.clusters | selectattr('name', 'equalto', test_cluster_name) | map(attribute='cluster') | map(attribute='server') | first == "https://updated.example.com:6443" # Test 5: Check mode - name: Test check mode @@ -115,8 +115,72 @@ check_mode: true register: check_mode_result -- name: Verify check mode didn't write +- name: Verify check mode reports change but does not write assert: that: - check_mode_result is changed - - check_mode_result.kubeconfig.clusters | length == 3 # Includes new cluster in output + - check_mode_result.kubeconfig.clusters | length == 3 + +- name: Verify check mode cluster was not actually written to disk + kubernetes.core.kubeconfig: + path: "{{ test_config_path }}" + register: after_check_mode + +- name: Confirm check-mode-cluster is absent from disk + assert: + that: + - after_check_mode.kubeconfig.clusters | selectattr('name', 'equalto', 'check-mode-cluster') | list | length == 0 + +# Test 6: Remove behavior +- name: Remove cluster-2, user-2, and context-2 + kubernetes.core.kubeconfig: + path: "{{ test_config_path }}" + clusters: + - name: cluster-2 + behavior: remove + users: + - name: user-2 + behavior: remove + contexts: + - name: context-2 + behavior: remove + register: remove_result + +- name: Verify entries were removed + assert: + that: + - remove_result is changed + - remove_result.kubeconfig.clusters | selectattr('name', 'equalto', 'cluster-2') | list | length == 0 + - remove_result.kubeconfig.users | selectattr('name', 'equalto', 'user-2') | list | length == 0 + - remove_result.kubeconfig.contexts | selectattr('name', 'equalto', 'context-2') | list | length == 0 + +# Test 7: Remove behavior is idempotent when entry does not exist +- name: Remove already-absent entry + kubernetes.core.kubeconfig: + path: "{{ test_config_path }}" + clusters: + - name: cluster-2 + behavior: remove + register: remove_idempotent_result + +- name: Verify no change when removing nonexistent entry + assert: + that: + - remove_idempotent_result is not changed + +# Test 8: Keep behavior protects existing entry +- name: Attempt to overwrite protected cluster + kubernetes.core.kubeconfig: + path: "{{ test_config_path }}" + clusters: + - name: "{{ test_cluster_name }}" + behavior: keep + cluster: + server: https://should-not-apply.example.com:6443 + register: keep_result + +- name: Verify keep behavior left existing entry unchanged + assert: + that: + - keep_result is not changed + - keep_result.kubeconfig.clusters | selectattr('name', 'equalto', test_cluster_name) | map(attribute='cluster') | map(attribute='server') | first == "https://updated.example.com:6443" diff --git a/tests/unit/module_utils/test_kubeconfig.py b/tests/unit/module_utils/test_kubeconfig.py index 70eb9c8f..2986b01e 100644 --- a/tests/unit/module_utils/test_kubeconfig.py +++ b/tests/unit/module_utils/test_kubeconfig.py @@ -141,6 +141,39 @@ def test_merge_by_name_keep_behavior_preserves_existing(): assert result[0]["cluster"]["server"] == "https://old.com" +def test_merge_by_name_remove_behavior_removes_existing_entry(): + existing = [{"name": "cluster-a", "cluster": {"server": "https://a.com"}}] + new = [{"name": "cluster-a", "behavior": "remove"}] + result = merge_by_name(existing, new) + assert result == [] + + +def test_merge_by_name_remove_behavior_only_removes_target_entry(): + existing = [ + {"name": "cluster-a", "cluster": {"server": "https://a.com"}}, + {"name": "cluster-b", "cluster": {"server": "https://b.com"}}, + ] + new = [{"name": "cluster-a", "behavior": "remove"}] + result = merge_by_name(existing, new) + assert len(result) == 1 + assert result[0]["name"] == "cluster-b" + + +def test_merge_by_name_remove_behavior_silently_skips_nonexistent_entry(): + existing = [{"name": "cluster-a", "cluster": {"server": "https://a.com"}}] + new = [{"name": "cluster-nonexistent", "behavior": "remove"}] + result = merge_by_name(existing, new) + assert len(result) == 1 + assert result[0]["name"] == "cluster-a" + + +def test_merge_by_name_remove_behavior_on_empty_existing(): + existing = [] + new = [{"name": "cluster-a", "behavior": "remove"}] + result = merge_by_name(existing, new) + assert result == [] + + def test_merge_by_name_behavior_key_not_in_output(): existing = [] new = [