From 71c8764d761372e552756c8f4ea40ffac2378ea7 Mon Sep 17 00:00:00 2001 From: Andrew Bays Date: Wed, 9 Sep 2026 09:54:00 +0000 Subject: [PATCH] [kustomize_deploy] Randomize libvirt-secret password at deploy time The architecture repo's lib/dataplane/nodeset component generates a libvirt-secret Secret from a hardcoded libvirt-secret.env file (LibvirtPassword=12345678). Extend the post-kustomize manifest injection pattern introduced for osp-secret so this well-known default password is also randomized before `oc apply`. After each `kustomize build`, if the rendered dataplane manifest contains a libvirt-secret Secret, its data keys are replaced with a cryptographically random value via the shared `osp_secret_manifest.py` helper, unless a live cluster secret already exists (in which case its value is preserved across redeploys). To support this, generalize `osp_secret_manifest.py` so its helpers and CLI commands (has, get, get-namespace, set, randomize) accept an optional secret name, defaulting to osp-secret for backward compatibility. Add a new inject_libvirt_secret_key.yml task file mirroring the osp-secret randomization block, wired into execute_step.yml so it runs for every stage but is a no-op except for the dataplane stage. Co-Authored-By: Cursor Signed-off-by: Andrew Bays --- docs/dictionary/en-custom.txt | 1 + roles/kustomize_deploy/README.md | 12 ++ .../files/osp_secret_manifest.py | 92 +++++++----- roles/kustomize_deploy/tasks/execute_step.yml | 5 + .../tasks/inject_libvirt_secret_key.yml | 137 ++++++++++++++++++ tests/unit/roles/test_osp_secret_manifest.py | 106 ++++++++++++++ 6 files changed, 317 insertions(+), 36 deletions(-) create mode 100644 roles/kustomize_deploy/tasks/inject_libvirt_secret_key.yml diff --git a/docs/dictionary/en-custom.txt b/docs/dictionary/en-custom.txt index dfc11fffd..41277fa1a 100644 --- a/docs/dictionary/en-custom.txt +++ b/docs/dictionary/en-custom.txt @@ -570,6 +570,7 @@ redhat refspec regexes repo +repo's repos rgw rhel diff --git a/roles/kustomize_deploy/README.md b/roles/kustomize_deploy/README.md index 768e27d59..07cb0ce15 100644 --- a/roles/kustomize_deploy/README.md +++ b/roles/kustomize_deploy/README.md @@ -130,6 +130,18 @@ resolution: 2. **Generate** — a new random value is produced (respecting the special-keys format rules). Keys in the skip list are never touched. +### libvirt-secret randomization + +The architecture repo's `lib/dataplane/nodeset` component generates a +`libvirt-secret` Secret from a hardcoded +[`libvirt-secret.env`](https://github.com/openstack-k8s-operators/architecture/blob/main/lib/dataplane/nodeset/libvirt-secret.env) +file (`LibvirtPassword=12345678`). After each `kustomize build`, if the +rendered dataplane manifest contains a `libvirt-secret` Secret, its data +keys are randomized before `oc apply` using the same two-tier resolution as +`osp-secret` (live cluster value takes priority over a freshly generated +20-char alphanumeric password). This is a no-op for stages that do not +produce a `libvirt-secret` (e.g. control-plane, operators). + ## Automation specificities ### Timeouts diff --git a/roles/kustomize_deploy/files/osp_secret_manifest.py b/roles/kustomize_deploy/files/osp_secret_manifest.py index da48329b3..94e5994c3 100644 --- a/roles/kustomize_deploy/files/osp_secret_manifest.py +++ b/roles/kustomize_deploy/files/osp_secret_manifest.py @@ -1,5 +1,11 @@ #!/usr/bin/env python3 -"""Helpers for osp-secret keys in kustomize-built manifests.""" +"""Helpers for Secret keys in kustomize-built manifests. + +Originally written for ``osp-secret`` (the default target of every +function/command below), the same helpers also operate on other +single-Secret manifests such as ``libvirt-secret`` by passing an explicit +``secret_name``. +""" import base64 import json @@ -17,26 +23,31 @@ def load_docs(path): return [doc for doc in yaml.safe_load_all(handle) if doc is not None] -def find_osp_secret(docs): - # Each kustomize output is expected to contain at most one osp-secret. +def find_secret(docs, secret_name=OSP_SECRET_NAME): + # Each kustomize output is expected to contain at most one Secret + # with a given name (e.g. osp-secret, libvirt-secret). for doc in docs: if doc.get("kind") != "Secret": continue - if doc.get("metadata", {}).get("name") != OSP_SECRET_NAME: + if doc.get("metadata", {}).get("name") != secret_name: continue return doc return None -def get_secret_namespace(docs): - secret = find_osp_secret(docs) +def find_osp_secret(docs): + return find_secret(docs, OSP_SECRET_NAME) + + +def get_secret_namespace(docs, secret_name=OSP_SECRET_NAME): + secret = find_secret(docs, secret_name) if secret is None: return None return secret.get("metadata", {}).get("namespace") -def get_secret_key(docs, key): - secret = find_osp_secret(docs) +def get_secret_key(docs, key, secret_name=OSP_SECRET_NAME): + secret = find_secret(docs, secret_name) if secret is None: return None data = secret.get("data", {}) @@ -45,8 +56,8 @@ def get_secret_key(docs, key): return base64.b64decode(data[key]).decode() -def apply_secret_keys(docs, keys): - secret = find_osp_secret(docs) +def apply_secret_keys(docs, keys, secret_name=OSP_SECRET_NAME): + secret = find_secret(docs, secret_name) if secret is None: return False, [] data = secret.setdefault("data", {}) @@ -70,15 +81,15 @@ def generate_hex_key(byte_length): return secrets.token_hex(byte_length) -def randomize_secret_keys(docs, config): - """Replace osp-secret data values with random ones. +def randomize_secret_keys(docs, config, secret_name=OSP_SECRET_NAME): + """Replace a Secret's data values with random ones. ``config`` is a dict with optional keys: cluster_values - dict of key->plaintext from the live cluster skip_keys - list of keys to leave untouched special_keys - dict of key->{type, length} for non-password formats """ - secret = find_osp_secret(docs) + secret = find_secret(docs, secret_name) if secret is None: return False, [] @@ -114,8 +125,8 @@ def randomize_secret_keys(docs, config): return bool(changed_keys), changed_keys -def cmd_has(path): - # Exit codes: 0 = osp-secret found, 1 = not found, 2 = error while +def cmd_has(path, secret_name=OSP_SECRET_NAME): + # Exit codes: 0 = secret found, 1 = not found, 2 = error while # reading/parsing the manifest. Callers must not treat 2 the same as 1: # a parse failure should never be silently mistaken for "nothing to do". try: @@ -123,19 +134,19 @@ def cmd_has(path): except Exception as exc: sys.stderr.write("error: failed to load manifest {}: {}\n".format(path, exc)) sys.exit(2) - secret = find_osp_secret(docs) + secret = find_secret(docs, secret_name) sys.exit(0 if secret else 1) -def cmd_get(path, key): - value = get_secret_key(load_docs(path), key) +def cmd_get(path, key, secret_name=OSP_SECRET_NAME): + value = get_secret_key(load_docs(path), key, secret_name) if value is None: sys.exit(2) sys.stdout.write(value) -def cmd_get_namespace(path): - namespace = get_secret_namespace(load_docs(path)) +def cmd_get_namespace(path, secret_name=OSP_SECRET_NAME): + namespace = get_secret_namespace(load_docs(path), secret_name) if not namespace: sys.exit(2) sys.stdout.write(namespace) @@ -146,10 +157,10 @@ def load_keys(keys_path): return json.load(handle) -def cmd_set(path, keys_path): +def cmd_set(path, keys_path, secret_name=OSP_SECRET_NAME): docs = load_docs(path) keys = load_keys(keys_path) - changed, changed_keys = apply_secret_keys(docs, keys) + changed, changed_keys = apply_secret_keys(docs, keys, secret_name) if changed: with open(path, "w") as handle: yaml.dump_all(docs, handle, default_flow_style=False) @@ -157,10 +168,10 @@ def cmd_set(path, keys_path): print("Set: {}".format(key)) -def cmd_randomize(path, config_path): +def cmd_randomize(path, config_path, secret_name=OSP_SECRET_NAME): docs = load_docs(path) config = load_keys(config_path) - changed, changed_keys = randomize_secret_keys(docs, config) + changed, changed_keys = randomize_secret_keys(docs, config, secret_name) if changed: with open(path, "w") as handle: yaml.dump_all(docs, handle, default_flow_style=False) @@ -172,28 +183,37 @@ def main(): if len(sys.argv) < 3: sys.exit( "usage: osp_secret_manifest.py" - " [args]" + " [args] [secret-name]" ) command = sys.argv[1] path = sys.argv[2] if command == "has": - cmd_has(path) + secret_name = sys.argv[3] if len(sys.argv) > 3 else OSP_SECRET_NAME + cmd_has(path, secret_name) elif command == "get": - if len(sys.argv) != 4: - sys.exit("usage: osp_secret_manifest.py get ") - cmd_get(path, sys.argv[3]) + if len(sys.argv) not in (4, 5): + sys.exit("usage: osp_secret_manifest.py get [secret-name]") + secret_name = sys.argv[4] if len(sys.argv) == 5 else OSP_SECRET_NAME + cmd_get(path, sys.argv[3], secret_name) elif command == "get-namespace": - cmd_get_namespace(path) + secret_name = sys.argv[3] if len(sys.argv) > 3 else OSP_SECRET_NAME + cmd_get_namespace(path, secret_name) elif command == "set": - if len(sys.argv) != 4: - sys.exit("usage: osp_secret_manifest.py set ") - cmd_set(path, sys.argv[3]) + if len(sys.argv) not in (4, 5): + sys.exit( + "usage: osp_secret_manifest.py" + " set [secret-name]" + ) + secret_name = sys.argv[4] if len(sys.argv) == 5 else OSP_SECRET_NAME + cmd_set(path, sys.argv[3], secret_name) elif command == "randomize": - if len(sys.argv) != 4: + if len(sys.argv) not in (4, 5): sys.exit( - "usage: osp_secret_manifest.py randomize " + "usage: osp_secret_manifest.py" + " randomize [secret-name]" ) - cmd_randomize(path, sys.argv[3]) + secret_name = sys.argv[4] if len(sys.argv) == 5 else OSP_SECRET_NAME + cmd_randomize(path, sys.argv[3], secret_name) else: sys.exit("unknown command: {}".format(command)) diff --git a/roles/kustomize_deploy/tasks/execute_step.yml b/roles/kustomize_deploy/tasks/execute_step.yml index da53b2a2e..c3a22edd4 100644 --- a/roles/kustomize_deploy/tasks/execute_step.yml +++ b/roles/kustomize_deploy/tasks/execute_step.yml @@ -294,6 +294,11 @@ vars: cifmw_kustomize_deploy_manifest_path: "{{ _output }}" + - name: Ensure libvirt-secret password is randomized in kustomize output + ansible.builtin.include_tasks: inject_libvirt_secret_key.yml + vars: + cifmw_kustomize_deploy_manifest_path: "{{ _output }}" + - name: "Store kustomized content in artifacts for {{ stage.path }}" ansible.builtin.copy: remote_src: true diff --git a/roles/kustomize_deploy/tasks/inject_libvirt_secret_key.yml b/roles/kustomize_deploy/tasks/inject_libvirt_secret_key.yml new file mode 100644 index 000000000..16cbd1e10 --- /dev/null +++ b/roles/kustomize_deploy/tasks/inject_libvirt_secret_key.yml @@ -0,0 +1,137 @@ +--- +# Copyright Red Hat, Inc. +# All Rights Reserved. +# +# 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. + +# Randomize the libvirt-secret (LibvirtPassword) coming from the +# architecture repo's lib/dataplane/nodeset libvirt-secret.env, which +# hardcodes a well-known default password. Only present in the dataplane +# manifest, so this is a no-op for every other stage. + +- name: Ensure libvirt-secret password is randomized in kustomize manifest + vars: + _libvirt_secret_name: libvirt-secret + _nolog: "{{ cifmw_nolog | default(true) | bool }}" + block: + - name: Check whether kustomize output contains libvirt-secret + ansible.builtin.command: + argv: + - python3 + - "{{ role_path }}/files/osp_secret_manifest.py" + - has + - "{{ cifmw_kustomize_deploy_manifest_path }}" + - "{{ _libvirt_secret_name }}" + register: _libvirt_secret_manifest_check + changed_when: false + # rc 0 = found, rc 1 = not found (both fine, handled below). Any other + # rc means the manifest could not be read/parsed: fail loudly instead + # of silently skipping randomization of a possibly-default password. + failed_when: _libvirt_secret_manifest_check.rc not in [0, 1] + + - name: Randomize libvirt-secret password + when: _libvirt_secret_manifest_check.rc == 0 + block: + - name: Read libvirt-secret namespace from kustomize manifest + ansible.builtin.command: + argv: + - python3 + - "{{ role_path }}/files/osp_secret_manifest.py" + - get-namespace + - "{{ cifmw_kustomize_deploy_manifest_path }}" + - "{{ _libvirt_secret_name }}" + register: _manifest_libvirt_secret_namespace + changed_when: false + failed_when: false + + - name: Set libvirt-secret namespace for cluster lookup + ansible.builtin.set_fact: + _libvirt_secret_namespace: >- + {{ + _manifest_libvirt_secret_namespace.stdout + if _manifest_libvirt_secret_namespace.rc == 0 and + (_manifest_libvirt_secret_namespace.stdout | length > 0) + else cifmw_openstack_namespace + }} + + - name: Get existing libvirt-secret from cluster + when: not cifmw_kustomize_deploy_generate_crs_only | bool + kubernetes.core.k8s_info: + kubeconfig: "{{ cifmw_openshift_kubeconfig }}" + api_key: "{{ cifmw_openshift_token | default(omit) }}" + context: "{{ cifmw_openshift_context | default(omit) }}" + api_version: v1 + kind: Secret + name: "{{ _libvirt_secret_name }}" + namespace: "{{ _libvirt_secret_namespace }}" + register: _existing_libvirt_secret + no_log: "{{ _nolog }}" + + - name: Build cluster secret values dict for libvirt-secret randomizer + vars: + _raw_data: >- + {{ + _existing_libvirt_secret.resources[0].data + if (_existing_libvirt_secret.resources | default([]) | length > 0) + else {} + }} + ansible.builtin.set_fact: + _cluster_values_for_libvirt_randomize: >- + {% set result = {} -%} + {% for key, val in _raw_data.items() -%} + {% set _ = result.update({key: val | b64decode}) -%} + {% endfor -%} + {{ result }} + no_log: "{{ _nolog }}" + + - name: Create temporary libvirt-secret randomize config file + ansible.builtin.tempfile: + state: file + suffix: .libvirt-secret-randomize.json + register: _libvirt_randomize_config_file + no_log: "{{ _nolog }}" + + - name: Write config and randomize libvirt-secret key, always removing the temp file + block: + - name: Write libvirt-secret randomize config + ansible.builtin.copy: + dest: "{{ _libvirt_randomize_config_file.path }}" + content: >- + {{ + { + "cluster_values": _cluster_values_for_libvirt_randomize + } | to_json + }} + mode: "0600" + no_log: "{{ _nolog }}" + + - name: Randomize libvirt-secret key in kustomize manifest + ansible.builtin.command: + argv: + - python3 + - "{{ role_path }}/files/osp_secret_manifest.py" + - randomize + - "{{ cifmw_kustomize_deploy_manifest_path }}" + - "{{ _libvirt_randomize_config_file.path }}" + - "{{ _libvirt_secret_name }}" + register: _randomize_libvirt_secret + changed_when: "'Randomized:' in _randomize_libvirt_secret.stdout" + no_log: "{{ _nolog }}" + always: + # Ensures the plaintext cluster secret never lingers on disk, + # even if the write or the randomize command above fails. + - name: Remove temporary libvirt-secret randomize config file + ansible.builtin.file: + path: "{{ _libvirt_randomize_config_file.path }}" + state: absent + no_log: "{{ _nolog }}" diff --git a/tests/unit/roles/test_osp_secret_manifest.py b/tests/unit/roles/test_osp_secret_manifest.py index 7e323aa17..d2b293829 100644 --- a/tests/unit/roles/test_osp_secret_manifest.py +++ b/tests/unit/roles/test_osp_secret_manifest.py @@ -399,3 +399,109 @@ def test_randomize_preserves_cluster_values_via_cli( osp_secret_module.get_secret_key(loaded, "AdminPassword") == "kept-from-cluster" ) + + +def _make_libvirt_secret_docs(data_dict): + return [ + { + "kind": "Secret", + "metadata": {"name": "libvirt-secret", "namespace": "openstack"}, + "data": {k: _b64(v) for k, v in data_dict.items()}, + } + ] + + +class TestSecretNameParameter: + """Same helpers/CLI, targeting a Secret other than osp-secret.""" + + def test_find_secret_matches_by_name(self, osp_secret_module): + docs = _make_libvirt_secret_docs({"LibvirtPassword": "12345678"}) + assert osp_secret_module.find_secret(docs, "libvirt-secret") is not None + assert osp_secret_module.find_secret(docs, "osp-secret") is None + + def test_get_secret_key_with_secret_name(self, osp_secret_module): + docs = _make_libvirt_secret_docs({"LibvirtPassword": "12345678"}) + assert ( + osp_secret_module.get_secret_key(docs, "LibvirtPassword", "libvirt-secret") + == "12345678" + ) + + def test_randomize_secret_keys_with_secret_name(self, osp_secret_module): + docs = _make_libvirt_secret_docs({"LibvirtPassword": "12345678"}) + changed, changed_keys = osp_secret_module.randomize_secret_keys( + docs, {}, "libvirt-secret" + ) + assert changed is True + assert changed_keys == ["LibvirtPassword"] + value = osp_secret_module.get_secret_key( + docs, "LibvirtPassword", "libvirt-secret" + ) + assert value != "12345678" + assert len(value) == 20 + + def test_randomize_secret_keys_ignores_other_named_secret(self, osp_secret_module): + # A libvirt-secret config must not touch an unrelated osp-secret + # present in the same manifest. + docs = _make_osp_secret_docs( + {"AdminPassword": "12345678"} + ) + _make_libvirt_secret_docs({"LibvirtPassword": "12345678"}) + osp_secret_module.randomize_secret_keys(docs, {}, "libvirt-secret") + assert osp_secret_module.get_secret_key(docs, "AdminPassword") == "12345678" + + def test_has_cli_supports_secret_name(self, manifest_path): + write_manifest( + manifest_path, + _make_libvirt_secret_docs({"LibvirtPassword": "12345678"}), + ) + result = subprocess.run( + [ + sys.executable, + str(SCRIPT_PATH), + "has", + str(manifest_path), + "libvirt-secret", + ], + capture_output=True, + text=True, + check=False, + ) + assert result.returncode == 0 + result = subprocess.run( + [sys.executable, str(SCRIPT_PATH), "has", str(manifest_path)], + capture_output=True, + text=True, + check=False, + ) + assert result.returncode == 1 + + def test_randomize_cli_supports_secret_name( + self, manifest_path, osp_secret_module, tmp_path + ): + write_manifest( + manifest_path, + _make_libvirt_secret_docs({"LibvirtPassword": "12345678"}), + ) + config_file = tmp_path / "config.json" + config_file.write_text(json.dumps({})) + result = subprocess.run( + [ + sys.executable, + str(SCRIPT_PATH), + "randomize", + str(manifest_path), + str(config_file), + "libvirt-secret", + ], + capture_output=True, + text=True, + check=False, + ) + assert result.returncode == 0 + assert "Randomized: LibvirtPassword" in result.stdout + loaded = osp_secret_module.load_docs(manifest_path) + assert ( + osp_secret_module.get_secret_key( + loaded, "LibvirtPassword", "libvirt-secret" + ) + != "12345678" + )