From 9f5476862a749449fc58ca310344b0d03eb818bd Mon Sep 17 00:00:00 2001 From: Veronika Fisarova Date: Mon, 17 Aug 2026 08:45:54 +0200 Subject: [PATCH] [kustomize_deploy] Generate BarbicanSimpleCryptoKEK at deploy time Ensure osp-secret manifests include a per-cluster Fernet KEK before oc apply. Look up the live secret in the namespace declared on the manifest (for example openstack2 in multi-namespace scenarios), reuse an existing cluster key when present, and generate a new key only when needed. Signed-off-by: Veronika Fisarova Co-authored-by: Cursor --- docs/dictionary/en-custom.txt | 1 + roles/kustomize_deploy/README.md | 7 + .../files/osp_secret_manifest.py | 115 ++++++++++ roles/kustomize_deploy/tasks/execute_step.yml | 5 + .../tasks/inject_osp_secret_keys.yml | 153 +++++++++++++ tests/unit/roles/test_osp_secret_manifest.py | 208 ++++++++++++++++++ 6 files changed, 489 insertions(+) create mode 100644 roles/kustomize_deploy/files/osp_secret_manifest.py create mode 100644 roles/kustomize_deploy/tasks/inject_osp_secret_keys.yml create mode 100644 tests/unit/roles/test_osp_secret_manifest.py diff --git a/docs/dictionary/en-custom.txt b/docs/dictionary/en-custom.txt index 937ea09f0..73da13798 100644 --- a/docs/dictionary/en-custom.txt +++ b/docs/dictionary/en-custom.txt @@ -234,6 +234,7 @@ fci fdp fedoraproject fil +Fernet filesystem fips firewalld diff --git a/roles/kustomize_deploy/README.md b/roles/kustomize_deploy/README.md index 23f0718b5..21a4df42d 100644 --- a/roles/kustomize_deploy/README.md +++ b/roles/kustomize_deploy/README.md @@ -118,6 +118,13 @@ $ ansible-playbook deploy-edpm.yml \ ``` This would skip the first stage described in the automation file. +During control-plane stages, `kustomize_deploy` ensures `BarbicanSimpleCryptoKEK` +is present in generated `osp-secret` manifests before `oc apply`. The role +looks up the live secret in the namespace declared on the manifest (for example +`openstack2` in multi-namespace scenarios), reuses an existing cluster key when +present, otherwise keeps keys from the kustomize output, otherwise generates a +Fernet key at deploy time. + ### Break point You can also stop the automated deploy by setting `cifmw_deploy_architecture_stopper` diff --git a/roles/kustomize_deploy/files/osp_secret_manifest.py b/roles/kustomize_deploy/files/osp_secret_manifest.py new file mode 100644 index 000000000..628086073 --- /dev/null +++ b/roles/kustomize_deploy/files/osp_secret_manifest.py @@ -0,0 +1,115 @@ +#!/usr/bin/env python3 +"""Helpers for osp-secret keys in kustomize-built manifests.""" + +import base64 +import json +import sys + +import yaml + +OSP_SECRET_NAME = "osp-secret" + + +def load_docs(path): + with open(path) as handle: + return [doc for doc in yaml.safe_load_all(handle) if doc is not None] + + +def find_osp_secret(docs): + for doc in docs: + if doc.get("kind") != "Secret": + continue + if doc.get("metadata", {}).get("name") != OSP_SECRET_NAME: + continue + return doc + return None + + +def get_secret_namespace(docs): + secret = find_osp_secret(docs) + if secret is None: + return None + return secret.get("metadata", {}).get("namespace") + + +def get_secret_key(docs, key): + secret = find_osp_secret(docs) + if secret is None: + return None + data = secret.get("data", {}) + if key not in data or not data[key]: + return None + return base64.b64decode(data[key]).decode() + + +def apply_secret_keys(docs, keys): + secret = find_osp_secret(docs) + if secret is None: + return False, [] + data = secret.setdefault("data", {}) + changed_keys = [] + for key, value in keys.items(): + if not value: + continue + encoded = base64.b64encode(value.encode()).decode() + if data.get(key) != encoded: + data[key] = encoded + changed_keys.append(key) + return bool(changed_keys), changed_keys + + +def cmd_has(path): + secret = find_osp_secret(load_docs(path)) + sys.exit(0 if secret else 1) + + +def cmd_get(path, key): + value = get_secret_key(load_docs(path), key) + if value is None: + sys.exit(2) + sys.stdout.write(value) + + +def cmd_get_namespace(path): + namespace = get_secret_namespace(load_docs(path)) + if not namespace: + sys.exit(2) + sys.stdout.write(namespace) + + +def cmd_set(path, keys_json): + docs = load_docs(path) + keys = json.loads(keys_json) + changed, changed_keys = apply_secret_keys(docs, keys) + if changed: + with open(path, "w") as handle: + yaml.dump_all(docs, handle, default_flow_style=False) + for key in changed_keys: + print("Set: {}".format(key)) + + +def main(): + if len(sys.argv) < 3: + sys.exit( + "usage: osp_secret_manifest.py [args]" + ) + command = sys.argv[1] + path = sys.argv[2] + if command == "has": + cmd_has(path) + elif command == "get": + if len(sys.argv) != 4: + sys.exit("usage: osp_secret_manifest.py get ") + cmd_get(path, sys.argv[3]) + elif command == "get-namespace": + cmd_get_namespace(path) + elif command == "set": + if len(sys.argv) != 4: + sys.exit("usage: osp_secret_manifest.py set ") + cmd_set(path, sys.argv[3]) + else: + sys.exit("unknown command: {}".format(command)) + + +if __name__ == "__main__": + main() diff --git a/roles/kustomize_deploy/tasks/execute_step.yml b/roles/kustomize_deploy/tasks/execute_step.yml index b78babdd6..da53b2a2e 100644 --- a/roles/kustomize_deploy/tasks/execute_step.yml +++ b/roles/kustomize_deploy/tasks/execute_step.yml @@ -289,6 +289,11 @@ loop_control: label: "{{ item }}" + - name: Ensure confidentiality-critical osp-secret keys in kustomize output + ansible.builtin.include_tasks: inject_osp_secret_keys.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_osp_secret_keys.yml b/roles/kustomize_deploy/tasks/inject_osp_secret_keys.yml new file mode 100644 index 000000000..29d25ef08 --- /dev/null +++ b/roles/kustomize_deploy/tasks/inject_osp_secret_keys.yml @@ -0,0 +1,153 @@ +--- +# 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. + +# Ensure confidentiality-critical osp-secret keys exist in kustomize output +# before oc apply. Prefers the live cluster secret over manifest content. + +- name: Check whether kustomize output contains osp-secret + ansible.builtin.command: + argv: + - python3 + - "{{ role_path }}/files/osp_secret_manifest.py" + - has + - "{{ cifmw_kustomize_deploy_manifest_path }}" + register: _osp_secret_manifest_check + changed_when: false + failed_when: false + +- name: Ensure BarbicanSimpleCryptoKEK in osp-secret manifest + when: _osp_secret_manifest_check.rc == 0 + vars: + _barbican_kek_key: BarbicanSimpleCryptoKEK + _nolog: "{{ cifmw_nolog | default(true) | bool }}" + block: + - name: Reset BarbicanSimpleCryptoKEK selection for this manifest + ansible.builtin.set_fact: + _barbican_simple_crypto_kek: "" + _decoded_cluster_barbican_kek: "" + + - name: Read osp-secret namespace from kustomize manifest + ansible.builtin.command: + argv: + - python3 + - "{{ role_path }}/files/osp_secret_manifest.py" + - get-namespace + - "{{ cifmw_kustomize_deploy_manifest_path }}" + register: _manifest_osp_secret_namespace + changed_when: false + failed_when: false + + - name: Set osp-secret namespace for cluster lookup + ansible.builtin.set_fact: + _osp_secret_namespace: >- + {{ + _manifest_osp_secret_namespace.stdout + if _manifest_osp_secret_namespace.rc == 0 and + (_manifest_osp_secret_namespace.stdout | length > 0) + else cifmw_openstack_namespace + }} + + - name: Get existing osp-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: osp-secret + namespace: "{{ _osp_secret_namespace }}" + register: _existing_osp_secret + no_log: "{{ _nolog }}" + + - name: Decode BarbicanSimpleCryptoKEK from cluster osp-secret + when: + - not cifmw_kustomize_deploy_generate_crs_only | bool + - _existing_osp_secret.resources is defined + - _existing_osp_secret.resources | length > 0 + - _barbican_kek_key in (_existing_osp_secret.resources[0].data | default({})) + ansible.builtin.set_fact: + _decoded_cluster_barbican_kek: >- + {{ _existing_osp_secret.resources[0].data[_barbican_kek_key] | b64decode }} + no_log: "{{ _nolog }}" + + - name: Use BarbicanSimpleCryptoKEK from cluster osp-secret + when: + - not cifmw_kustomize_deploy_generate_crs_only | bool + - _decoded_cluster_barbican_kek is defined + - _decoded_cluster_barbican_kek | length > 0 + ansible.builtin.set_fact: + _barbican_simple_crypto_kek: "{{ _decoded_cluster_barbican_kek }}" + no_log: "{{ _nolog }}" + + - name: Read BarbicanSimpleCryptoKEK from kustomize manifest + when: _barbican_simple_crypto_kek | length == 0 + ansible.builtin.command: + argv: + - python3 + - "{{ role_path }}/files/osp_secret_manifest.py" + - get + - "{{ cifmw_kustomize_deploy_manifest_path }}" + - "{{ _barbican_kek_key }}" + register: _manifest_barbican_kek + changed_when: false + failed_when: false + no_log: "{{ _nolog }}" + + - name: Use BarbicanSimpleCryptoKEK from kustomize manifest + when: + - _barbican_simple_crypto_kek | length == 0 + - _manifest_barbican_kek.rc == 0 + - _manifest_barbican_kek.stdout | length > 0 + ansible.builtin.set_fact: + _barbican_simple_crypto_kek: "{{ _manifest_barbican_kek.stdout }}" + no_log: "{{ _nolog }}" + + - name: Generate BarbicanSimpleCryptoKEK + when: _barbican_simple_crypto_kek | length == 0 + ansible.builtin.command: + argv: + - python3 + - -c + - >- + from cryptography.fernet import Fernet; + print(Fernet.generate_key().decode()) + register: _generated_barbican_kek + changed_when: false + no_log: "{{ _nolog }}" + + - name: Set generated BarbicanSimpleCryptoKEK + when: _barbican_simple_crypto_kek | length == 0 + ansible.builtin.set_fact: + _barbican_simple_crypto_kek: "{{ _generated_barbican_kek.stdout }}" + no_log: "{{ _nolog }}" + + - name: Inject BarbicanSimpleCryptoKEK into kustomize manifest + ansible.builtin.command: + argv: + - python3 + - "{{ role_path }}/files/osp_secret_manifest.py" + - set + - "{{ cifmw_kustomize_deploy_manifest_path }}" + - >- + {{ + { + _barbican_kek_key: _barbican_simple_crypto_kek + } | to_json + }} + register: _inject_barbican_kek + changed_when: "'Set:' in _inject_barbican_kek.stdout" + no_log: "{{ _nolog }}" diff --git a/tests/unit/roles/test_osp_secret_manifest.py b/tests/unit/roles/test_osp_secret_manifest.py new file mode 100644 index 000000000..46b97bda7 --- /dev/null +++ b/tests/unit/roles/test_osp_secret_manifest.py @@ -0,0 +1,208 @@ +import base64 +import importlib.util +import json +import subprocess +import sys +from pathlib import Path + +import pytest +import yaml + +REPO_ROOT = Path(__file__).resolve().parents[3] +SCRIPT_PATH = REPO_ROOT / "roles/kustomize_deploy/files/osp_secret_manifest.py" + + +def load_module(): + spec = importlib.util.spec_from_file_location("osp_secret_manifest", SCRIPT_PATH) + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + return module + + +def write_manifest(path, docs): + with open(path, "w") as handle: + yaml.dump_all(docs, handle, default_flow_style=False) + + +@pytest.fixture +def osp_secret_module(): + return load_module() + + +@pytest.fixture +def manifest_path(tmp_path): + return tmp_path / "control-plane.yaml" + + +class TestOspSecretManifestHelpers: + def test_find_osp_secret_returns_matching_secret(self, osp_secret_module): + docs = [ + {"kind": "Secret", "metadata": {"name": "other-secret"}}, + { + "kind": "Secret", + "metadata": {"name": "osp-secret", "namespace": "openstack2"}, + }, + ] + secret = osp_secret_module.find_osp_secret(docs) + assert secret["metadata"]["namespace"] == "openstack2" + + def test_get_secret_namespace(self, osp_secret_module): + docs = [ + { + "kind": "Secret", + "metadata": {"name": "osp-secret", "namespace": "openstack2"}, + } + ] + assert osp_secret_module.get_secret_namespace(docs) == "openstack2" + + def test_get_secret_namespace_missing(self, osp_secret_module): + docs = [{"kind": "Secret", "metadata": {"name": "osp-secret"}}] + assert osp_secret_module.get_secret_namespace(docs) is None + + def test_get_secret_key_roundtrip(self, osp_secret_module): + value = "sEFmdFjDUqRM2VemYslV5yGNWjokioJXsg8Nrlc3drU=" + docs = [ + { + "kind": "Secret", + "metadata": {"name": "osp-secret"}, + "data": { + "BarbicanSimpleCryptoKEK": base64.b64encode(value.encode()).decode() + }, + } + ] + assert ( + osp_secret_module.get_secret_key(docs, "BarbicanSimpleCryptoKEK") == value + ) + + def test_apply_secret_keys_sets_missing_key(self, osp_secret_module, manifest_path): + docs = [{"kind": "Secret", "metadata": {"name": "osp-secret"}, "data": {}}] + write_manifest(manifest_path, docs) + loaded = osp_secret_module.load_docs(manifest_path) + changed, changed_keys = osp_secret_module.apply_secret_keys( + loaded, + {"BarbicanSimpleCryptoKEK": "generated-kek-value"}, + ) + assert changed is True + assert changed_keys == ["BarbicanSimpleCryptoKEK"] + assert ( + osp_secret_module.get_secret_key(loaded, "BarbicanSimpleCryptoKEK") + == "generated-kek-value" + ) + + def test_apply_secret_keys_skips_identical_value(self, osp_secret_module): + value = "same-kek-value" + encoded = base64.b64encode(value.encode()).decode() + docs = [ + { + "kind": "Secret", + "metadata": {"name": "osp-secret"}, + "data": {"BarbicanSimpleCryptoKEK": encoded}, + } + ] + changed, changed_keys = osp_secret_module.apply_secret_keys( + docs, + {"BarbicanSimpleCryptoKEK": value}, + ) + assert changed is False + assert changed_keys == [] + + +class TestOspSecretManifestCli: + def test_has_exits_zero_when_secret_present(self, manifest_path): + write_manifest( + manifest_path, + [{"kind": "Secret", "metadata": {"name": "osp-secret"}}], + ) + result = subprocess.run( + [sys.executable, str(SCRIPT_PATH), "has", str(manifest_path)], + capture_output=True, + text=True, + check=False, + ) + assert result.returncode == 0 + + def test_has_exits_nonzero_when_secret_missing(self, manifest_path): + write_manifest( + manifest_path, + [{"kind": "Secret", "metadata": {"name": "other-secret"}}], + ) + 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_get_namespace_returns_manifest_namespace(self, manifest_path): + write_manifest( + manifest_path, + [ + { + "kind": "Secret", + "metadata": {"name": "osp-secret", "namespace": "openstack2"}, + } + ], + ) + result = subprocess.run( + [ + sys.executable, + str(SCRIPT_PATH), + "get-namespace", + str(manifest_path), + ], + capture_output=True, + text=True, + check=False, + ) + assert result.returncode == 0 + assert result.stdout == "openstack2" + + def test_set_only_reports_changed_keys(self, manifest_path, osp_secret_module): + value = "existing-kek" + encoded = base64.b64encode(value.encode()).decode() + write_manifest( + manifest_path, + [ + { + "kind": "Secret", + "metadata": {"name": "osp-secret"}, + "data": {"BarbicanSimpleCryptoKEK": encoded}, + } + ], + ) + result = subprocess.run( + [ + sys.executable, + str(SCRIPT_PATH), + "set", + str(manifest_path), + json.dumps({"BarbicanSimpleCryptoKEK": value}), + ], + capture_output=True, + text=True, + check=False, + ) + assert result.returncode == 0 + assert result.stdout == "" + + new_value = "new-kek-value" + result = subprocess.run( + [ + sys.executable, + str(SCRIPT_PATH), + "set", + str(manifest_path), + json.dumps({"BarbicanSimpleCryptoKEK": new_value}), + ], + capture_output=True, + text=True, + check=False, + ) + assert result.returncode == 0 + assert "Set: BarbicanSimpleCryptoKEK" in result.stdout + loaded = osp_secret_module.load_docs(manifest_path) + assert ( + osp_secret_module.get_secret_key(loaded, "BarbicanSimpleCryptoKEK") + == new_value + )