diff --git a/common/.ansible-lint b/common/.ansible-lint index 353222eb..aaffc6b5 100644 --- a/common/.ansible-lint +++ b/common/.ansible-lint @@ -14,4 +14,7 @@ skip_list: exclude_paths: - ./ansible/playbooks/vault/vault.yaml - ./ansible/playbooks/iib-ci/iib-ci.yaml + - ./ansible/playbooks/k8s_secrets/k8s_secrets.yml + - ./ansible/playbooks/process_secrets/process_secrets.yml + - ./ansible/playbooks/process_secrets/display_secrets_info.yml - ./ansible/roles/vault_utils/tests/test.yml diff --git a/common/.github/workflows/chart-branches.yml b/common/.github/workflows/chart-branches.yml index d93b1dbb..1a4fb455 100644 --- a/common/.github/workflows/chart-branches.yml +++ b/common/.github/workflows/chart-branches.yml @@ -32,7 +32,7 @@ jobs: - name: Checkout Code uses: actions/checkout@v4 - - uses: dorny/paths-filter@v2 + - uses: dorny/paths-filter@v3 id: filter with: filters: | diff --git a/common/.github/workflows/linter.yml b/common/.github/workflows/linter.yml index 947cc127..39aa63cb 100644 --- a/common/.github/workflows/linter.yml +++ b/common/.github/workflows/linter.yml @@ -36,7 +36,7 @@ jobs: - name: Setup helm uses: azure/setup-helm@v3 with: - version: 'v3.12.3' + version: 'v3.13.2' ################################ diff --git a/common/.gitignore b/common/.gitignore index 9e5051a8..454efc9e 100644 --- a/common/.gitignore +++ b/common/.gitignore @@ -5,6 +5,7 @@ __pycache__/ *.swo values-secret.yaml .*.expected.yaml +.vscode pattern-vault.init pattern-vault.init.bak super-linter.log diff --git a/common/Makefile b/common/Makefile index d07ca5cd..0d5d0a36 100644 --- a/common/Makefile +++ b/common/Makefile @@ -77,9 +77,37 @@ uninstall: ## runs helm uninstall @oc delete csv -n openshift-operators $(CSV) .PHONY: load-secrets -load-secrets: ## loads the secrets into the vault +load-secrets: ## loads the secrets into the backend determined by values-global setting + common/scripts/process-secrets.sh $(NAME) + +.PHONY: legacy-load-secrets +legacy-load-secrets: ## loads the secrets into vault (only) common/scripts/vault-utils.sh push_secrets $(NAME) +.PHONY: secrets-backend-vault +secrets-backend-vault: ## Edits values files to use default Vault+ESO secrets config + common/scripts/set-secret-backend.sh vault + common/scripts/manage-secret-app.sh vault present + common/scripts/manage-secret-app.sh golang-external-secrets present + common/scripts/manage-secret-namespace.sh validated-patterns-secrets absent + @git diff --exit-code || echo "Secrets backend set to vault, please review changes, commit, and push to activate in the pattern" + +.PHONY: secrets-backend-kubernetes +secrets-backend-kubernetes: ## Edits values file to use Kubernetes+ESO secrets config + common/scripts/set-secret-backend.sh kubernetes + common/scripts/manage-secret-namespace.sh validated-patterns-secrets present + common/scripts/manage-secret-app.sh vault absent + common/scripts/manage-secret-app.sh golang-external-secrets present + @git diff --exit-code || echo "Secrets backend set to kubernetes, please review changes, commit, and push to activate in the pattern" + +.PHONY: secrets-backend-none +secrets-backend-none: ## Edits values files to remove secrets manager + ESO + common/scripts/set-secret-backend.sh none + common/scripts/manage-secret-app.sh vault absent + common/scripts/manage-secret-app.sh golang-external-secrets absent + common/scripts/manage-secret-namespace.sh validated-patterns-secrets absent + @git diff --exit-code || echo "Secrets backend set to none, please review changes, commit, and push to activate in the pattern" + .PHONY: load-iib load-iib: ## CI target to install Index Image Bundles @set -e; if [ x$(INDEX_IMAGES) != x ]; then \ @@ -99,14 +127,9 @@ load-iib: ## CI target to install Index Image Bundles .PHONY: validate-origin validate-origin: ## verify the git origin is available @echo "Checking repository:" - @echo -n " $(TARGET_REPO) - branch $(TARGET_BRANCH): " - @if [ ! -f /run/.containerenv ]; then\ - git ls-remote --exit-code --heads $(TARGET_REPO) $(TARGET_BRANCH) >/dev/null &&\ - echo "OK" ||\ - (echo "NOT FOUND"; exit 1);\ - else\ - echo "Running inside a container: Skipping git ssh checks";\ - fi + @echo -n " $(TARGET_REPO) - branch '$(TARGET_BRANCH)': " + @git ls-remote --exit-code --heads $(TARGET_REPO) $(TARGET_BRANCH) >/dev/null &&\ + echo "OK" || (echo "NOT FOUND"; exit 1) .PHONY: validate-cluster validate-cluster: ## Do some cluster validations before installing @@ -130,15 +153,19 @@ validate-schema: ## validates values files against schema in common/clustergroup .PHONY: validate-prereq validate-prereq: ## verify pre-requisites - @echo "Checking prerequisites:" - @for t in $(EXECUTABLES); do if ! which $$t > /dev/null 2>&1; then echo "No $$t in PATH"; exit 1; fi; done - @echo " Check for '$(EXECUTABLES)': OK" - @echo -n " Check for python-kubernetes: " - @if ! ansible -m ansible.builtin.command -a "{{ ansible_python_interpreter }} -c 'import kubernetes'" localhost > /dev/null 2>&1; then echo "Not found"; exit 1; fi - @echo "OK" - @echo -n " Check for kubernetes.core collection: " - @if ! ansible-galaxy collection list | grep kubernetes.core > /dev/null 2>&1; then echo "Not found"; exit 1; fi - @echo "OK" + @if [ ! -f /run/.containerenv ]; then\ + echo "Checking prerequisites:";\ + for t in $(EXECUTABLES); do if ! which $$t > /dev/null 2>&1; then echo "No $$t in PATH"; exit 1; fi; done;\ + echo " Check for '$(EXECUTABLES)': OK";\ + echo -n " Check for python-kubernetes: ";\ + if ! ansible -m ansible.builtin.command -a "{{ ansible_python_interpreter }} -c 'import kubernetes'" localhost > /dev/null 2>&1; then echo "Not found"; exit 1; fi;\ + echo "OK";\ + echo -n " Check for kubernetes.core collection: ";\ + if ! ansible-galaxy collection list | grep kubernetes.core > /dev/null 2>&1; then echo "Not found"; exit 1; fi;\ + echo "OK";\ + else\ + echo "Skipping prerequisites check as we're running inside a container";\ + fi .PHONY: argo-healthcheck argo-healthcheck: ## Checks if all argo applications are synced diff --git a/common/ansible/playbooks/k8s_secrets/k8s_secrets.yml b/common/ansible/playbooks/k8s_secrets/k8s_secrets.yml new file mode 100644 index 00000000..989a498a --- /dev/null +++ b/common/ansible/playbooks/k8s_secrets/k8s_secrets.yml @@ -0,0 +1,9 @@ +--- +- name: Secrets parsing and direct loading + hosts: localhost + connection: local + gather_facts: false + roles: + - find_vp_secrets + - cluster_pre_check + - k8s_secret_utils diff --git a/common/ansible/playbooks/process_secrets/display_secrets_info.yml b/common/ansible/playbooks/process_secrets/display_secrets_info.yml new file mode 100644 index 00000000..4d972359 --- /dev/null +++ b/common/ansible/playbooks/process_secrets/display_secrets_info.yml @@ -0,0 +1,29 @@ +--- +- name: Parse and display secrets + hosts: localhost + connection: local + gather_facts: false + vars: + secrets_backing_store: "vault" + tasks: + # Set the VALUES_SECRET environment variable to the file to parse + - name: Find and decrypt secrets if needed + ansible.builtin.include_role: + name: find_vp_secrets + + # find_vp_secrets will return a plaintext data structure called values_secrets_data + # This will allow us to determine schema version and which backend to use + - name: Determine how to load secrets + ansible.builtin.set_fact: + secrets_yaml: '{{ values_secrets_data | from_yaml }}' + + - name: Parse secrets data + no_log: '{{ override_no_log | default(true) }}' + parse_secrets_info: + values_secrets_plaintext: "{{ values_secrets_data }}" + secrets_backing_store: "{{ secrets_backing_store }}" + register: secrets_results + + - name: Display secrets data + ansible.builtin.debug: + var: secrets_results diff --git a/common/ansible/playbooks/process_secrets/process_secrets.yml b/common/ansible/playbooks/process_secrets/process_secrets.yml new file mode 100644 index 00000000..ecc1b565 --- /dev/null +++ b/common/ansible/playbooks/process_secrets/process_secrets.yml @@ -0,0 +1,50 @@ +--- +- name: Parse and load secrets + hosts: localhost + connection: local + gather_facts: false + vars: + secrets_role: 'vault_utils' + pattern_name: 'common' + pattern_dir: '.' + secrets_backing_store: 'vault' + tasks_from: 'push_parsed_secrets' + tasks: + - name: "Run secret-loading pre-requisites" + ansible.builtin.include_role: + name: '{{ item }}' + loop: + - cluster_pre_check + - find_vp_secrets + + # find_vp_secrets will return a plaintext data structure called values_secrets_data + # This will allow us to determine schema version and which backend to use + - name: Determine how to load secrets + ansible.builtin.set_fact: + secrets_yaml: '{{ values_secrets_data | from_yaml }}' + + - name: Parse secrets data + no_log: '{{ override_no_log | default(true) }}' + parse_secrets_info: + values_secrets_plaintext: "{{ values_secrets_data }}" + secrets_backing_store: "{{ secrets_backing_store }}" + register: secrets_results + + # Use the k8s secrets loader when explicitly requested + - name: Determine role to use to load secrets + ansible.builtin.set_fact: + secrets_role: 'k8s_secret_utils' + tasks_from: 'inject_k8s_secrets' + when: + - secrets_backing_store == "kubernetes" or secrets_backing_store == "none" + - secrets_yaml['version'] | default('2.0') >= '2.0' + + # secrets_role will have been changed from the default if needed + - name: Load secrets using designated role and tasks + ansible.builtin.include_role: + name: '{{ secrets_role }}' + tasks_from: '{{ tasks_from }}' + vars: + kubernetes_secret_objects: "{{ secrets_results['kubernetes_secret_objects'] }}" + vault_policies: "{{ secrets_results['vault_policies'] }}" + parsed_secrets: "{{ secrets_results['parsed_secrets'] }}" diff --git a/common/ansible/playbooks/vault/vault.yaml b/common/ansible/playbooks/vault/vault.yaml index 64711e47..b0da9405 100644 --- a/common/ansible/playbooks/vault/vault.yaml +++ b/common/ansible/playbooks/vault/vault.yaml @@ -4,4 +4,6 @@ connection: local gather_facts: false roles: + - find_vp_secrets + - cluster_pre_check - vault_utils diff --git a/common/ansible/plugins/module_utils/load_secrets_common.py b/common/ansible/plugins/module_utils/load_secrets_common.py index 1652a287..b4ebc816 100644 --- a/common/ansible/plugins/module_utils/load_secrets_common.py +++ b/common/ansible/plugins/module_utils/load_secrets_common.py @@ -102,3 +102,23 @@ def get_ini_value(inifile, inisection, inikey): config = configparser.ConfigParser() config.read(inifile) return config.get(inisection, inikey, fallback=None) + + +def stringify_dict(input_dict): + """ + Return a dict whose keys and values are all co-erced to strings, for creating labels and annotations in the + python Kubernetes module + + Parameters: + input_dict(dict): A dictionary of keys and values + + Returns: + + obj: The same dict in the same order but with the keys coerced to str + """ + output_dict = {} + + for key, value in input_dict.items(): + output_dict[str(key)] = str(value) + + return output_dict diff --git a/common/ansible/plugins/module_utils/parse_secrets_v2.py b/common/ansible/plugins/module_utils/parse_secrets_v2.py new file mode 100644 index 00000000..512f75ef --- /dev/null +++ b/common/ansible/plugins/module_utils/parse_secrets_v2.py @@ -0,0 +1,527 @@ +# Copyright 2022, 2023 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. + +""" +Module that implements V2 of the values-secret.yaml spec +""" + +import base64 +import getpass +import os + +from ansible.module_utils.load_secrets_common import ( + find_dupes, + get_ini_value, + get_version, + stringify_dict, +) + +default_vp_vault_policies = { + "validatedPatternDefaultPolicy": ( + "length=20\n" + 'rule "charset" { charset = "abcdefghijklmnopqrstuvwxyz" min-chars = 1 }\n' + 'rule "charset" { charset = "ABCDEFGHIJKLMNOPQRSTUVWXYZ" min-chars = 1 }\n' + 'rule "charset" { charset = "0123456789" min-chars = 1 }\n' + 'rule "charset" { charset = "!@#%^&*" min-chars = 1 }\n' + ) +} + +secret_store_namespace = "validated-patterns-secrets" + + +class ParseSecretsV2: + def __init__(self, module, syaml, secrets_backing_store): + self.module = module + self.syaml = syaml + self.secrets_backing_store = str(secrets_backing_store) + self.secret_store_namespace = None + self.parsed_secrets = {} + self.kubernetes_secret_objects = [] + self.vault_policies = {} + + def _get_backingstore(self): + """ + Backing store is now influenced by the caller more than the file. Setting + Return the backingStore: of the parsed yaml object. In most cases the file + key was not set anyway - since vault was the only supported option. Since + we are introducing new options now, this method of defining behavior is + deprecated, but if the file key is included it must match the option defined + by values-global in the pattern, or there is an error. The default remains + 'vault' if the key is unspecified. + + Returns: + ret(str): The value of the top-level 'backingStore:' key + """ + file_backing_store = str(self.syaml.get("backingStore", "unset")) + + if file_backing_store == "unset": + pass + else: + if file_backing_store != self.secrets_backing_store: + self.module.fail_json( + f"Secrets file specifies '{file_backing_store}' backend but pattern config " + f"specifies '{self.secrets_backing_store}'." + ) + + return self.secrets_backing_store + + def _get_vault_policies(self, enable_default_vp_policies=True): + # We start off with the hard-coded default VP policy and add the user-defined ones + if enable_default_vp_policies: + policies = default_vp_vault_policies.copy() + else: + policies = {} + + # This is useful for embedded newlines, which occur with YAML + # flow-type scalars (|, |- for example) + for name, policy in self.syaml.get("vaultPolicies", {}).items(): + policies[name] = self._sanitize_yaml_value(policy) + + return policies + + def _get_secrets(self): + return self.syaml.get("secrets", {}) + + def _get_field_on_missing_value(self, f): + # By default if 'onMissingValue' is missing we assume we need to + # error out whenever the value is missing + return f.get("onMissingValue", "error") + + def _get_field_value(self, f): + return f.get("value", None) + + def _get_field_path(self, f): + return f.get("path", None) + + def _get_field_ini_file(self, f): + return f.get("ini_file", None) + + def _get_field_annotations(self, f): + return f.get("annotations", {}) + + def _get_field_labels(self, f): + return f.get("labels", {}) + + def _get_field_kind(self, f): + # value: null will be interpreted with None, so let's just + # check for the existence of the field, as we use 'value: null' to say + # "we want a value/secret and not a file path" + found = [] + for i in ["value", "path", "ini_file"]: + if i in f: + found.append(i) + + if len(found) > 1: # you can only have one of value, path and ini_file + self.module.fail_json( + f"Both '{found[0]}' and '{found[1]}' cannot be used " + f"in field {f['name']}" + ) + + if len(found) == 0: + return "" + return found[0] + + def _get_field_prompt(self, f): + return f.get("prompt", None) + + def _get_field_base64(self, f): + return bool(f.get("base64", False)) + + def _get_field_override(self, f): + return bool(f.get("override", False)) + + def _get_secret_store_namespace(self): + return str(self.syaml.get("secretStoreNamespace", secret_store_namespace)) + + def _get_vault_prefixes(self, s): + return list(s.get("vaultPrefixes", ["hub"])) + + def _get_default_labels(self): + return self.syaml.get("defaultLabels", {}) + + def _get_default_annotations(self): + return self.syaml.get("defaultAnnotations", {}) + + def _append_kubernetes_secret(self, secret_obj): + self.kubernetes_secret_objects.append(secret_obj) + + def _sanitize_yaml_value(self, value): + # This is useful for embedded newlines, which occur with YAML + # flow-type scalars (|, |- for example) + if value is not None: + sanitized_value = bytes(value, "utf-8").decode("unicode_escape") + else: + sanitized_value = None + + return sanitized_value + + def _create_k8s_secret(self, sname, secret_type, namespace, labels, annotations): + return { + "type": secret_type, + "kind": "Secret", + "apiVersion": "v1", + "metadata": { + "name": sname, + "namespace": namespace, + "annotations": annotations, + "labels": labels, + }, + "stringData": {}, + } + + # This does what inject_secrets used to (mostly) + def parse(self): + self.sanitize_values() + self.vault_policies = self._get_vault_policies() + self.secret_store_namespace = self._get_secret_store_namespace() + backing_store = self._get_backingstore() + secrets = self._get_secrets() + + total_secrets = 0 # Counter for all the secrets uploaded + for s in secrets: + total_secrets += 1 + counter = 0 # This counter is to use kv put on first secret and kv patch on latter + sname = s.get("name") + fields = s.get("fields", []) + vault_prefixes = self._get_vault_prefixes(s) + secret_type = s.get("type", "Opaque") + vault_mount = s.get("vaultMount", "secret") + target_namespaces = s.get("targetNamespaces", []) + labels = stringify_dict(s.get("labels", self._get_default_labels())) + annotations = stringify_dict( + s.get("annotations", self._get_default_annotations()) + ) + + self.parsed_secrets[sname] = { + "name": sname, + "fields": {}, + "vault_mount": vault_mount, + "vault_policies": {}, + "vault_prefixes": vault_prefixes, + "override": [], + "generate": [], + "paths": {}, + "base64": [], + "ini_file": {}, + "type": secret_type, + "target_namespaces": target_namespaces, + "labels": labels, + "annotations": annotations, + } + + for i in fields: + self._inject_field(sname, i) + counter += 1 + + if backing_store == "kubernetes": + k8s_namespaces = [self._get_secret_store_namespace()] + else: + k8s_namespaces = target_namespaces + + for tns in k8s_namespaces: + k8s_secret = self._create_k8s_secret( + sname, secret_type, tns, labels, annotations + ) + k8s_secret["stringData"] = self.parsed_secrets[sname]["fields"] + self.kubernetes_secret_objects.append(k8s_secret) + + return total_secrets + + # This function could use some rewriting and it should call a specific validation function + # for each type (value, path, ini_file) + def _validate_field(self, f): + # These fields are mandatory + try: + _ = f["name"] + except KeyError: + return (False, f"Field {f} is missing name") + + on_missing_value = self._get_field_on_missing_value(f) + if on_missing_value not in ["error", "generate", "prompt"]: + return (False, f"onMissingValue: {on_missing_value} is invalid") + + value = self._get_field_value(f) + path = self._get_field_path(f) + ini_file = self._get_field_ini_file(f) + kind = self._get_field_kind(f) + if kind == "ini_file": + # if we are using ini_file then at least ini_key needs to be defined + # ini_section defaults to 'default' when omitted + ini_key = f.get("ini_key", None) + if ini_key is None: + return ( + False, + "ini_file requires at least ini_key to be defined", + ) + + # Test if base64 is a correct boolean (defaults to False) + _ = self._get_field_base64(f) + _ = self._get_field_override(f) + + vault_policy = f.get("vaultPolicy", None) + if vault_policy is not None and vault_policy not in self._get_vault_policies(): + return ( + False, + f"Secret has vaultPolicy set to {vault_policy} but no such policy exists", + ) + + if on_missing_value in ["error"]: + if ( + (value is None or len(value) < 1) + and (path is None or len(path) < 1) + and (ini_file is None or len(ini_file) < 1) + ): + return ( + False, + "Secret has onMissingValue set to 'error' and has neither value nor path nor ini_file set", + ) + if path is not None and not os.path.isfile(os.path.expanduser(path)): + return (False, f"Field has non-existing path: {path}") + + if ini_file is not None and not os.path.isfile( + os.path.expanduser(ini_file) + ): + return (False, f"Field has non-existing ini_file: {ini_file}") + + if on_missing_value in ["prompt"]: + # When we prompt, the user needs to set one of the following: + # - value: null # prompt for a secret without a default value + # - value: 123 # prompt for a secret but use a default value + # - path: null # prompt for a file path without a default value + # - path: /tmp/ca.crt # prompt for a file path with a default value + if "value" not in f and "path" not in f: + return ( + False, + "Secret has onMissingValue set to 'prompt' but has no value nor path fields", + ) + + if "override" in f: + return ( + False, + "'override' attribute requires 'onMissingValue' to be set to 'generate'", + ) + + return (True, "") + + def _validate_secrets(self): + backing_store = self._get_backingstore() + secrets = self._get_secrets() + if len(secrets) == 0: + self.module.fail_json("No secrets found") + + names = [] + for s in secrets: + # These fields are mandatory + for i in ["name"]: + try: + _ = s[i] + except KeyError: + return (False, f"Secret {s['name']} is missing {i}") + names.append(s["name"]) + + vault_prefixes = s.get("vaultPrefixes", ["hub"]) + # This checks for the case when vaultPrefixes: is specified but empty + if vault_prefixes is None or len(vault_prefixes) == 0: + return (False, f"Secret {s['name']} has empty vaultPrefixes") + + namespaces = s.get("targetNamespaces", []) + if not isinstance(namespaces, list): + return (False, f"Secret {s['name']} targetNamespaces must be a list") + + if backing_store == "none" and namespaces == []: + return ( + False, + f"Secret {s['name']} targetNamespaces cannot be empty for secrets backend {backing_store}", + ) # noqa: E501 + + labels = s.get("labels", {}) + if not isinstance(labels, dict): + return (False, f"Secret {s['name']} labels must be a dictionary") + + annotations = s.get("annotations", {}) + if not isinstance(annotations, dict): + return (False, f"Secret {s['name']} annotations must be a dictionary") + + fields = s.get("fields", []) + if len(fields) == 0: + return (False, f"Secret {s['name']} does not have any fields") + + field_names = [] + for i in fields: + (ret, msg) = self._validate_field(i) + if not ret: + return (False, msg) + field_names.append(i["name"]) + field_dupes = find_dupes(field_names) + if len(field_dupes) > 0: + return (False, f"You cannot have duplicate field names: {field_dupes}") + + dupes = find_dupes(names) + if len(dupes) > 0: + return (False, f"You cannot have duplicate secret names: {dupes}") + return (True, "") + + def sanitize_values(self): + """ + Sanitizes the secrets YAML object version 2.0 + + Parameters: + + Returns: + Nothing: Updates self.syaml(obj) if needed + """ + v = get_version(self.syaml) + if v not in ["2.0"]: + self.module.fail_json(f"Version is not 2.0: {v}") + + backing_store = self._get_backingstore() + if backing_store not in [ + "kubernetes", + "vault", + "none", + ]: # we currently only support vault + self.module.fail_json( + f"Currently only the 'vault', 'kubernetes' and 'none' backingStores are supported: {backing_store}" + ) + + (ret, msg) = self._validate_secrets() + if not ret: + self.module.fail_json(msg) + + def _get_secret_value(self, name, field): + on_missing_value = self._get_field_on_missing_value(field) + # We cannot use match + case as RHEL8 has python 3.9 (it needs 3.10) + # We checked for errors in _validate_secrets() already + if on_missing_value == "error": + return self._sanitize_yaml_value(field.get("value")) + elif on_missing_value == "prompt": + prompt = self._get_field_prompt(field) + if prompt is None: + prompt = f"Type secret for {name}/{field['name']}: " + value = self._get_field_value(field) + if value is not None: + prompt += f" [{value}]" + prompt += ": " + return getpass.getpass(prompt) + return None + + def _get_file_path(self, name, field): + on_missing_value = self._get_field_on_missing_value(field) + if on_missing_value == "error": + return os.path.expanduser(field.get("path")) + elif on_missing_value == "prompt": + prompt = self._get_field_prompt(field) + path = self._get_field_path(field) + if path is None: + path = "" + + if prompt is None: + text = f"Type path for file {name}/{field['name']} [{path}]: " + else: + text = f"{prompt} [{path}]: " + + newpath = getpass.getpass(text) + if newpath == "": # Set the default if no string was entered + newpath = path + + if os.path.isfile(os.path.expanduser(newpath)): + return newpath + self.module.fail_json(f"File {newpath} not found, exiting") + + self.module.fail_json("File with wrong onMissingValue") + + def _inject_field(self, secret_name, f): + on_missing_value = self._get_field_on_missing_value(f) + override = self._get_field_override(f) + kind = self._get_field_kind(f) + b64 = self._get_field_base64(f) + + if kind in ["value", ""]: + if on_missing_value == "generate": + self.parsed_secrets[secret_name]["generate"].append(f["name"]) + if self._get_backingstore() != "vault": + self.module.fail_json( + "You cannot have onMissingValue set to 'generate' unless using vault backingstore " + f"for secret {secret_name} field {f['name']}" + ) + else: + if kind in ["path", "ini_file"]: + self.module.fail_json( + "You cannot have onMissingValue set to 'generate' with a path or ini_file" + f" for secret {secret_name} field {f['name']}" + ) + + vault_policy = f.get("vaultPolicy", "validatedPatternDefaultPolicy") + + if override: + self.parsed_secrets[secret_name]["override"].append(f["name"]) + + if b64: + self.parsed_secrets[secret_name]["base64"].append(f["name"]) + + self.parsed_secrets[secret_name]["fields"][f["name"]] = None + self.parsed_secrets[secret_name]["vault_policies"][ + f["name"] + ] = vault_policy + + return + + # If we're not generating the secret inside the vault directly we either read it from the file ("error") + # or we are prompting the user for it + secret = self._get_secret_value(secret_name, f) + if b64: + secret = base64.b64encode(secret.encode()).decode("utf-8") + self.parsed_secrets[secret_name]["base64"].append(f["name"]) + + self.parsed_secrets[secret_name]["fields"][f["name"]] = secret + + elif kind == "path": # path. we upload files + path = self._get_file_path(secret_name, f) + self.parsed_secrets[secret_name]["paths"][f["name"]] = path + + binfile = False + + # Default to UTF-8 + try: + secret = open(path, encoding="utf-8").read() + except UnicodeDecodeError: + secret = open(path, "rb").read() + binfile = True + + if b64: + self.parsed_secrets[secret_name]["base64"].append(f["name"]) + if binfile: + secret = base64.b64encode(bytes(secret)).decode("utf-8") + else: + secret = base64.b64encode(secret.encode()).decode("utf-8") + + self.parsed_secrets[secret_name]["fields"][f["name"]] = secret + elif kind == "ini_file": # ini_file. we parse an ini_file + ini_file = os.path.expanduser(f.get("ini_file")) + ini_section = f.get("ini_section", "default") + ini_key = f.get("ini_key") + secret = get_ini_value(ini_file, ini_section, ini_key) + if b64: + self.parsed_secrets[secret_name]["base64"].append(f["name"]) + secret = base64.b64encode(secret.encode()).decode("utf-8") + + self.parsed_secrets[secret_name]["ini_file"][f["name"]] = { + "ini_file": ini_file, + "ini_section": ini_section, + "ini_key": ini_key, + } + self.parsed_secrets[secret_name]["fields"][f["name"]] = secret + + return diff --git a/common/ansible/plugins/modules/parse_secrets_info.py b/common/ansible/plugins/modules/parse_secrets_info.py new file mode 100644 index 00000000..b962271a --- /dev/null +++ b/common/ansible/plugins/modules/parse_secrets_info.py @@ -0,0 +1,149 @@ +# Copyright 2022,2023 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. + +""" +Ansible plugin module that loads secrets from a yaml file and pushes them +inside the HashiCorp Vault in an OCP cluster. The values-secrets.yaml file is +expected to be in the following format: +--- +# version is optional. When not specified it is assumed it is 1.0 +version: 1.0 + +# These secrets will be pushed in the vault at secret/hub/test The vault will +# have secret/hub/test with secret1 and secret2 as keys with their associated +# values (secrets) +secrets: + test: + secret1: foo + secret2: bar + +# This will create the vault key secret/hub/testfoo which will have two +# properties 'b64content' and 'content' which will be the base64-encoded +# content and the normal content respectively +files: + testfoo: ~/ca.crt + +# These secrets will be pushed in the vault at secret/region1/test The vault will +# have secret/region1/test with secret1 and secret2 as keys with their associated +# values (secrets) +secrets.region1: + test: + secret1: foo1 + secret2: bar1 + +# This will create the vault key secret/region2/testbar which will have two +# properties 'b64content' and 'content' which will be the base64-encoded +# content and the normal content respectively +files.region2: + testbar: ~/ca.crt +""" + +import yaml +from ansible.module_utils.basic import AnsibleModule +from ansible.module_utils.parse_secrets_v2 import ParseSecretsV2 + +ANSIBLE_METADATA = { + "metadata_version": "1.2", + "status": ["preview"], + "supported_by": "community", +} + +DOCUMENTATION = """ +--- +module: parse_secrets_info +short_description: Parses a Validated Patterns Secrets file for later loading +version_added: "2.50" +author: "Martin Jackson" +description: + - Takes a values-secret.yaml file, parses and returns values for secrets loading. The goal here is to do all the + work of reading and interpreting the file and resolving the content pointers (that is, creating content where it + is given) such that that content is then available for secrets vaults to load. It does not attempt to load the + content or interpret the content beyond the conventions of the file format. (So, it knows how to retrieve + ini-keys, about paths, and about base64 but leaves interaction with backends to backend-specific code. +options: + values_secrets_plaintext: + description: + - The unencrypted content of the values-secrets file + required: true + type: str + secrets_backing_store: + description: + - The secrets backing store that will be used for parsed secrets (i.e. vault, kubernetes, none) + required: false + default: vault + type: str +""" + +RETURN = """ +""" + +EXAMPLES = """ +- name: Parse secrets file into objects - backingstore defaults to vault + parse_secrets_info: + values_secrets_plaintext: '{{ }}' + register: secrets_info + +- name: Parse secrets file into data structures + parse_secrets_info: + values_secrets_plaintext: '{{ }}' + secrets_backing_store: 'kubernetes' + register: secrets_info + +- name: Parse secrets file into data structures + parse_secrets_info: + values_secrets_plaintext: '{{ }}' + secrets_backing_store: 'none' + register: secrets_info +""" + + +def run(module): + """Main ansible module entry point""" + results = dict(changed=False) + + args = module.params + values_secrets_plaintext = args.get("values_secrets_plaintext", "") + secrets_backing_store = args.get("secrets_backing_store", "vault") + + syaml = yaml.safe_load(values_secrets_plaintext) + + if syaml is None: + syaml = {} + + parsed_secret_obj = ParseSecretsV2(module, syaml, secrets_backing_store) + parsed_secret_obj.parse() + + results["failed"] = False + results["changed"] = False + + results["vault_policies"] = parsed_secret_obj.vault_policies + results["parsed_secrets"] = parsed_secret_obj.parsed_secrets + results["kubernetes_secret_objects"] = parsed_secret_obj.kubernetes_secret_objects + results["secret_store_namespace"] = parsed_secret_obj.secret_store_namespace + + module.exit_json(**results) + + +def main(): + """Main entry point where the AnsibleModule class is instantiated""" + module = AnsibleModule( + argument_spec=yaml.safe_load(DOCUMENTATION)["options"], + supports_check_mode=True, + ) + run(module) + + +if __name__ == "__main__": + main() diff --git a/common/ansible/plugins/modules/vault_load_parsed_secrets.py b/common/ansible/plugins/modules/vault_load_parsed_secrets.py new file mode 100644 index 00000000..cfcf9732 --- /dev/null +++ b/common/ansible/plugins/modules/vault_load_parsed_secrets.py @@ -0,0 +1,302 @@ +# Copyright 2022 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. + +""" +Ansible plugin module that loads secrets and policies once parsed and pushes them +into a HashiCorp Vault in an OCP cluster. The values-secrets.yaml file is +expected to be in the following format: +--- +# version is optional. When not specified it is assumed it is 2.0 +version: 2.0 + +""" + +import os +import time + +import yaml +from ansible.module_utils.basic import AnsibleModule + +ANSIBLE_METADATA = { + "metadata_version": "1.1", + "status": ["preview"], + "supported_by": "community", +} + +DOCUMENTATION = """ +--- +module: vault_load_parsed_secrets +short_description: Loads secrets into the HashiCorp Vault +version_added: "2.50" +author: "Martin Jackson" +description: + - Takes parsed secrets objects and vault policies (as delivered by parse_secrets_info) and runs the commands to + load them into a vault instance. The relevent metadata will exist in the parsed secrets object. Returns count + of secrets injected. +options: + parsed_secrets: + description: + - A structure containing the secrets, fields, and their metadata + required: true + type: dict + vault_policies: + description: + - Vault policies to inject into the instance. + required: true + type: dict + namespace: + description: + - Namespace where the vault is running + required: false + type: str + default: vault + pod: + description: + - Name of the vault pod to use to inject secrets + required: false + type: str + default: vault-0 +""" + +RETURN = """ +""" + +EXAMPLES = """ +- name: Loads secrets file into the vault of a cluster + vault_load_parsed_secrets: + parsed_secrets: "{{ parsed_secrets_structure_from_parse_secrets_info }}" + vault_policies: "{{ parsed_vault_policies_structure_from_parse_secrets_info }}" +""" + + +class VaultSecretLoader: + def __init__( + self, + module, + parsed_secrets, + vault_policies, + namespace, + pod, + ): + self.module = module + self.parsed_secrets = parsed_secrets + self.vault_policies = vault_policies + self.namespace = namespace + self.pod = pod + + def _run_command(self, command, attempts=1, sleep=3, checkrc=True): + """ + Runs a command on the host ansible is running on. A failing command + will raise an exception in this function directly (due to check=True) + + Parameters: + command(str): The command to be run. + attempts(int): Number of times to retry in case of Error (defaults to 1) + sleep(int): Number of seconds to wait in between retry attempts (defaults to 3s) + + Returns: + ret(subprocess.CompletedProcess): The return value from run() + """ + for attempt in range(attempts): + ret = self.module.run_command( + command, + check_rc=checkrc, + use_unsafe_shell=True, + environ_update=os.environ.copy(), + ) + if ret[0] == 0: + return ret + if attempt >= attempts - 1: + return ret + time.sleep(sleep) + + def _vault_secret_attr_exists(self, mount, prefix, secret_name, attribute): + cmd = ( + f"oc exec -n {self.namespace} {self.pod} -i -- sh -c " + f'"vault kv get -mount={mount} -field={attribute} {prefix}/{secret_name}"' + ) + # we ignore stdout and stderr + (ret, _, _) = self._run_command(cmd, attempts=1, checkrc=False) + if ret == 0: + return True + + return False + + def load_vault(self): + injected_secret_count = 0 + + self.inject_vault_policies() + + for secret_name, secret in self.parsed_secrets.items(): + self.inject_secret(secret_name, secret) + injected_secret_count += 1 + + return injected_secret_count + + def inject_field( + self, + secret_name, + soverride, + sbase64, + sgenerate, + spaths, + svault_policies, + fieldname, + fieldvalue, + mount, + vault_prefixes, + first=False, + ): + # Special cases: + # generate w|wo override + # path (w|wo b64) + # + # inifile secrets will be resolved by parser + # values (including base64'd ones) will be resolved by parser + # And we just ignore k8s or other fields + + override = True if fieldname in soverride else False + b64 = True if fieldname in sbase64 else False + generate = True if fieldname in sgenerate else False + path = spaths.get(fieldname, False) + prefixes = vault_prefixes + verb = "put" if first else "patch" + policy = svault_policies.get(fieldname, False) + + # "generate" secrets are created with policies and may be overridden or not + if generate: + gen_cmd = ( + f"vault read -field=password sys/policies/password/{policy}/generate" + ) + if b64: + gen_cmd += " | base64 --wrap=0" + for prefix in prefixes: + # if the override field is False and the secret attribute exists at the prefix then we just + # skip, as we do not want to overwrite the existing secret + if not override and self._vault_secret_attr_exists( + mount, prefix, secret_name, fieldname + ): + continue + cmd = ( + f"oc exec -n {self.namespace} {self.pod} -i -- sh -c " + f'"{gen_cmd} | vault kv {verb} -mount={mount} {prefix}/{secret_name} {fieldname}=-"' + ) + self._run_command(cmd, attempts=3) + return + + if path: + for prefix in prefixes: + if b64: + b64_cmd = "| base64 --wrap=0" + else: + b64_cmd = "" + cmd = ( + f"cat '{path}' | oc exec -n {self.namespace} {self.pod} -i -- sh -c " + f"'cat - {b64_cmd}> /tmp/vcontent'; " + f"oc exec -n {self.namespace} {self.pod} -i -- sh -c '" + f"vault kv {verb} -mount={mount} {prefix}/{secret_name} {fieldname}=@/tmp/vcontent; " + f"rm /tmp/vcontent'" + ) + self._run_command(cmd, attempts=3) + return + + for prefix in prefixes: + cmd = ( + f"oc exec -n {self.namespace} {self.pod} -i -- sh -c " + f"\"vault kv {verb} -mount={mount} {prefix}/{secret_name} {fieldname}='{fieldvalue}'\"" + ) + self._run_command(cmd, attempts=3) + return + + def inject_secret(self, secret_name, secret): + mount = secret.get("vault_mount", "secret") + vault_prefixes = secret.get("vault_prefixes", ["hub"]) + + counter = 0 + # In this structure, each field will have one value + for fname, fvalue in secret.get("fields").items(): + self.inject_field( + secret_name=secret_name, + soverride=secret["override"], + sbase64=secret["base64"], + sgenerate=secret["generate"], + spaths=secret["paths"], + svault_policies=secret["vault_policies"], + fieldname=fname, + fieldvalue=fvalue, + mount=mount, + vault_prefixes=vault_prefixes, + first=counter == 0, + ) + counter += 1 + return + + def inject_vault_policies(self): + for name, policy in self.vault_policies.items(): + cmd = ( + f"echo '{policy}' | oc exec -n {self.namespace} {self.pod} -i -- sh -c " + f"'cat - > /tmp/{name}.hcl';" + f"oc exec -n {self.namespace} {self.pod} -i -- sh -c 'vault write sys/policies/password/{name} " + f" policy=@/tmp/{name}.hcl'" + ) + self._run_command(cmd, attempts=3) + + +def run(module): + """Main ansible module entry point""" + results = dict(changed=False) + + args = module.params + + vault_policies = args.get("vault_policies", {}) + parsed_secrets = args.get("parsed_secrets", {}) + namespace = args.get("namespace", "vault") + pod = args.get("pod", "vault-0") + + if vault_policies == {}: + results["failed"] = True + module.fail_json("Must pass vault_policies") + + if parsed_secrets == {}: + results["failed"] = True + module.fail_json("Must pass parsed_secrets") + + loader = VaultSecretLoader( + module, + parsed_secrets, + vault_policies, + namespace, + pod, + ) + + nr_secrets = loader.load_vault() + + results["failed"] = False + results["changed"] = True + results["msg"] = f"{nr_secrets} secrets injected" + module.exit_json(**results) + + +def main(): + """Main entry point where the AnsibleModule class is instantiated""" + module = AnsibleModule( + argument_spec=yaml.safe_load(DOCUMENTATION)["options"], + supports_check_mode=False, + ) + run(module) + + +if __name__ == "__main__": + main() diff --git a/common/ansible/roles/cluster_pre_check/defaults/main.yml b/common/ansible/roles/cluster_pre_check/defaults/main.yml new file mode 100644 index 00000000..fd6cdd5c --- /dev/null +++ b/common/ansible/roles/cluster_pre_check/defaults/main.yml @@ -0,0 +1,3 @@ +--- +kubeconfig: "{{ lookup('env', 'KUBECONFIG') }}" +kubeconfig_backup: "{{ lookup('env', 'HOME') }}/.kube/config" diff --git a/common/ansible/roles/vault_utils/tasks/pre_check.yaml b/common/ansible/roles/cluster_pre_check/tasks/main.yml similarity index 100% rename from common/ansible/roles/vault_utils/tasks/pre_check.yaml rename to common/ansible/roles/cluster_pre_check/tasks/main.yml diff --git a/common/ansible/roles/find_vp_secrets/tasks/main.yml b/common/ansible/roles/find_vp_secrets/tasks/main.yml new file mode 100644 index 00000000..ce847a01 --- /dev/null +++ b/common/ansible/roles/find_vp_secrets/tasks/main.yml @@ -0,0 +1,87 @@ +--- +# Once V1 support is dropped we can remove the whole secret_template support +- name: Set secret_template fact + no_log: "{{ override_no_log | default(true) }}" + ansible.builtin.set_fact: + secret_template: "{{ pattern_dir }}/values-secret.yaml.template" + +- name: Is a VALUES_SECRET env variable set? + ansible.builtin.set_fact: + custom_env_values_secret: "{{ lookup('ansible.builtin.env', 'VALUES_SECRET') }}" + +- name: Check if VALUES_SECRET file exists + ansible.builtin.stat: + path: "{{ custom_env_values_secret }}" + register: custom_file_values_secret + when: custom_env_values_secret | default('') | length > 0 + +- name: Set values-secret yaml file to {{ custom_file_values_secret.stat.path }} + ansible.builtin.set_fact: + found_file: "{{ custom_file_values_secret.stat.path }}" + when: + - custom_env_values_secret | default('') | length > 0 + - custom_file_values_secret.stat.exists + +# FIXME(bandini): Eventually around end of 2023(?) we should drop +# ~/values-secret-{{ pattern_name }}.yaml and ~/values-secret.yaml +- name: Find first existing values-secret yaml file + ansible.builtin.set_fact: + found_file: "{{ lookup('ansible.builtin.first_found', findme) }}" + vars: + findme: + - "~/.config/hybrid-cloud-patterns/values-secret-{{ pattern_name }}.yaml" + - "~/.config/validated-patterns/values-secret-{{ pattern_name }}.yaml" + - "~/values-secret-{{ pattern_name }}.yaml" + - "~/values-secret.yaml" + - "{{ pattern_dir }}/values-secret.yaml.template" + when: custom_env_values_secret | default('') | length == 0 + +- name: Is found values secret file encrypted + no_log: "{{ override_no_log | default(true) }}" + ansible.builtin.shell: | + set -o pipefail + head -1 "{{ found_file }}" | grep -q \$ANSIBLE_VAULT + changed_when: false + register: encrypted + failed_when: (encrypted.rc not in [0, 1]) + +# When HOME is set we replace it with '~' in this debug message +# because when run from inside the container the HOME is /pattern-home +# which is confusing for users +- name: Is found values secret file encrypted + ansible.builtin.debug: + msg: "Using {{ (lookup('env', 'HOME') | length > 0) | ternary(found_file | regex_replace('^' + lookup('env', 'HOME'), '~'), found_file) }} to parse secrets" + +- name: Set encryption bool fact + no_log: "{{ override_no_log | default(true) }}" + ansible.builtin.set_fact: + is_encrypted: "{{ encrypted.rc == 0 | bool }}" + +- name: Get password for "{{ found_file }}" + ansible.builtin.pause: + prompt: "Input the password for {{ found_file }}" + echo: false + when: is_encrypted + register: vault_pass + +- name: Get decrypted content if {{ found_file }} was encrypted + no_log: "{{ override_no_log | default(true) }}" + ansible.builtin.shell: + ansible-vault view --vault-password-file <(cat <<<"{{ vault_pass.user_input }}") "{{ found_file }}" + register: values_secret_plaintext + when: is_encrypted + changed_when: false + +- name: Normalize secrets format (un-encrypted) + no_log: '{{ override_no_log | default(true) }}' + ansible.builtin.set_fact: + values_secrets_data: "{{ lookup('file', found_file) | from_yaml }}" + when: not is_encrypted + changed_when: false + +- name: Normalize secrets format (encrypted) + no_log: '{{ override_no_log | default(true) }}' + ansible.builtin.set_fact: + values_secrets_data: "{{ values_secret_plaintext.stdout | from_yaml }}" + when: is_encrypted + changed_when: false diff --git a/common/ansible/roles/k8s_secret_utils/defaults/main.yml b/common/ansible/roles/k8s_secret_utils/defaults/main.yml new file mode 100644 index 00000000..7ebda207 --- /dev/null +++ b/common/ansible/roles/k8s_secret_utils/defaults/main.yml @@ -0,0 +1,2 @@ +--- +secrets_ns: 'validated-patterns-secrets' diff --git a/common/ansible/roles/k8s_secret_utils/tasks/inject_k8s_secret.yml b/common/ansible/roles/k8s_secret_utils/tasks/inject_k8s_secret.yml new file mode 100644 index 00000000..283fb6a2 --- /dev/null +++ b/common/ansible/roles/k8s_secret_utils/tasks/inject_k8s_secret.yml @@ -0,0 +1,15 @@ +--- +- name: Check for secrets namespace + no_log: false + kubernetes.core.k8s_info: + kind: Namespace + name: "{{ item['metadata']['namespace'] }}" + register: secrets_ns_rc + until: secrets_ns_rc.resources | length > 0 + retries: 20 + delay: 45 + +- name: Inject k8s secret + no_log: '{{ override_no_log | default(True) }}' + kubernetes.core.k8s: + definition: '{{ item }}' diff --git a/common/ansible/roles/k8s_secret_utils/tasks/inject_k8s_secrets.yml b/common/ansible/roles/k8s_secret_utils/tasks/inject_k8s_secrets.yml new file mode 100644 index 00000000..a2299734 --- /dev/null +++ b/common/ansible/roles/k8s_secret_utils/tasks/inject_k8s_secrets.yml @@ -0,0 +1,5 @@ +--- +- name: Inject secrets + no_log: '{{ override_no_log | default(True) }}' + ansible.builtin.include_tasks: inject_k8s_secret.yml + loop: '{{ kubernetes_secret_objects }}' diff --git a/common/ansible/roles/k8s_secret_utils/tasks/main.yml b/common/ansible/roles/k8s_secret_utils/tasks/main.yml new file mode 100644 index 00000000..d72de7ae --- /dev/null +++ b/common/ansible/roles/k8s_secret_utils/tasks/main.yml @@ -0,0 +1,6 @@ +--- +- name: Parse and extract k8s secrets from values-secret file + ansible.builtin.include_tasks: parse_secrets.yml + +- name: Inject k8s secrets + ansible.builtin.include_tasks: inject_k8s_secrets.yml diff --git a/common/ansible/roles/k8s_secret_utils/tasks/parse_secrets.yml b/common/ansible/roles/k8s_secret_utils/tasks/parse_secrets.yml new file mode 100644 index 00000000..b1755cc2 --- /dev/null +++ b/common/ansible/roles/k8s_secret_utils/tasks/parse_secrets.yml @@ -0,0 +1,12 @@ +--- +- name: Parse secrets data + # no_log: '{{ override_no_log | default(true) }}' + parse_secrets_info: + values_secrets_plaintext: "{{ values_secrets_data }}" + secrets_backing_store: "{{ secrets_backing_store }}" + register: secrets_results + +- name: Return kubernetes objects + no_log: '{{ override_no_log | default(true) }}' + ansible.builtin.set_fact: + kubernetes_secret_objects: "{{ secrets_results['kubernetes_secret_objects'] }}" diff --git a/common/ansible/roles/vault_utils/tasks/push_parsed_secrets.yaml b/common/ansible/roles/vault_utils/tasks/push_parsed_secrets.yaml new file mode 100644 index 00000000..cbca15e0 --- /dev/null +++ b/common/ansible/roles/vault_utils/tasks/push_parsed_secrets.yaml @@ -0,0 +1,43 @@ +--- +- name: "Do pre-checks for Vault" + ansible.builtin.include_role: + name: vault_utils + tasks_from: vault_status + +# Unfortunately we cannot loop vault_status and just check if the vault is unsealed +# https://github.com/ansible/proposals/issues/136 +# So here we keep running the 'vault status' command until sealed is set to false +- name: If the vault is still sealed we need to retry + kubernetes.core.k8s_exec: + namespace: "{{ vault_ns }}" + pod: "{{ vault_pod }}" + command: vault status -format=json + register: vault_status_json + until: "'stdout' in vault_status_json and (not (vault_status_json.stdout | from_json)['sealed'] | bool)" + retries: 20 + delay: 45 + failed_when: "'stdout_lines' not in vault_status_json" + +# This step is not really needed when running make vault-init + load-secrets as +# everything is sequential +# It is needed when the vault is unsealed/configured inside the cluster and load-secrets +# gets run *while* the cronjob configures the vault. I.e. it might be half configured and return +# errors +- name: Make sure that the vault auth policy exists + kubernetes.core.k8s_exec: + namespace: "{{ vault_ns }}" + pod: "{{ vault_pod }}" + command: + sh -c "vault list auth/{{ vault_hub }}/role | grep '{{ vault_hub }}-role'" + register: vault_role_cmd + until: + - vault_role_cmd.rc is defined + - vault_role_cmd.rc == 0 + retries: 20 + delay: 45 + changed_when: false + +- name: Load parsed secrets into cluster vault + vault_load_parsed_secrets: + vault_policies: "{{ vault_policies }}" + parsed_secrets: "{{ parsed_secrets }}" diff --git a/common/ansible/roles/vault_utils/tasks/push_secrets.yaml b/common/ansible/roles/vault_utils/tasks/push_secrets.yaml index 31d2878b..7954dc47 100644 --- a/common/ansible/roles/vault_utils/tasks/push_secrets.yaml +++ b/common/ansible/roles/vault_utils/tasks/push_secrets.yaml @@ -1,6 +1,4 @@ --- -- name: Vault pre checks - ansible.builtin.include_tasks: pre_check.yaml - name: Vault status check ansible.builtin.include_tasks: vault_status.yaml diff --git a/common/ansible/roles/vault_utils/tasks/vault_init.yaml b/common/ansible/roles/vault_utils/tasks/vault_init.yaml index 16ce73df..38e1e911 100644 --- a/common/ansible/roles/vault_utils/tasks/vault_init.yaml +++ b/common/ansible/roles/vault_utils/tasks/vault_init.yaml @@ -1,6 +1,4 @@ --- -- name: Vault pre checks - ansible.builtin.include_tasks: pre_check.yaml - name: Vault status check ansible.builtin.include_tasks: vault_status.yaml diff --git a/common/ansible/roles/vault_utils/tasks/vault_secrets_init.yaml b/common/ansible/roles/vault_utils/tasks/vault_secrets_init.yaml index 7e0741aa..35327d58 100644 --- a/common/ansible/roles/vault_utils/tasks/vault_secrets_init.yaml +++ b/common/ansible/roles/vault_utils/tasks/vault_secrets_init.yaml @@ -1,7 +1,4 @@ --- -- name: Vault pre checks - ansible.builtin.include_tasks: pre_check.yaml - - name: Is secrets backend already enabled kubernetes.core.k8s_exec: namespace: "{{ vault_ns }}" diff --git a/common/ansible/roles/vault_utils/tasks/vault_spokes_init.yaml b/common/ansible/roles/vault_utils/tasks/vault_spokes_init.yaml index d4310e7f..e930252a 100644 --- a/common/ansible/roles/vault_utils/tasks/vault_spokes_init.yaml +++ b/common/ansible/roles/vault_utils/tasks/vault_spokes_init.yaml @@ -1,7 +1,4 @@ --- -- name: Vault pre checks - ansible.builtin.include_tasks: pre_check.yaml - - name: Find managed clusters kubernetes.core.k8s_info: kind: ManagedCluster diff --git a/common/ansible/roles/vault_utils/tasks/vault_unseal.yaml b/common/ansible/roles/vault_utils/tasks/vault_unseal.yaml index 862f19d8..43232ac7 100644 --- a/common/ansible/roles/vault_utils/tasks/vault_unseal.yaml +++ b/common/ansible/roles/vault_utils/tasks/vault_unseal.yaml @@ -1,6 +1,4 @@ --- -- name: Vault pre checks - ansible.builtin.include_tasks: pre_check.yaml - name: Vault status check ansible.builtin.include_tasks: vault_status.yaml diff --git a/common/ansible/roles/vault_utils/values-secrets.v2.schema.json b/common/ansible/roles/vault_utils/values-secrets.v2.schema.json index c9723d6f..c8b5c020 100644 --- a/common/ansible/roles/vault_utils/values-secrets.v2.schema.json +++ b/common/ansible/roles/vault_utils/values-secrets.v2.schema.json @@ -10,7 +10,7 @@ "title": "Hybrid Cloud Patterns - values-secret.yaml files schema V2", "description": "This schema defines the values-secret.yaml file as used by [Validated Patterns](https://hybrid-cloud-patterns.io)", "type": "object", - "examples": [ + "examples": [ { "version": "2.0", "backingStore": "vault", @@ -105,6 +105,19 @@ "$ref": "#/definitions/VaultPolicies", "description": "A dictionary of {name}:{policy} of custom vault password policies" }, + "secretStoreNamespace": { + "type": "string", + "description": "Namespace to store secrets in for kubernetes loader", + "default": "validated-patterns-secrets" + }, + "defaultLabels": { + "type": "object", + "description": "Default labels to add to secret objects for kubernetes loader" + }, + "defaultAnnotations": { + "type": "object", + "description": "Default labels to add to secret objects for kubernetes loader" + }, "secrets": { "$ref": "#/definitions/Secrets", "description": "The list of actual secrets to be uploaded in the vault" @@ -166,6 +179,23 @@ }, "default": [ "hub" ] }, + "targetNamespaces": { + "type": "array", + "description": "The namespace(s) that the secret will be injected into, ignored by configs using ESO", + "items": { + "type": "string", + "minItems": 1, + "uniqueItems": true + } + }, + "annotations": { + "type": "object", + "description": "Annotations to add to the kubernetes secret object, which override defaults" + }, + "labels": { + "type": "object", + "description": "Labels to add to the kubernetes secret object, which override defaults" + }, "fields": { "type": "array", "description": "This is the list of actual secret material that will be placed in a vault key's attributes", diff --git a/common/ansible/tests/unit/test_parse_secrets.py b/common/ansible/tests/unit/test_parse_secrets.py new file mode 100644 index 00000000..0cfef1b6 --- /dev/null +++ b/common/ansible/tests/unit/test_parse_secrets.py @@ -0,0 +1,981 @@ +# Copyright 2022, 2023 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. + +""" +Simple module to test parse_secret_info +""" + +import base64 +import configparser +import json +import os +import sys +import unittest +from unittest import mock +from unittest.mock import patch + +from ansible.module_utils import basic +from ansible.module_utils.common.text.converters import to_bytes +from test_util_datastructures import ( + DEFAULT_KUBERNETES_METADATA, + DEFAULT_KUBERNETES_SECRET_OBJECT, + DEFAULT_PARSED_SECRET_VALUE, + DEFAULT_VAULT_POLICIES, +) + +# from unittest.mock import call, patch + +# TODO(bandini): I could not come up with something better to force the imports to be existing +# when we "import parse_secrets_info" +sys.path.insert(1, "./ansible/plugins/module_utils") +sys.path.insert(1, "./ansible/plugins/modules") + +import load_secrets_common # noqa: E402 + +sys.modules["ansible.module_utils.load_secrets_common"] = load_secrets_common + +import parse_secrets_v2 # noqa: E402 + +sys.modules["ansible.module_utils.parse_secrets_v2"] = parse_secrets_v2 + +import parse_secrets_info # noqa: E402 + +sys.modules["ansible.modules.parse_secrets_info"] = parse_secrets_info + + +def set_module_args(args): + """prepare arguments so that they will be picked up during module creation""" + args = json.dumps({"ANSIBLE_MODULE_ARGS": args}) + basic._ANSIBLE_ARGS = to_bytes(args) + + +class BytesEncoder(json.JSONEncoder): + def default(self, o): + if isinstance(o, bytes): + return base64.b64encode(o).decode("ascii") + else: + return super().default(o) + + +def json_str(a): + return json.dumps(a, sort_keys=True, cls=BytesEncoder) + + +def ds_eq(a, b): + """ + This function takes two arbitrary data structures, sorts their keys, stringifies them into JSON + and compares them. The idea here is to test data structure difference without having to write + an involved recursive data structure parser. If the function returns true, the two data + structures are equal. + """ + print("a=" + json_str(a)) + print("b=" + json_str(b)) + return json_str(a) == json_str(b) + + +class AnsibleExitJson(Exception): + """Exception class to be raised by module.exit_json and caught by the test case""" + + pass + + +class AnsibleFailJson(Exception): + """Exception class to be raised by module.fail_json and caught by the test case""" + + pass + + +def exit_json(*args, **kwargs): + """function to patch over exit_json; package return data into an exception""" + if "changed" not in kwargs: + kwargs["changed"] = False + raise AnsibleExitJson(kwargs) + + +def fail_json(*args, **kwargs): + """function to patch over fail_json; package return data into an exception""" + kwargs["failed"] = True + kwargs["args"] = args + raise AnsibleFailJson(kwargs) + + +@mock.patch("getpass.getpass") +class TestMyModule(unittest.TestCase): + def create_inifile(self): + self.inifile = open("/tmp/awscredentials", "w") + config = configparser.ConfigParser() + config["default"] = { + "aws_access_key_id": "123123", + "aws_secret_access_key": "abcdefghi", + } + config["foobar"] = { + "aws_access_key_id": "345345", + "aws_secret_access_key": "rstuvwxyz", + } + with self.inifile as configfile: + config.write(configfile) + + def create_testbinfile(self): + with open(self.binfilename, "wb") as f: + f.write(bytes([8, 6, 7, 5, 3, 0, 9])) + f.close() + + def setUp(self): + self.binfilename = "/tmp/testbinfile.bin" + self.mock_module_helper = patch.multiple( + basic.AnsibleModule, exit_json=exit_json, fail_json=fail_json + ) + self.mock_module_helper.start() + self.addCleanup(self.mock_module_helper.stop) + self.testdir_v2 = os.path.join(os.path.dirname(os.path.abspath(__file__)), "v2") + self.testfile = open("/tmp/ca.crt", "w") + self.create_inifile() + self.create_testbinfile() + # For ~/expanduser tests + self.orig_home = os.environ["HOME"] + os.environ["HOME"] = self.testdir_v2 + + def tearDown(self): + os.environ["HOME"] = self.orig_home + self.testfile.close() + try: + os.remove("/tmp/ca.crt") + os.remove(self.binfilename) + # os.remove("/tmp/awscredentials") + except OSError: + pass + + def get_file_as_stdout(self, filename, openmode="r"): + with open(filename, mode=openmode, encoding="utf-8") as f: + return f.read() + + def test_module_fail_when_required_args_missing(self, getpass): + with self.assertRaises(AnsibleFailJson): + set_module_args({}) + parse_secrets_info.main() + + def test_module_parse_base(self, getpass): + getpass.return_value = "/tmp/ca.crt" + testfile_output = self.get_file_as_stdout( + os.path.join(self.testdir_v2, "values-secret-v2-base.yaml") + ) + with self.assertRaises(AnsibleExitJson) as result: + set_module_args( + { + "values_secrets_plaintext": testfile_output, + } + ) + parse_secrets_info.main() + + ret = result.exception.args[0] + self.assertTrue( + (ret["failed"] is False) + and (ret["changed"] is False) + and (len(ret["parsed_secrets"])) == 1 + and (len(ret["kubernetes_secret_objects"]) == 0) + ) + + def test_module_parse_base_parsed_secrets(self, getpass): + getpass.return_value = "/tmp/ca.crt" + testfile_output = self.get_file_as_stdout( + os.path.join(self.testdir_v2, "values-secret-v2-base.yaml") + ) + with self.assertRaises(AnsibleExitJson) as result: + set_module_args( + { + "values_secrets_plaintext": testfile_output, + } + ) + parse_secrets_info.main() + + vp = DEFAULT_VAULT_POLICIES | { + "basicPolicy": 'length=10\nrule "charset" { charset = "abcdefghijklmnopqrstuvwxyz" min-chars = 1 }\nrule "charset" { charset = "ABCDEFGHIJKLMNOPQRSTUVWXYZ" min-chars = 1 }\nrule "charset" { charset = "0123456789" min-chars = 1 }\n', # noqa: E501 + "advancedPolicy": 'length=20\nrule "charset" { charset = "abcdefghijklmnopqrstuvwxyz" min-chars = 1 }\nrule "charset" { charset = "ABCDEFGHIJKLMNOPQRSTUVWXYZ" min-chars = 1 }\nrule "charset" { charset = "0123456789" min-chars = 1 }\nrule "charset" { charset = "!@#%^&*" min-chars = 1 }\n', # noqa: E501 + } + + # Beware reading this structure aloud to your cat... + pspsps = { + "config-demo": DEFAULT_PARSED_SECRET_VALUE + | { + "name": "config-demo", + "fields": { + "secret": None, + "secret2": "/tmp/ca.crt", + "ca_crt": "", + "ca_crt2": "", + }, + "base64": ["ca_crt2"], + "generate": ["secret"], + "override": ["secret"], + "vault_policies": { + "secret": "basicPolicy", + }, + "vault_prefixes": [ + "region-one", + "snowflake.blueprints.rhecoeng.com", + ], + "paths": { + "ca_crt": "/tmp/ca.crt", + "ca_crt2": "/tmp/ca.crt", + }, + }, + } + + ret = result.exception.args[0] + self.assertTrue( + (ret["failed"] is False) + and (ret["changed"] is False) + and (ds_eq(vp, ret["vault_policies"])) + and (ds_eq(pspsps, ret["parsed_secrets"])) + ) + + def test_module_parsed_secret_ini_files(self, getpass): + testfile_output = self.get_file_as_stdout( + os.path.join(self.testdir_v2, "values-secret-v2-ini-file.yaml") + ) + with self.assertRaises(AnsibleExitJson) as result: + set_module_args( + { + "values_secrets_plaintext": testfile_output, + } + ) + parse_secrets_info.main() + + ps = { + "aws": DEFAULT_PARSED_SECRET_VALUE + | { + "name": "aws", + "fields": { + "aws_access_key_id": "123123", + "aws_secret_access_key": "abcdefghi", + }, + "ini_file": { + "aws_access_key_id": { + "ini_file": "/tmp/awscredentials", + "ini_section": "default", + "ini_key": "aws_access_key_id", + }, + "aws_secret_access_key": { + "ini_file": "/tmp/awscredentials", + "ini_section": "default", + "ini_key": "aws_secret_access_key", + }, + }, + }, + "awsfoobar": DEFAULT_PARSED_SECRET_VALUE + | { + "name": "awsfoobar", + "fields": { + "aws_access_key_id": "345345", + "aws_secret_access_key": "rstuvwxyz", + }, + "ini_file": { + "aws_access_key_id": { + "ini_file": "/tmp/awscredentials", + "ini_section": "foobar", + "ini_key": "aws_access_key_id", + }, + "aws_secret_access_key": { + "ini_file": "/tmp/awscredentials", + "ini_section": "foobar", + "ini_key": "aws_secret_access_key", + }, + }, + }, + } + + ret = result.exception.args[0] + self.assertTrue( + (ret["failed"] is False) + and (ret["changed"] is False) + and (len(ret["parsed_secrets"]) == 2) + and (ds_eq(ps, ret["parsed_secrets"])) + ) + + def test_module_parsed_secret_ini_files_base64(self, getpass): + testfile_output = self.get_file_as_stdout( + os.path.join(self.testdir_v2, "values-secret-v2-ini-file-b64.yaml") + ) + with self.assertRaises(AnsibleExitJson) as result: + set_module_args( + { + "values_secrets_plaintext": testfile_output, + } + ) + parse_secrets_info.main() + + ps = { + "aws": DEFAULT_PARSED_SECRET_VALUE + | { + "name": "aws", + "fields": { + "aws_access_key_id": "A123456789012345678A", + "aws_secret_access_key": "A12345678901234567890123456789012345678A", + }, + "ini_file": { + "aws_access_key_id": { + "ini_file": f"{os.environ['HOME']}/aws-example.ini", + "ini_section": "default", + "ini_key": "aws_access_key_id", + }, + "aws_secret_access_key": { + "ini_file": f"{os.environ['HOME']}/aws-example.ini", + "ini_section": "default", + "ini_key": "aws_secret_access_key", + }, + }, + }, + "awsb64": DEFAULT_PARSED_SECRET_VALUE + | { + "name": "awsb64", + "fields": { + "aws_access_key_id": "QTEyMzQ1Njc4OTAxMjM0NTY3OEE=", + "aws_secret_access_key": "QTEyMzQ1Njc4OTAxMjM0NTY3ODkwMTIzNDU2Nzg5MDEyMzQ1Njc4QQ==", + }, + "base64": [ + "aws_access_key_id", + "aws_secret_access_key", + ], + "ini_file": { + "aws_access_key_id": { + "ini_file": f"{os.environ['HOME']}/aws-example.ini", + "ini_section": "default", + "ini_key": "aws_access_key_id", + }, + "aws_secret_access_key": { + "ini_file": f"{os.environ['HOME']}/aws-example.ini", + "ini_section": "default", + "ini_key": "aws_secret_access_key", + }, + }, + }, + } + + ret = result.exception.args[0] + self.assertTrue( + (ret["failed"] is False) + and (ret["changed"] is False) + and (len(ret["parsed_secrets"]) == 2) + and (len(ret["kubernetes_secret_objects"]) == 0) + and (ds_eq(ps, ret["parsed_secrets"])) + ) + + def test_module_parsed_secret_ini_files_base64_kubernetes(self, getpass): + testfile_output = self.get_file_as_stdout( + os.path.join(self.testdir_v2, "values-secret-v2-ini-file-b64.yaml") + ) + with self.assertRaises(AnsibleExitJson) as result: + set_module_args( + { + "values_secrets_plaintext": testfile_output, + "secrets_backing_store": "kubernetes", + } + ) + parse_secrets_info.main() + + ps = { + "aws": DEFAULT_PARSED_SECRET_VALUE + | { + "name": "aws", + "fields": { + "aws_access_key_id": "A123456789012345678A", + "aws_secret_access_key": "A12345678901234567890123456789012345678A", + }, + "ini_file": { + "aws_access_key_id": { + "ini_file": f"{os.environ['HOME']}/aws-example.ini", + "ini_section": "default", + "ini_key": "aws_access_key_id", + }, + "aws_secret_access_key": { + "ini_file": f"{os.environ['HOME']}/aws-example.ini", + "ini_section": "default", + "ini_key": "aws_secret_access_key", + }, + }, + }, + "awsb64": DEFAULT_PARSED_SECRET_VALUE + | { + "name": "awsb64", + "fields": { + "aws_access_key_id": "QTEyMzQ1Njc4OTAxMjM0NTY3OEE=", + "aws_secret_access_key": "QTEyMzQ1Njc4OTAxMjM0NTY3ODkwMTIzNDU2Nzg5MDEyMzQ1Njc4QQ==", + }, + "base64": [ + "aws_access_key_id", + "aws_secret_access_key", + ], + "ini_file": { + "aws_access_key_id": { + "ini_file": f"{os.environ['HOME']}/aws-example.ini", + "ini_section": "default", + "ini_key": "aws_access_key_id", + }, + "aws_secret_access_key": { + "ini_file": f"{os.environ['HOME']}/aws-example.ini", + "ini_section": "default", + "ini_key": "aws_secret_access_key", + }, + }, + }, + } + + ret = result.exception.args[0] + self.assertTrue( + (ret["failed"] is False) + and (ret["changed"] is False) + and (len(ret["parsed_secrets"]) == 2) + and (len(ret["kubernetes_secret_objects"]) == 2) + and (ds_eq(ps, ret["parsed_secrets"])) + ) + + def test_module_default_labels(self, getpass): + testfile_output = self.get_file_as_stdout( + os.path.join(self.testdir_v2, "values-secret-v2-default-labels.yaml") + ) + with self.assertRaises(AnsibleExitJson) as result: + set_module_args( + { + "values_secrets_plaintext": testfile_output, + "secrets_backing_store": "kubernetes", + } + ) + parse_secrets_info.main() + + ret = result.exception.args[0] + self.assertTrue( + ds_eq( + ret["kubernetes_secret_objects"][0], + DEFAULT_KUBERNETES_SECRET_OBJECT + | { + "metadata": DEFAULT_KUBERNETES_METADATA + | { + "name": "test-secret", + "labels": {"testlabel": "4"}, + "namespace": "validated-patterns-secrets", + }, + "stringData": {"username": "user"}, + }, + ) + ) + + def test_module_override_labels(self, getpass): + testfile_output = self.get_file_as_stdout( + os.path.join(self.testdir_v2, "values-secret-v2-override-labels.yaml") + ) + with self.assertRaises(AnsibleExitJson) as result: + set_module_args( + { + "values_secrets_plaintext": testfile_output, + "secrets_backing_store": "kubernetes", + } + ) + parse_secrets_info.main() + ret = result.exception.args[0] + self.assertTrue( + ds_eq( + ret["kubernetes_secret_objects"][0], + DEFAULT_KUBERNETES_SECRET_OBJECT + | { + "metadata": DEFAULT_KUBERNETES_METADATA + | { + "name": "test-secret", + "labels": {"overridelabel": "42"}, + }, + "stringData": {"username": "user"}, + }, + ) + ) + + def test_module_override_namespace(self, getpass): + testfile_output = self.get_file_as_stdout( + os.path.join(self.testdir_v2, "values-secret-v2-override-namespace.yaml") + ) + with self.assertRaises(AnsibleExitJson) as result: + set_module_args( + { + "values_secrets_plaintext": testfile_output, + "secrets_backing_store": "kubernetes", + } + ) + parse_secrets_info.main() + ret = result.exception.args[0] + self.assertTrue( + len(ret["kubernetes_secret_objects"]) == 1 + and ds_eq( + ret["kubernetes_secret_objects"][0], + DEFAULT_KUBERNETES_SECRET_OBJECT + | { + "metadata": DEFAULT_KUBERNETES_METADATA + | { + "name": "test-secret", + "namespace": "overridden-namespace", + }, + "stringData": {"username": "user"}, + }, + ) + ) + + def test_module_none_extra_namespaces(self, getpass): + testfile_output = self.get_file_as_stdout( + os.path.join(self.testdir_v2, "values-secret-v2-more-namespaces.yaml") + ) + with self.assertRaises(AnsibleExitJson) as result: + set_module_args( + { + "values_secrets_plaintext": testfile_output, + "secrets_backing_store": "none", + } + ) + parse_secrets_info.main() + ret = result.exception.args[0] + self.assertTrue( + len(ret["kubernetes_secret_objects"]) == 2 + and ds_eq( + ret["kubernetes_secret_objects"][0], + DEFAULT_KUBERNETES_SECRET_OBJECT + | { + "metadata": DEFAULT_KUBERNETES_METADATA + | { + "name": "test-secret", + "namespace": "default", + }, + "stringData": {"username": "user"}, + }, + ) + and ds_eq( + ret["kubernetes_secret_objects"][1], + DEFAULT_KUBERNETES_SECRET_OBJECT + | { + "metadata": DEFAULT_KUBERNETES_METADATA + | { + "name": "test-secret", + "namespace": "extra", + }, + "stringData": {"username": "user"}, + }, + ) + ) + + def test_module_override_type_kubernetes(self, getpass): + testfile_output = self.get_file_as_stdout( + os.path.join(self.testdir_v2, "values-secret-v2-override-type.yaml") + ) + with self.assertRaises(AnsibleExitJson) as result: + set_module_args( + { + "values_secrets_plaintext": testfile_output, + "secrets_backing_store": "kubernetes", + } + ) + parse_secrets_info.main() + ret = result.exception.args[0] + self.assertTrue( + len(ret["kubernetes_secret_objects"]) == 1 + and ds_eq( + ret["kubernetes_secret_objects"][0], + DEFAULT_KUBERNETES_SECRET_OBJECT + | { + "type": "user-specified", + "metadata": DEFAULT_KUBERNETES_METADATA + | { + "name": "test-secret", + }, + "stringData": {"username": "user"}, + }, + ) + ) + + def test_module_override_type_none(self, getpass): + testfile_output = self.get_file_as_stdout( + os.path.join(self.testdir_v2, "values-secret-v2-override-type-none.yaml") + ) + with self.assertRaises(AnsibleExitJson) as result: + set_module_args( + { + "values_secrets_plaintext": testfile_output, + "secrets_backing_store": "none", + } + ) + parse_secrets_info.main() + ret = result.exception.args[0] + self.assertTrue( + len(ret["kubernetes_secret_objects"]) == 1 + and ds_eq( + ret["kubernetes_secret_objects"][0], + DEFAULT_KUBERNETES_SECRET_OBJECT + | { + "type": "user-specified", + "metadata": DEFAULT_KUBERNETES_METADATA + | {"name": "test-secret", "namespace": "default"}, + "stringData": {"username": "user"}, + }, + ) + ) + + def test_module_secret_file_contents(self, getpass): + testfile_output = self.get_file_as_stdout( + os.path.join(self.testdir_v2, "values-secret-v2-file-contents.yaml") + ) + with self.assertRaises(AnsibleExitJson) as result: + set_module_args( + { + "values_secrets_plaintext": testfile_output, + "secrets_backing_store": "kubernetes", + } + ) + parse_secrets_info.main() + ret = result.exception.args[0] + self.assertTrue( + len(ret["kubernetes_secret_objects"]) == 1 + and ds_eq( + ret["kubernetes_secret_objects"][0], + DEFAULT_KUBERNETES_SECRET_OBJECT + | { + "metadata": DEFAULT_KUBERNETES_METADATA + | { + "name": "test-secret", + }, + "stringData": {"username": "This space intentionally left blank\n"}, + }, + ) + ) + + def test_module_secret_file_contents_b64(self, getpass): + testfile_output = self.get_file_as_stdout( + os.path.join(self.testdir_v2, "values-secret-v2-file-contents-b64.yaml") + ) + with self.assertRaises(AnsibleExitJson) as result: + set_module_args( + { + "values_secrets_plaintext": testfile_output, + "secrets_backing_store": "kubernetes", + } + ) + parse_secrets_info.main() + ret = result.exception.args[0] + self.assertTrue( + len(ret["kubernetes_secret_objects"]) == 1 + and ds_eq( + ret["kubernetes_secret_objects"][0], + DEFAULT_KUBERNETES_SECRET_OBJECT + | { + "metadata": DEFAULT_KUBERNETES_METADATA + | { + "name": "test-secret", + }, + "stringData": { + "username": "VGhpcyBzcGFjZSBpbnRlbnRpb25hbGx5IGxlZnQgYmxhbmsK" + }, + }, + ) + ) + + def test_module_secret_file_contents_double_b64(self, getpass): + testfile_output = self.get_file_as_stdout( + os.path.join( + self.testdir_v2, "values-secret-v2-file-contents-double-b64.yaml" + ) + ) + with self.assertRaises(AnsibleExitJson) as result: + set_module_args( + { + "values_secrets_plaintext": testfile_output, + "secrets_backing_store": "kubernetes", + } + ) + parse_secrets_info.main() + ret = result.exception.args[0] + self.assertTrue( + len(ret["kubernetes_secret_objects"]) == 1 + and ds_eq( + ret["kubernetes_secret_objects"][0], + DEFAULT_KUBERNETES_SECRET_OBJECT + | { + "metadata": DEFAULT_KUBERNETES_METADATA + | { + "name": "test-secret", + }, + "stringData": { + "username": "VkdocGN5QnpjR0ZqWlNCcGJuUmxiblJwYjI1aGJHeDVJR3hsWm5RZ1lteGhibXNL" + }, + }, + ) + ) + + def test_module_secret_file_contents_binary_b64(self, getpass): + testfile_output = self.get_file_as_stdout( + os.path.join(self.testdir_v2, "values-secret-v2-secret-binary-b64.yaml") + ) + with self.assertRaises(AnsibleExitJson) as result: + set_module_args( + { + "values_secrets_plaintext": testfile_output, + "secrets_backing_store": "kubernetes", + } + ) + parse_secrets_info.main() + ret = result.exception.args[0] + + # The binary bytes are [ 8, 6, 7, 5, 3, 0, 9 ] (IYKYK) + self.assertTrue( + len(ret["kubernetes_secret_objects"]) == 1 + and ds_eq( + ret["kubernetes_secret_objects"][0], + DEFAULT_KUBERNETES_SECRET_OBJECT + | { + "metadata": DEFAULT_KUBERNETES_METADATA + | { + "name": "secret", + }, + "stringData": {"secret": "CAYHBQMACQ=="}, + }, + ) + ) + + def test_ensure_success_retrieving_block_yaml_policy(self, getpass): + testfile_output = self.get_file_as_stdout( + os.path.join(self.testdir_v2, "values-secret-v2-defaultvp-policy.yaml") + ) + with self.assertRaises(AnsibleExitJson) as ansible_err: + set_module_args( + { + "values_secrets_plaintext": testfile_output, + "secrets_backing_store": "vault", + } + ) + parse_secrets_info.main() + + ret = ansible_err.exception.args[0] + self.assertTrue( + ds_eq( + ret["vault_policies"], + { + "basicPolicy": 'length=10\nrule "charset" { charset = "abcdefghijklmnopqrstuvwxyz" min-chars = 1 }\nrule "charset" { charset = "ABCDEFGHIJKLMNOPQRSTUVWXYZ" min-chars = 1 }\nrule "charset" { charset = "0123456789" min-chars = 1 }\n', # noqa: E501 + "validatedPatternDefaultPolicy": 'length=20\nrule "charset" { charset = "abcdefghijklmnopqrstuvwxyz" min-chars = 1 }\nrule "charset" { charset = "ABCDEFGHIJKLMNOPQRSTUVWXYZ" min-chars = 1 }\nrule "charset" { charset = "0123456789" min-chars = 1 }\nrule "charset" { charset = "!@#%^&*" min-chars = 1 }\n', # noqa: E501 + }, + ) + ) + + def test_ensure_success_retrieving_block_yaml_value(self, getpass): + testfile_output = self.get_file_as_stdout( + os.path.join(self.testdir_v2, "values-secret-v2-block-yamlstring.yaml") + ) + with self.assertRaises(AnsibleExitJson) as ansible_err: + set_module_args( + { + "values_secrets_plaintext": testfile_output, + "secrets_backing_store": "vault", + } + ) + parse_secrets_info.main() + + ret = ansible_err.exception.args[0] + self.assertTrue( + ds_eq( + ret["parsed_secrets"], + { + "config-demo": DEFAULT_PARSED_SECRET_VALUE + | { + "fields": { + "sshprivkey": "ssh-rsa oNb/kAvwdQl+FKdwzzKo5rnGIB68UOxWoaKPnKdgF/ts67CDBslWGnpUZCpp8TdaxfHmpoyA6nutMwQw8OAMEUybxvilDn+ZVJ/5qgfRBdi8wLKRLTIj0v+ZW7erN9yuZG53xUQAaQjivM3cRyNLIZ9torShYaYwD1UTTDkV97RMfNDlWI5f5FGRvfy429ZfCwbUWUbijrcv/mWc/uO3x/+MBXwa4f8ubzEYlrt4yH/Vbpzs67kE9UJ9z1zurFUFJydy1ZDAdKSiBS91ImI3ccKnbz0lji2bgSYR0Wp1IQhzSpjyJU2rIu9HAEUh85Rwf2jakfLpMcg/hSBer3sG kilroy@example.com", # noqa: E501 + "sshpubkey": "-----BEGIN OPENSSH PRIVATE KEY-----\nTtzxGgWrNerAr1hzUqPW2xphF/Aur1rQXSLv4J7frEJxNED6u/eScsNgwJMGXwRx7QYVohh0ARHVhJdUzJK7pEIphi4BGw==\nwlo+oQsi828b47SKZB8/K9dbeLlLiXh9/hu47MGpeGHZsKbjAdauncuw+YUDDN2EADJjasNMZHjxYhXKtqDjXTIw1X1n0Q==\n-----END OPENSSH PRIVATE KEY-----", # noqa: E501 + }, + "name": "config-demo", + } + }, + ) + ) + + def test_ensure_kubernetes_object_block_yaml_value(self, getpass): + testfile_output = self.get_file_as_stdout( + os.path.join(self.testdir_v2, "values-secret-v2-block-yamlstring.yaml") + ) + with self.assertRaises(AnsibleExitJson) as ansible_err: + set_module_args( + { + "values_secrets_plaintext": testfile_output, + "secrets_backing_store": "kubernetes", + } + ) + parse_secrets_info.main() + + ret = ansible_err.exception.args[0] + self.assertTrue( + ds_eq( + ret["kubernetes_secret_objects"][0], + DEFAULT_KUBERNETES_SECRET_OBJECT + | { + "metadata": DEFAULT_KUBERNETES_METADATA + | { + "name": "config-demo", + }, + "stringData": { + "sshprivkey": "ssh-rsa oNb/kAvwdQl+FKdwzzKo5rnGIB68UOxWoaKPnKdgF/ts67CDBslWGnpUZCpp8TdaxfHmpoyA6nutMwQw8OAMEUybxvilDn+ZVJ/5qgfRBdi8wLKRLTIj0v+ZW7erN9yuZG53xUQAaQjivM3cRyNLIZ9torShYaYwD1UTTDkV97RMfNDlWI5f5FGRvfy429ZfCwbUWUbijrcv/mWc/uO3x/+MBXwa4f8ubzEYlrt4yH/Vbpzs67kE9UJ9z1zurFUFJydy1ZDAdKSiBS91ImI3ccKnbz0lji2bgSYR0Wp1IQhzSpjyJU2rIu9HAEUh85Rwf2jakfLpMcg/hSBer3sG kilroy@example.com", # noqa: E501 + "sshpubkey": "-----BEGIN OPENSSH PRIVATE KEY-----\nTtzxGgWrNerAr1hzUqPW2xphF/Aur1rQXSLv4J7frEJxNED6u/eScsNgwJMGXwRx7QYVohh0ARHVhJdUzJK7pEIphi4BGw==\nwlo+oQsi828b47SKZB8/K9dbeLlLiXh9/hu47MGpeGHZsKbjAdauncuw+YUDDN2EADJjasNMZHjxYhXKtqDjXTIw1X1n0Q==\n-----END OPENSSH PRIVATE KEY-----", # noqa: E501 + }, + }, + ) + ) + + def test_ensure_kubernetes_backend_allowed(self, getpass): + testfile_output = self.get_file_as_stdout( + os.path.join(self.testdir_v2, "values-secret-v2-base-k8s-backend.yaml") + ) + with self.assertRaises(AnsibleExitJson) as ansible_err: + set_module_args( + { + "values_secrets_plaintext": testfile_output, + "secrets_backing_store": "kubernetes", + } + ) + parse_secrets_info.main() + + ret = ansible_err.exception.args[0] + self.assertFalse(ret["failed"]) + + def test_ensure_none_backend_allowed(self, getpass): + testfile_output = self.get_file_as_stdout( + os.path.join(self.testdir_v2, "values-secret-v2-base-none-backend.yaml") + ) + with self.assertRaises(AnsibleExitJson) as ansible_err: + set_module_args( + { + "values_secrets_plaintext": testfile_output, + "secrets_backing_store": "none", + } + ) + parse_secrets_info.main() + + ret = ansible_err.exception.args[0] + self.assertFalse(ret["failed"]) + + def test_ensure_error_conflicting_backends(self, getpass): + testfile_output = self.get_file_as_stdout( + os.path.join(self.testdir_v2, "values-secret-v2-base-k8s-backend.yaml") + ) + with self.assertRaises(AnsibleFailJson) as ansible_err: + set_module_args( + { + "values_secrets_plaintext": testfile_output, + "secrets_backing_store": "vault", + } + ) + parse_secrets_info.main() + + ret = ansible_err.exception.args[0] + self.assertEqual(ret["failed"], True) + assert ( + ret["args"][1] + == "Secrets file specifies 'kubernetes' backend but pattern config specifies 'vault'." + ) + + def test_ensure_error_unknown_backends(self, getpass): + testfile_output = self.get_file_as_stdout( + os.path.join(self.testdir_v2, "values-secret-v2-base-unknown-backend.yaml") + ) + with self.assertRaises(AnsibleFailJson) as ansible_err: + set_module_args( + { + "values_secrets_plaintext": testfile_output, + "secrets_backing_store": "unknown", + } + ) + parse_secrets_info.main() + + ret = ansible_err.exception.args[0] + self.assertEqual(ret["failed"], True) + assert ( + ret["args"][1] + == "Currently only the 'vault', 'kubernetes' and 'none' backingStores are supported: unknown" + ) + + def test_ensure_error_secrets_same_name(self, getpass): + testfile_output = self.get_file_as_stdout( + os.path.join(self.testdir_v2, "values-secret-v2-same-secret-names.yaml") + ) + with self.assertRaises(AnsibleFailJson) as ansible_err: + set_module_args( + { + "values_secrets_plaintext": testfile_output, + } + ) + parse_secrets_info.main() + + ret = ansible_err.exception.args[0] + self.assertEqual(ret["failed"], True) + assert ( + ret["args"][1] == "You cannot have duplicate secret names: ['config-demo']" + ) + + def test_ensure_error_fields_same_name(self, getpass): + testfile_output = self.get_file_as_stdout( + os.path.join(self.testdir_v2, "values-secret-v2-same-field-names.yaml") + ) + with self.assertRaises(AnsibleFailJson) as ansible_err: + set_module_args( + { + "values_secrets_plaintext": testfile_output, + } + ) + parse_secrets_info.main() + + ret = ansible_err.exception.args[0] + self.assertEqual(ret["failed"], True) + assert ret["args"][1] == "You cannot have duplicate field names: ['secret']" + + def test_ensure_generate_errors_on_kubernetes(self, getpass): + testfile_output = self.get_file_as_stdout( + os.path.join(self.testdir_v2, "values-secret-v2-generic-onlygenerate.yaml") + ) + with self.assertRaises(AnsibleFailJson) as ansible_err: + set_module_args( + { + "values_secrets_plaintext": testfile_output, + "secrets_backing_store": "kubernetes", + } + ) + parse_secrets_info.main() + + ret = ansible_err.exception.args[0] + self.assertEqual(ret["failed"], True) + assert ( + ret["args"][1] + == "You cannot have onMissingValue set to 'generate' unless using vault backingstore for secret config-demo field secret" # noqa: E501 + ) + + def test_ensure_generate_errors_on_none_generate(self, getpass): + testfile_output = self.get_file_as_stdout( + os.path.join(self.testdir_v2, "values-secret-v2-generic-onlygenerate.yaml") + ) + with self.assertRaises(AnsibleFailJson) as ansible_err: + set_module_args( + { + "values_secrets_plaintext": testfile_output, + "secrets_backing_store": "none", + } + ) + parse_secrets_info.main() + + ret = ansible_err.exception.args[0] + self.assertEqual(ret["failed"], True) + assert ( + ret["args"][1] + == "You cannot have onMissingValue set to 'generate' unless using vault backingstore for secret config-demo field secret" # noqa: E501 + ) + + +if __name__ == "__main__": + unittest.main() diff --git a/common/ansible/tests/unit/test_util_datastructures.py b/common/ansible/tests/unit/test_util_datastructures.py new file mode 100644 index 00000000..11d7cdae --- /dev/null +++ b/common/ansible/tests/unit/test_util_datastructures.py @@ -0,0 +1,205 @@ +DEFAULT_PARSED_SECRET_VALUE = { + "name": "overwrite-me", + "fields": {}, + "base64": [], + "ini_file": {}, + "generate": [], + "override": [], + "vault_mount": "secret", + "vault_policies": {}, + "vault_prefixes": ["hub"], + "type": "Opaque", + "target_namespaces": [], + "labels": {}, + "annotations": {}, + "paths": {}, +} + +DEFAULT_KUBERNETES_METADATA = { + "name": "overwrite-me", + "labels": {}, + "annotations": {}, + "namespace": "validated-patterns-secrets", +} +DEFAULT_KUBERNETES_SECRET_OBJECT = { + "kind": "Secret", + "type": "Opaque", + "apiVersion": "v1", + "metadata": DEFAULT_KUBERNETES_METADATA, + "stringData": {}, +} + +DEFAULT_VAULT_POLICIES = { + "validatedPatternDefaultPolicy": ( + "length=20\n" + 'rule "charset" { charset = "abcdefghijklmnopqrstuvwxyz" min-chars = 1 }\n' # noqa: E501 + 'rule "charset" { charset = "ABCDEFGHIJKLMNOPQRSTUVWXYZ" min-chars = 1 }\n' # noqa: E501 + 'rule "charset" { charset = "0123456789" min-chars = 1 }\n' + 'rule "charset" { charset = "!@#%^&*" min-chars = 1 }\n' + ), +} + +GENERATE_POLICY_B64_TEST = { + "vault_policies": { + "basicPolicy": 'length=10\nrule "charset" { charset = "abcdefghijklmnopqrstuvwxyz" min-chars = 1 }\nrule "charset" { charset = "ABCDEFGHIJKLMNOPQRSTUVWXYZ" min-chars = 1 }\nrule "charset" { charset = "0123456789" min-chars = 1 }\n', # noqa: E501 + "validatedPatternDefaultPolicy": 'length=20\nrule "charset" { charset = "abcdefghijklmnopqrstuvwxyz" min-chars = 1 }\nrule "charset" { charset = "ABCDEFGHIJKLMNOPQRSTUVWXYZ" min-chars = 1 }\nrule "charset" { charset = "0123456789" min-chars = 1 }\nrule "charset" { charset = "!@#%^&*" min-chars = 1 }\n', # noqa: E501 + }, + "parsed_secrets": { + "config-demo": { + "annotations": {}, + "base64": ["secret"], + "fields": {"secret": None}, + "generate": ["secret"], + "ini_file": {}, + "labels": {}, + "name": "config-demo", + "namespace": "validated-patterns-secrets", + "override": ["secret"], + "paths": {}, + "type": "Opaque", + "vault_mount": "secret", + "vault_policies": {"secret": "basicPolicy"}, + "vault_prefixes": ["region-one", "snowflake.blueprints.rhecoeng.com"], + } + }, +} + +PARSED_SECRET_VALUE_TEST = { + "parsed_secrets": { + "config-demo": { + "annotations": {}, + "base64": [], + "fields": {"secret": "value123"}, + "generate": [], + "ini_file": {}, + "labels": {}, + "name": "config-demo", + "namespace": "validated-patterns-secrets", + "override": [], + "paths": {}, + "type": "Opaque", + "vault_mount": "secret", + "vault_policies": {}, + "vault_prefixes": ["hub"], + } + }, + "vault_policies": { + "validatedPatternDefaultPolicy": 'length=20\nrule "charset" { charset = "abcdefghijklmnopqrstuvwxyz" min-chars = 1 }\nrule "charset" { charset = "ABCDEFGHIJKLMNOPQRSTUVWXYZ" min-chars = 1 }\nrule "charset" { charset = "0123456789" min-chars = 1 }\nrule "charset" { charset = "!@#%^&*" min-chars = 1 }\n' # noqa: E501 + }, +} + +PARSED_SECRET_B64_VALUE_TEST = { + "parsed_secrets": { + "config-demo": { + "annotations": {}, + "base64": ["secret"], + "fields": {"secret": "dmFsdWUxMjMK"}, + "generate": [], + "ini_file": {}, + "labels": {}, + "name": "config-demo", + "namespace": "validated-patterns-secrets", + "override": [], + "paths": {}, + "type": "Opaque", + "vault_mount": "secret", + "vault_policies": {}, + "vault_prefixes": ["hub"], + } + }, + "vault_policies": { + "validatedPatternDefaultPolicy": 'length=20\nrule "charset" { charset = "abcdefghijklmnopqrstuvwxyz" min-chars = 1 }\nrule "charset" { charset = "ABCDEFGHIJKLMNOPQRSTUVWXYZ" min-chars = 1 }\nrule "charset" { charset = "0123456789" min-chars = 1 }\nrule "charset" { charset = "!@#%^&*" min-chars = 1 }\n' # noqa: E501 + }, +} + +PARSED_SECRET_FILE_INJECTION_TEST = { + "parsed_secrets": { + "config-demo": { + "annotations": {}, + "base64": [], + "fields": {"secret": "value123"}, + "generate": [], + "ini_file": {}, + "labels": {}, + "name": "config-demo", + "namespace": "validated-patterns-secrets", + "override": [], + "paths": {}, + "type": "Opaque", + "vault_mount": "secret", + "vault_policies": {}, + "vault_prefixes": [ + "secret/region-one", + "secret/snowflake.blueprints.rhecoeng.com", + ], + }, + "config-demo-file": { + "annotations": {}, + "base64": [], + "fields": {"test": ""}, + "generate": [], + "ini_file": {}, + "labels": {}, + "name": "config-demo-file", + "namespace": "validated-patterns-secrets", + "override": [], + "paths": {"test": "/tmp/footest"}, + "type": "Opaque", + "vault_mount": "secret", + "vault_policies": {}, + "vault_prefixes": [ + "secret/region-two", + "secret/snowflake.blueprints.rhecoeng.com", + ], + }, + }, + "vault_policies": { + "validatedPatternDefaultPolicy": 'length=20\nrule "charset" { charset = "abcdefghijklmnopqrstuvwxyz" min-chars = 1 }\nrule "charset" { charset = "ABCDEFGHIJKLMNOPQRSTUVWXYZ" min-chars = 1 }\nrule "charset" { charset = "0123456789" min-chars = 1 }\nrule "charset" { charset = "!@#%^&*" min-chars = 1 }\n' # noqa: 501 + }, +} + +PARSED_SECRET_FILE_B64_INJECTION_TEST = { + "parsed_secrets": { + "config-demo": { + "annotations": {}, + "base64": [], + "fields": {"secret": "value123"}, + "generate": [], + "ini_file": {}, + "labels": {}, + "name": "config-demo", + "namespace": "validated-patterns-secrets", + "override": [], + "paths": {}, + "type": "Opaque", + "vault_mount": "secret", + "vault_policies": {}, + "vault_prefixes": [ + "secret/region-one", + "secret/snowflake.blueprints.rhecoeng.com", + ], + }, + "config-demo-file": { + "annotations": {}, + "base64": ["test"], + "fields": {"test": ""}, + "generate": [], + "ini_file": {}, + "labels": {}, + "name": "config-demo-file", + "namespace": "validated-patterns-secrets", + "override": [], + "paths": {"test": "/tmp/footest"}, + "type": "Opaque", + "vault_mount": "secret", + "vault_policies": {}, + "vault_prefixes": [ + "secret/region-two", + "secret/snowflake.blueprints.rhecoeng.com", + ], + }, + }, + "vault_policies": { + "validatedPatternDefaultPolicy": 'length=20\nrule "charset" { charset = "abcdefghijklmnopqrstuvwxyz" min-chars = 1 }\nrule "charset" { charset = "ABCDEFGHIJKLMNOPQRSTUVWXYZ" min-chars = 1 }\nrule "charset" { charset = "0123456789" min-chars = 1 }\nrule "charset" { charset = "!@#%^&*" min-chars = 1 }\n' # noqa: 501 + }, +} diff --git a/common/ansible/tests/unit/test_vault_load_parsed_secrets.py b/common/ansible/tests/unit/test_vault_load_parsed_secrets.py new file mode 100644 index 00000000..ca37de94 --- /dev/null +++ b/common/ansible/tests/unit/test_vault_load_parsed_secrets.py @@ -0,0 +1,320 @@ +# Copyright 2022 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. + +""" +Simple module to test vault_load_parsed_secrets +""" + +import json +import os +import sys +import unittest +from unittest.mock import call, patch + +import test_util_datastructures +from ansible.module_utils import basic +from ansible.module_utils.common.text.converters import to_bytes + +# TODO(bandini): I could not come up with something better to force the imports to be existing +# when we 'import vault_load_secrets' +sys.path.insert(1, "./ansible/plugins/module_utils") +sys.path.insert(1, "./ansible/plugins/modules") + +import vault_load_parsed_secrets # noqa: E402 + +sys.modules["ansible.modules.vault_load_parsed_secrets"] = vault_load_parsed_secrets + + +def set_module_args(args): + """prepare arguments so that they will be picked up during module creation""" + args = json.dumps({"ANSIBLE_MODULE_ARGS": args}) + basic._ANSIBLE_ARGS = to_bytes(args) + + +class AnsibleExitJson(Exception): + """Exception class to be raised by module.exit_json and caught by the test case""" + + pass + + +class AnsibleFailJson(Exception): + """Exception class to be raised by module.fail_json and caught by the test case""" + + pass + + +def exit_json(*args, **kwargs): + """function to patch over exit_json; package return data into an exception""" + if "changed" not in kwargs: + kwargs["changed"] = False + raise AnsibleExitJson(kwargs) + + +def fail_json(*args, **kwargs): + """function to patch over fail_json; package return data into an exception""" + kwargs["failed"] = True + kwargs["args"] = args + raise AnsibleFailJson(kwargs) + + +class TestMyModule(unittest.TestCase): + def setUp(self): + self.mock_module_helper = patch.multiple( + basic.AnsibleModule, exit_json=exit_json, fail_json=fail_json + ) + self.mock_module_helper.start() + self.addCleanup(self.mock_module_helper.stop) + self.testdir_v2 = os.path.join(os.path.dirname(os.path.abspath(__file__)), "v2") + + def tearDown(self): + return + + def test_module_fail_when_required_args_missing(self): + with self.assertRaises(AnsibleFailJson): + set_module_args({}) + vault_load_parsed_secrets.main() + + # For these tests, we need the data structures that parse_secrets_info outputs. + # Several have been saved in the test_util_datastructures module for this purpose + def test_ensure_value_injection_works(self): + set_module_args( + { + "parsed_secrets": test_util_datastructures.PARSED_SECRET_VALUE_TEST[ + "parsed_secrets" + ], + "vault_policies": test_util_datastructures.PARSED_SECRET_VALUE_TEST[ + "vault_policies" + ], + } + ) + with patch.object( + vault_load_parsed_secrets.VaultSecretLoader, "_run_command" + ) as mock_run_command: + stdout = "" + stderr = "" + ret = 0 + mock_run_command.return_value = ret, stdout, stderr # successful execution + + with self.assertRaises(AnsibleExitJson) as result: + vault_load_parsed_secrets.main() + self.assertTrue( + result.exception.args[0]["changed"] + ) # ensure result is changed + assert mock_run_command.call_count == 2 + + calls = [ + call( + 'echo \'length=20\nrule "charset" { charset = "abcdefghijklmnopqrstuvwxyz" min-chars = 1 }\nrule "charset" { charset = "ABCDEFGHIJKLMNOPQRSTUVWXYZ" min-chars = 1 }\nrule "charset" { charset = "0123456789" min-chars = 1 }\nrule "charset" { charset = "!@#%^&*" min-chars = 1 }\n\' | oc exec -n vault vault-0 -i -- sh -c \'cat - > /tmp/validatedPatternDefaultPolicy.hcl\';oc exec -n vault vault-0 -i -- sh -c \'vault write sys/policies/password/validatedPatternDefaultPolicy policy=@/tmp/validatedPatternDefaultPolicy.hcl\'', # noqa: E501 + attempts=3, + ), + call( + "oc exec -n vault vault-0 -i -- sh -c \"vault kv put -mount=secret hub/config-demo secret='value123'\"", + attempts=3, + ), + ] + print(mock_run_command.mock_calls) + mock_run_command.assert_has_calls(calls) + + def test_ensure_b64_value_injection_works(self): + set_module_args( + { + "parsed_secrets": test_util_datastructures.PARSED_SECRET_B64_VALUE_TEST[ + "parsed_secrets" + ], + "vault_policies": test_util_datastructures.PARSED_SECRET_B64_VALUE_TEST[ + "vault_policies" + ], + } + ) + with patch.object( + vault_load_parsed_secrets.VaultSecretLoader, "_run_command" + ) as mock_run_command: + stdout = "" + stderr = "" + ret = 0 + mock_run_command.return_value = ret, stdout, stderr # successful execution + + with self.assertRaises(AnsibleExitJson) as result: + vault_load_parsed_secrets.main() + self.assertTrue( + result.exception.args[0]["changed"] + ) # ensure result is changed + assert mock_run_command.call_count == 2 + + calls = [ + call( + 'echo \'length=20\nrule "charset" { charset = "abcdefghijklmnopqrstuvwxyz" min-chars = 1 }\nrule "charset" { charset = "ABCDEFGHIJKLMNOPQRSTUVWXYZ" min-chars = 1 }\nrule "charset" { charset = "0123456789" min-chars = 1 }\nrule "charset" { charset = "!@#%^&*" min-chars = 1 }\n\' | oc exec -n vault vault-0 -i -- sh -c \'cat - > /tmp/validatedPatternDefaultPolicy.hcl\';oc exec -n vault vault-0 -i -- sh -c \'vault write sys/policies/password/validatedPatternDefaultPolicy policy=@/tmp/validatedPatternDefaultPolicy.hcl\'', # noqa: E501 + attempts=3, + ), + call( + "oc exec -n vault vault-0 -i -- sh -c \"vault kv put -mount=secret hub/config-demo secret='dmFsdWUxMjMK'\"", # noqa: E501 + attempts=3, + ), + ] + print(mock_run_command.mock_calls) + mock_run_command.assert_has_calls(calls) + + def test_ensure_file_injection_works(self): + set_module_args( + { + "parsed_secrets": test_util_datastructures.PARSED_SECRET_FILE_INJECTION_TEST[ + "parsed_secrets" + ], + "vault_policies": test_util_datastructures.PARSED_SECRET_FILE_INJECTION_TEST[ + "vault_policies" + ], + } + ) + with patch.object( + vault_load_parsed_secrets.VaultSecretLoader, "_run_command" + ) as mock_run_command: + stdout = "" + stderr = "" + ret = 0 + mock_run_command.return_value = ret, stdout, stderr # successful execution + + with self.assertRaises(AnsibleExitJson) as result: + vault_load_parsed_secrets.main() + self.assertTrue( + result.exception.args[0]["changed"] + ) # ensure result is changed + assert mock_run_command.call_count == 5 + + calls = [ + call( + 'echo \'length=20\nrule "charset" { charset = "abcdefghijklmnopqrstuvwxyz" min-chars = 1 }\nrule "charset" { charset = "ABCDEFGHIJKLMNOPQRSTUVWXYZ" min-chars = 1 }\nrule "charset" { charset = "0123456789" min-chars = 1 }\nrule "charset" { charset = "!@#%^&*" min-chars = 1 }\n\' | oc exec -n vault vault-0 -i -- sh -c \'cat - > /tmp/validatedPatternDefaultPolicy.hcl\';oc exec -n vault vault-0 -i -- sh -c \'vault write sys/policies/password/validatedPatternDefaultPolicy policy=@/tmp/validatedPatternDefaultPolicy.hcl\'', # noqa: E501 + attempts=3, + ), + call( + "oc exec -n vault vault-0 -i -- sh -c \"vault kv put -mount=secret secret/region-one/config-demo secret='value123'\"", # noqa: E501 + attempts=3, + ), + call( + "oc exec -n vault vault-0 -i -- sh -c \"vault kv put -mount=secret secret/snowflake.blueprints.rhecoeng.com/config-demo secret='value123'\"", # noqa: E501 + attempts=3, + ), + call( + "cat '/tmp/footest' | oc exec -n vault vault-0 -i -- sh -c 'cat - > /tmp/vcontent'; oc exec -n vault vault-0 -i -- sh -c 'vault kv put -mount=secret secret/region-two/config-demo-file test=@/tmp/vcontent; rm /tmp/vcontent'", # noqa: E501 + attempts=3, + ), + call( + "cat '/tmp/footest' | oc exec -n vault vault-0 -i -- sh -c 'cat - > /tmp/vcontent'; oc exec -n vault vault-0 -i -- sh -c 'vault kv put -mount=secret secret/snowflake.blueprints.rhecoeng.com/config-demo-file test=@/tmp/vcontent; rm /tmp/vcontent'", # noqa: E501 + attempts=3, + ), + ] + print(mock_run_command.mock_calls) + mock_run_command.assert_has_calls(calls) + + def test_ensure_file_b64_injection_works(self): + set_module_args( + { + "parsed_secrets": test_util_datastructures.PARSED_SECRET_FILE_B64_INJECTION_TEST[ + "parsed_secrets" + ], + "vault_policies": test_util_datastructures.PARSED_SECRET_FILE_B64_INJECTION_TEST[ + "vault_policies" + ], + } + ) + with patch.object( + vault_load_parsed_secrets.VaultSecretLoader, "_run_command" + ) as mock_run_command: + stdout = "" + stderr = "" + ret = 0 + mock_run_command.return_value = ret, stdout, stderr # successful execution + + with self.assertRaises(AnsibleExitJson) as result: + vault_load_parsed_secrets.main() + self.assertTrue( + result.exception.args[0]["changed"] + ) # ensure result is changed + assert mock_run_command.call_count == 5 + + calls = [ + call( + 'echo \'length=20\nrule "charset" { charset = "abcdefghijklmnopqrstuvwxyz" min-chars = 1 }\nrule "charset" { charset = "ABCDEFGHIJKLMNOPQRSTUVWXYZ" min-chars = 1 }\nrule "charset" { charset = "0123456789" min-chars = 1 }\nrule "charset" { charset = "!@#%^&*" min-chars = 1 }\n\' | oc exec -n vault vault-0 -i -- sh -c \'cat - > /tmp/validatedPatternDefaultPolicy.hcl\';oc exec -n vault vault-0 -i -- sh -c \'vault write sys/policies/password/validatedPatternDefaultPolicy policy=@/tmp/validatedPatternDefaultPolicy.hcl\'', # noqa: E501 + attempts=3, + ), + call( + "oc exec -n vault vault-0 -i -- sh -c \"vault kv put -mount=secret secret/region-one/config-demo secret='value123'\"", # noqa: E501 + attempts=3, + ), + call( + "oc exec -n vault vault-0 -i -- sh -c \"vault kv put -mount=secret secret/snowflake.blueprints.rhecoeng.com/config-demo secret='value123'\"", # noqa: E501 + attempts=3, + ), + call( + "cat '/tmp/footest' | oc exec -n vault vault-0 -i -- sh -c 'cat - | base64 --wrap=0> /tmp/vcontent'; oc exec -n vault vault-0 -i -- sh -c 'vault kv put -mount=secret secret/region-two/config-demo-file test=@/tmp/vcontent; rm /tmp/vcontent'", # noqa: E501 + attempts=3, + ), + call( + "cat '/tmp/footest' | oc exec -n vault vault-0 -i -- sh -c 'cat - | base64 --wrap=0> /tmp/vcontent'; oc exec -n vault vault-0 -i -- sh -c 'vault kv put -mount=secret secret/snowflake.blueprints.rhecoeng.com/config-demo-file test=@/tmp/vcontent; rm /tmp/vcontent'", # noqa: E501 + attempts=3, + ), + ] + print(mock_run_command.mock_calls) + mock_run_command.assert_has_calls(calls) + + def test_ensure_b64_generate_passwords_works(self): + set_module_args( + { + "parsed_secrets": test_util_datastructures.GENERATE_POLICY_B64_TEST[ + "parsed_secrets" + ], + "vault_policies": test_util_datastructures.GENERATE_POLICY_B64_TEST[ + "vault_policies" + ], + } + ) + with patch.object( + vault_load_parsed_secrets.VaultSecretLoader, "_run_command" + ) as mock_run_command: + stdout = "" + stderr = "" + ret = 0 + mock_run_command.return_value = ret, stdout, stderr # successful execution + + with self.assertRaises(AnsibleExitJson) as result: + vault_load_parsed_secrets.main() + self.assertTrue( + result.exception.args[0]["changed"] + ) # ensure result is changed + assert mock_run_command.call_count == 4 + + calls = [ + call( + 'echo \'length=10\nrule "charset" { charset = "abcdefghijklmnopqrstuvwxyz" min-chars = 1 }\nrule "charset" { charset = "ABCDEFGHIJKLMNOPQRSTUVWXYZ" min-chars = 1 }\nrule "charset" { charset = "0123456789" min-chars = 1 }\n\' | oc exec -n vault vault-0 -i -- sh -c \'cat - > /tmp/basicPolicy.hcl\';oc exec -n vault vault-0 -i -- sh -c \'vault write sys/policies/password/basicPolicy policy=@/tmp/basicPolicy.hcl\'', # noqa: E501 + attempts=3, + ), + call( + 'echo \'length=20\nrule "charset" { charset = "abcdefghijklmnopqrstuvwxyz" min-chars = 1 }\nrule "charset" { charset = "ABCDEFGHIJKLMNOPQRSTUVWXYZ" min-chars = 1 }\nrule "charset" { charset = "0123456789" min-chars = 1 }\nrule "charset" { charset = "!@#%^&*" min-chars = 1 }\n\' | oc exec -n vault vault-0 -i -- sh -c \'cat - > /tmp/validatedPatternDefaultPolicy.hcl\';oc exec -n vault vault-0 -i -- sh -c \'vault write sys/policies/password/validatedPatternDefaultPolicy policy=@/tmp/validatedPatternDefaultPolicy.hcl\'', # noqa: E501 + attempts=3, + ), + call( + 'oc exec -n vault vault-0 -i -- sh -c "vault read -field=password sys/policies/password/basicPolicy/generate | base64 --wrap=0 | vault kv put -mount=secret region-one/config-demo secret=-"', # noqa: E501 + attempts=3, + ), + call( + 'oc exec -n vault vault-0 -i -- sh -c "vault read -field=password sys/policies/password/basicPolicy/generate | base64 --wrap=0 | vault kv put -mount=secret snowflake.blueprints.rhecoeng.com/config-demo secret=-"', # noqa: E501 + attempts=3, + ), + ] + print(mock_run_command.mock_calls) + mock_run_command.assert_has_calls(calls) + + +if __name__ == "__main__": + unittest.main() diff --git a/common/ansible/tests/unit/v2/test-file-contents b/common/ansible/tests/unit/v2/test-file-contents new file mode 100644 index 00000000..49c9a88c --- /dev/null +++ b/common/ansible/tests/unit/v2/test-file-contents @@ -0,0 +1 @@ +This space intentionally left blank diff --git a/common/ansible/tests/unit/v2/test-file-contents.b64 b/common/ansible/tests/unit/v2/test-file-contents.b64 new file mode 100644 index 00000000..da896ba7 --- /dev/null +++ b/common/ansible/tests/unit/v2/test-file-contents.b64 @@ -0,0 +1 @@ +VGhpcyBzcGFjZSBpbnRlbnRpb25hbGx5IGxlZnQgYmxhbmsK \ No newline at end of file diff --git a/common/ansible/tests/unit/v2/values-secret-v2-base-k8s-backend.yaml b/common/ansible/tests/unit/v2/values-secret-v2-base-k8s-backend.yaml new file mode 100644 index 00000000..7194ebc3 --- /dev/null +++ b/common/ansible/tests/unit/v2/values-secret-v2-base-k8s-backend.yaml @@ -0,0 +1,9 @@ +version: "2.0" + +backingStore: kubernetes + +secrets: + - name: config-demo + fields: + - name: secret + value: secret diff --git a/common/ansible/tests/unit/v2/values-secret-v2-base-none-backend.yaml b/common/ansible/tests/unit/v2/values-secret-v2-base-none-backend.yaml new file mode 100644 index 00000000..4e1e3cd2 --- /dev/null +++ b/common/ansible/tests/unit/v2/values-secret-v2-base-none-backend.yaml @@ -0,0 +1,11 @@ +version: "2.0" + +backingStore: none + +secrets: + - name: config-demo + targetNamespaces: + - default + fields: + - name: secret + value: secret diff --git a/common/ansible/tests/unit/v2/values-secret-v2-base-unknown-backend.yaml b/common/ansible/tests/unit/v2/values-secret-v2-base-unknown-backend.yaml new file mode 100644 index 00000000..e1f4c6d5 --- /dev/null +++ b/common/ansible/tests/unit/v2/values-secret-v2-base-unknown-backend.yaml @@ -0,0 +1,9 @@ +version: "2.0" + +backingStore: unknown + +secrets: + - name: config-demo + fields: + - name: secret + value: secret diff --git a/common/ansible/tests/unit/v2/values-secret-v2-block-yamlstring.yaml b/common/ansible/tests/unit/v2/values-secret-v2-block-yamlstring.yaml new file mode 100644 index 00000000..84165f69 --- /dev/null +++ b/common/ansible/tests/unit/v2/values-secret-v2-block-yamlstring.yaml @@ -0,0 +1,16 @@ +version: "2.0" + +secrets: + - name: config-demo + fields: + - name: sshprivkey + onMissingValue: error + value: |- + ssh-rsa oNb/kAvwdQl+FKdwzzKo5rnGIB68UOxWoaKPnKdgF/ts67CDBslWGnpUZCpp8TdaxfHmpoyA6nutMwQw8OAMEUybxvilDn+ZVJ/5qgfRBdi8wLKRLTIj0v+ZW7erN9yuZG53xUQAaQjivM3cRyNLIZ9torShYaYwD1UTTDkV97RMfNDlWI5f5FGRvfy429ZfCwbUWUbijrcv/mWc/uO3x/+MBXwa4f8ubzEYlrt4yH/Vbpzs67kE9UJ9z1zurFUFJydy1ZDAdKSiBS91ImI3ccKnbz0lji2bgSYR0Wp1IQhzSpjyJU2rIu9HAEUh85Rwf2jakfLpMcg/hSBer3sG kilroy@example.com + - name: sshpubkey + onMissingValue: error + value: |- + -----BEGIN OPENSSH PRIVATE KEY----- + TtzxGgWrNerAr1hzUqPW2xphF/Aur1rQXSLv4J7frEJxNED6u/eScsNgwJMGXwRx7QYVohh0ARHVhJdUzJK7pEIphi4BGw== + wlo+oQsi828b47SKZB8/K9dbeLlLiXh9/hu47MGpeGHZsKbjAdauncuw+YUDDN2EADJjasNMZHjxYhXKtqDjXTIw1X1n0Q== + -----END OPENSSH PRIVATE KEY----- diff --git a/common/ansible/tests/unit/v2/values-secret-v2-default-annotations.yaml b/common/ansible/tests/unit/v2/values-secret-v2-default-annotations.yaml new file mode 100644 index 00000000..af3e2f9b --- /dev/null +++ b/common/ansible/tests/unit/v2/values-secret-v2-default-annotations.yaml @@ -0,0 +1,13 @@ +--- +version: "2.0" + +annotations: + test-annotation: 42 + +secrets: + - name: test-secret + fields: + - name: username + value: user + - name: password + value: testpass diff --git a/common/ansible/tests/unit/v2/values-secret-v2-default-labels.yaml b/common/ansible/tests/unit/v2/values-secret-v2-default-labels.yaml new file mode 100644 index 00000000..56af6586 --- /dev/null +++ b/common/ansible/tests/unit/v2/values-secret-v2-default-labels.yaml @@ -0,0 +1,11 @@ +--- +version: "2.0" + +defaultLabels: + testlabel: 4 + +secrets: + - name: test-secret + fields: + - name: username + value: user diff --git a/common/ansible/tests/unit/v2/values-secret-v2-default-namespace.yaml b/common/ansible/tests/unit/v2/values-secret-v2-default-namespace.yaml new file mode 100644 index 00000000..a0f4db63 --- /dev/null +++ b/common/ansible/tests/unit/v2/values-secret-v2-default-namespace.yaml @@ -0,0 +1,8 @@ +--- +version: "2.0" + +secrets: + test-secret: + fields: + - name: username + value: user diff --git a/common/ansible/tests/unit/v2/values-secret-v2-file-contents-b64.yaml b/common/ansible/tests/unit/v2/values-secret-v2-file-contents-b64.yaml new file mode 100644 index 00000000..47ed7219 --- /dev/null +++ b/common/ansible/tests/unit/v2/values-secret-v2-file-contents-b64.yaml @@ -0,0 +1,9 @@ +--- +version: "2.0" + +secrets: + - name: test-secret + fields: + - name: username + path: ~/test-file-contents + base64: true diff --git a/common/ansible/tests/unit/v2/values-secret-v2-file-contents-double-b64.yaml b/common/ansible/tests/unit/v2/values-secret-v2-file-contents-double-b64.yaml new file mode 100644 index 00000000..3a968eca --- /dev/null +++ b/common/ansible/tests/unit/v2/values-secret-v2-file-contents-double-b64.yaml @@ -0,0 +1,9 @@ +--- +version: "2.0" + +secrets: + - name: test-secret + fields: + - name: username + path: ~/test-file-contents.b64 + base64: true diff --git a/common/ansible/tests/unit/v2/values-secret-v2-file-contents.yaml b/common/ansible/tests/unit/v2/values-secret-v2-file-contents.yaml new file mode 100644 index 00000000..e2da90c2 --- /dev/null +++ b/common/ansible/tests/unit/v2/values-secret-v2-file-contents.yaml @@ -0,0 +1,8 @@ +--- +version: "2.0" + +secrets: + - name: test-secret + fields: + - name: username + path: ~/test-file-contents diff --git a/common/ansible/tests/unit/v2/values-secret-v2-generic-onlygenerate.yaml b/common/ansible/tests/unit/v2/values-secret-v2-generic-onlygenerate.yaml new file mode 100644 index 00000000..46992af1 --- /dev/null +++ b/common/ansible/tests/unit/v2/values-secret-v2-generic-onlygenerate.yaml @@ -0,0 +1,33 @@ +version: "2.0" + +vaultPolicies: + basicPolicy: | + length=10 + rule "charset" { charset = "abcdefghijklmnopqrstuvwxyz" min-chars = 1 } + rule "charset" { charset = "ABCDEFGHIJKLMNOPQRSTUVWXYZ" min-chars = 1 } + rule "charset" { charset = "0123456789" min-chars = 1 } + + advancedPolicy: | + length=20 + rule "charset" { charset = "abcdefghijklmnopqrstuvwxyz" min-chars = 1 } + rule "charset" { charset = "ABCDEFGHIJKLMNOPQRSTUVWXYZ" min-chars = 1 } + rule "charset" { charset = "0123456789" min-chars = 1 } + rule "charset" { charset = "!@#%^&*" min-chars = 1 } + +secrets: + - name: config-demo + targetNamespaces: + - default + vaultMount: foo + vaultPrefixes: + - region-one + - snowflake.blueprints.rhecoeng.com + fields: + - name: secret + onMissingValue: generate + override: true + vaultPolicy: basicPolicy + - name: secret2 + onMissingValue: generate + override: true + vaultPolicy: advancedPolicy diff --git a/common/ansible/tests/unit/v2/values-secret-v2-ini-file-b64.yaml b/common/ansible/tests/unit/v2/values-secret-v2-ini-file-b64.yaml new file mode 100644 index 00000000..ff08d20a --- /dev/null +++ b/common/ansible/tests/unit/v2/values-secret-v2-ini-file-b64.yaml @@ -0,0 +1,23 @@ +version: "2.0" +secrets: + - name: aws + fields: + - name: aws_access_key_id + ini_file: '~/aws-example.ini' + ini_section: default + ini_key: aws_access_key_id + - name: aws_secret_access_key + ini_file: '~/aws-example.ini' + ini_key: aws_secret_access_key + - name: awsb64 + fields: + - name: aws_access_key_id + ini_file: '~/aws-example.ini' + ini_section: default + ini_key: aws_access_key_id + base64: true + - name: aws_secret_access_key + ini_file: '~/aws-example.ini' + ini_section: default + ini_key: aws_secret_access_key + base64: true diff --git a/common/ansible/tests/unit/v2/values-secret-v2-more-namespaces.yaml b/common/ansible/tests/unit/v2/values-secret-v2-more-namespaces.yaml new file mode 100644 index 00000000..be409af7 --- /dev/null +++ b/common/ansible/tests/unit/v2/values-secret-v2-more-namespaces.yaml @@ -0,0 +1,11 @@ +--- +version: "2.0" + +secrets: + - name: test-secret + targetNamespaces: + - default + - extra + fields: + - name: username + value: user diff --git a/common/ansible/tests/unit/v2/values-secret-v2-nondefault-namespace.yaml b/common/ansible/tests/unit/v2/values-secret-v2-nondefault-namespace.yaml new file mode 100644 index 00000000..a0f4db63 --- /dev/null +++ b/common/ansible/tests/unit/v2/values-secret-v2-nondefault-namespace.yaml @@ -0,0 +1,8 @@ +--- +version: "2.0" + +secrets: + test-secret: + fields: + - name: username + value: user diff --git a/common/ansible/tests/unit/v2/values-secret-v2-none-no-targetnamespaces.yaml b/common/ansible/tests/unit/v2/values-secret-v2-none-no-targetnamespaces.yaml new file mode 100644 index 00000000..2a5ef0b6 --- /dev/null +++ b/common/ansible/tests/unit/v2/values-secret-v2-none-no-targetnamespaces.yaml @@ -0,0 +1,33 @@ +version: "2.0" + +backingStore: vault + +vaultPolicies: + basicPolicy: | + length=10 + rule "charset" { charset = "abcdefghijklmnopqrstuvwxyz" min-chars = 1 } + rule "charset" { charset = "ABCDEFGHIJKLMNOPQRSTUVWXYZ" min-chars = 1 } + rule "charset" { charset = "0123456789" min-chars = 1 } + + advancedPolicy: | + length=20 + rule "charset" { charset = "abcdefghijklmnopqrstuvwxyz" min-chars = 1 } + rule "charset" { charset = "ABCDEFGHIJKLMNOPQRSTUVWXYZ" min-chars = 1 } + rule "charset" { charset = "0123456789" min-chars = 1 } + rule "charset" { charset = "!@#%^&*" min-chars = 1 } + +secrets: + - name: config-demo + vaultMount: foo + vaultPrefixes: + - region-one + - snowflake.blueprints.rhecoeng.com + fields: + - name: secret + onMissingValue: generate + override: true + vaultPolicy: basicPolicy + - name: secret2 + onMissingValue: generate + override: true + vaultPolicy: advancedPolicy diff --git a/common/ansible/tests/unit/v2/values-secret-v2-override-labels.yaml b/common/ansible/tests/unit/v2/values-secret-v2-override-labels.yaml new file mode 100644 index 00000000..13a460be --- /dev/null +++ b/common/ansible/tests/unit/v2/values-secret-v2-override-labels.yaml @@ -0,0 +1,13 @@ +--- +version: "2.0" + +defaultLabels: + testlabel: 4 + +secrets: + - name: test-secret + labels: + overridelabel: 42 + fields: + - name: username + value: user diff --git a/common/ansible/tests/unit/v2/values-secret-v2-override-namespace.yaml b/common/ansible/tests/unit/v2/values-secret-v2-override-namespace.yaml new file mode 100644 index 00000000..ad53cf77 --- /dev/null +++ b/common/ansible/tests/unit/v2/values-secret-v2-override-namespace.yaml @@ -0,0 +1,10 @@ +--- +version: "2.0" + +secretStoreNamespace: 'overridden-namespace' + +secrets: + - name: test-secret + fields: + - name: username + value: user diff --git a/common/ansible/tests/unit/v2/values-secret-v2-override-type-none.yaml b/common/ansible/tests/unit/v2/values-secret-v2-override-type-none.yaml new file mode 100644 index 00000000..1d110671 --- /dev/null +++ b/common/ansible/tests/unit/v2/values-secret-v2-override-type-none.yaml @@ -0,0 +1,14 @@ +--- +version: "2.0" + +# This is the actual default +defaultNamespace: 'validated-patterns-secrets' + +secrets: + - name: test-secret + type: 'user-specified' + targetNamespaces: + - default + fields: + - name: username + value: user diff --git a/common/ansible/tests/unit/v2/values-secret-v2-override-type.yaml b/common/ansible/tests/unit/v2/values-secret-v2-override-type.yaml new file mode 100644 index 00000000..1bf8e369 --- /dev/null +++ b/common/ansible/tests/unit/v2/values-secret-v2-override-type.yaml @@ -0,0 +1,12 @@ +--- +version: "2.0" + +# This is the actual default +defaultNamespace: 'validated-patterns-secrets' + +secrets: + - name: test-secret + type: 'user-specified' + fields: + - name: username + value: user diff --git a/common/ansible/tests/unit/v2/values-secret-v2-secret-binary-b64.yaml b/common/ansible/tests/unit/v2/values-secret-v2-secret-binary-b64.yaml new file mode 100644 index 00000000..579c7d6e --- /dev/null +++ b/common/ansible/tests/unit/v2/values-secret-v2-secret-binary-b64.yaml @@ -0,0 +1,10 @@ +version: "2.0" + +secrets: + - name: secret + fields: + - name: secret + # Should contain 8, 6, 7, 5, 3, 0, 9 in binary + path: '/tmp/testbinfile.bin' + onMissingValue: error + base64: true diff --git a/common/clustergroup/templates/imperative/unsealjob.yaml b/common/clustergroup/templates/imperative/unsealjob.yaml index d0dbc3c7..4db14be3 100644 --- a/common/clustergroup/templates/imperative/unsealjob.yaml +++ b/common/clustergroup/templates/imperative/unsealjob.yaml @@ -1,3 +1,4 @@ +{{- if eq .Values.global.secretStore.backend "vault" | default "vault" }} {{- if not (eq .Values.enabled "plumbing") }} {{- if $.Values.clusterGroup.isHubCluster }} --- @@ -56,3 +57,4 @@ spec: restartPolicy: Never {{- end }} {{- end }} +{{- end }} diff --git a/common/clustergroup/values.yaml b/common/clustergroup/values.yaml index bb3a6e27..c74db48c 100644 --- a/common/clustergroup/values.yaml +++ b/common/clustergroup/values.yaml @@ -1,6 +1,8 @@ global: extraValueFiles: [] pattern: common + secretStore: + backend: "vault" targetRevision: main options: useCSV: True diff --git a/common/golang-external-secrets/templates/golang-external-secrets-hub-secretstore.yaml b/common/golang-external-secrets/templates/golang-external-secrets-hub-secretstore.yaml deleted file mode 100644 index fc0b410f..00000000 --- a/common/golang-external-secrets/templates/golang-external-secrets-hub-secretstore.yaml +++ /dev/null @@ -1,40 +0,0 @@ -apiVersion: external-secrets.io/v1beta1 -kind: ClusterSecretStore -metadata: - name: vault-backend - namespace: golang-external-secrets -spec: - provider: - vault: - server: https://vault-vault.{{ .Values.global.hubClusterDomain }} - path: secret - # Version of KV backend - version: v2 -{{- if .Values.golangExternalSecrets.caProvider.enabled }} -{{ if .Values.clusterGroup.isHubCluster }} - caProvider: - type: {{ .Values.golangExternalSecrets.caProvider.vaultHostCluster.type }} - name: {{ .Values.golangExternalSecrets.caProvider.vaultHostCluster.name }} - key: {{ .Values.golangExternalSecrets.caProvider.vaultHostCluster.key }} - namespace: {{ .Values.golangExternalSecrets.caProvider.vaultHostCluster.namespace }} -{{ else }} - caProvider: - type: {{ .Values.golangExternalSecrets.caProvider.vaultClientCluster.type }} - name: {{ .Values.golangExternalSecrets.caProvider.vaultClientCluster.name }} - key: {{ .Values.golangExternalSecrets.caProvider.vaultClientCluster.key }} - namespace: {{ .Values.golangExternalSecrets.caProvider.vaultClientCluster.namespace }} -{{ end }} -{{- end }} - auth: - kubernetes: -{{ if .Values.clusterGroup.isHubCluster }} - mountPath: {{ .Values.mountPath }} - role: {{ .Values.mountRole }} -{{ else }} - mountPath: {{ $.Values.global.clusterDomain }} - role: {{ $.Values.global.clusterDomain }}-role -{{ end }} - secretRef: - name: golang-external-secrets - namespace: golang-external-secrets - key: "token" diff --git a/common/golang-external-secrets/templates/kubernetes/golang-external-secrets-hub-role.yaml b/common/golang-external-secrets/templates/kubernetes/golang-external-secrets-hub-role.yaml new file mode 100644 index 00000000..05ce87a7 --- /dev/null +++ b/common/golang-external-secrets/templates/kubernetes/golang-external-secrets-hub-role.yaml @@ -0,0 +1,22 @@ +{{- if and (eq .Values.global.secretStore.backend "kubernetes") (eq .Values.clusterGroup.isHubCluster true) }} +--- +apiVersion: rbac.authorization.k8s.io/v1 +kind: Role +metadata: + namespace: {{ .Values.golangExternalSecrets.kubernetes.remoteNamespace }} + name: golang-external-secrets +rules: +- apiGroups: [""] + resources: + - secrets + verbs: + - get + - list + - watch +- apiGroups: + - authorization.k8s.io + resources: + - selfsubjectrulesreviews + verbs: + - create +{{- end }} diff --git a/common/golang-external-secrets/templates/kubernetes/golang-external-secrets-hub-secretstore.yaml b/common/golang-external-secrets/templates/kubernetes/golang-external-secrets-hub-secretstore.yaml new file mode 100644 index 00000000..62253f1f --- /dev/null +++ b/common/golang-external-secrets/templates/kubernetes/golang-external-secrets-hub-secretstore.yaml @@ -0,0 +1,34 @@ +{{- $backend := .Values.global.secretStore.backend | default "vault" }} +{{- if eq $backend "kubernetes" }} +--- +apiVersion: external-secrets.io/v1beta1 +kind: ClusterSecretStore +metadata: + name: {{ $backend }}-backend + namespace: golang-external-secrets +spec: + provider: + kubernetes: + remoteNamespace: {{ .Values.golangExternalSecrets.kubernetes.remoteNamespace }} + server: + url: {{ .Values.golangExternalSecrets.kubernetes.server.url }} +{{- if .Values.golangExternalSecrets.caProvider.enabled }} +{{- if .Values.clusterGroup.isHubCluster }} + caProvider: + type: {{ .Values.golangExternalSecrets.caProvider.hostCluster.type }} + name: {{ .Values.golangExternalSecrets.caProvider.hostCluster.name }} + key: {{ .Values.golangExternalSecrets.caProvider.hostCluster.key }} + namespace: {{ .Values.golangExternalSecrets.caProvider.hostCluster.namespace }} +{{- else }} + caProvider: + type: {{ .Values.golangExternalSecrets.caProvider.clientCluster.type }} + name: {{ .Values.golangExternalSecrets.caProvider.clientCluster.name }} + key: {{ .Values.golangExternalSecrets.caProvider.clientCluster.key }} + namespace: {{ .Values.golangExternalSecrets.caProvider.clientCluster.namespace }} +{{- end }} +{{- end }} + auth: + serviceAccount: + name: golang-external-secrets + namespace: golang-external-secrets +{{- end }} diff --git a/common/golang-external-secrets/templates/vault/golang-external-secrets-hub-secretstore.yaml b/common/golang-external-secrets/templates/vault/golang-external-secrets-hub-secretstore.yaml new file mode 100644 index 00000000..8fdd4ab0 --- /dev/null +++ b/common/golang-external-secrets/templates/vault/golang-external-secrets-hub-secretstore.yaml @@ -0,0 +1,44 @@ +{{- $backend := .Values.global.secretStore.backend | default "vault" }} +{{- if eq $backend "vault" }} +--- +apiVersion: external-secrets.io/v1beta1 +kind: ClusterSecretStore +metadata: + name: {{ $backend }}-backend + namespace: golang-external-secrets +spec: + provider: + vault: + server: https://vault-vault.{{ .Values.global.hubClusterDomain }} + path: secret + # Version of KV backend + version: v2 +{{- if .Values.golangExternalSecrets.caProvider.enabled }} +{{ if .Values.clusterGroup.isHubCluster }} + caProvider: + type: {{ .Values.golangExternalSecrets.caProvider.hostCluster.type }} + name: {{ .Values.golangExternalSecrets.caProvider.hostCluster.name }} + key: {{ .Values.golangExternalSecrets.caProvider.hostCluster.key }} + namespace: {{ .Values.golangExternalSecrets.caProvider.hostCluster.namespace }} +{{ else }} + caProvider: + type: {{ .Values.golangExternalSecrets.caProvider.clientCluster.type }} + name: {{ .Values.golangExternalSecrets.caProvider.clientCluster.name }} + key: {{ .Values.golangExternalSecrets.caProvider.clientCluster.key }} + namespace: {{ .Values.golangExternalSecrets.caProvider.clientCluster.namespace }} +{{ end }} +{{- end }} + auth: + kubernetes: +{{ if .Values.clusterGroup.isHubCluster }} + mountPath: {{ .Values.golangExternalSecrets.vault.mountPath }} + role: {{ .Values.golangExternalSecrets.rbac.rolename }} +{{ else }} + mountPath: {{ $.Values.global.clusterDomain }} + role: {{ $.Values.global.clusterDomain }}-role +{{ end }} + secretRef: + name: golang-external-secrets + namespace: golang-external-secrets + key: "token" +{{- end }} diff --git a/common/golang-external-secrets/values.yaml b/common/golang-external-secrets/values.yaml index 8a37f554..6ecd32f2 100644 --- a/common/golang-external-secrets/values.yaml +++ b/common/golang-external-secrets/values.yaml @@ -1,18 +1,25 @@ --- -# Eventually we should aim to move these two under the golangExternalSecrets key -mountPath: "hub" -mountRole: "hub-role" - golangExternalSecrets: - # This controls how ESO connects to vault + rbac: + rolename: "hub-role" + + kubernetes: + remoteNamespace: "validated-patterns-secrets" + server: + url: 'https://kubernetes.default' + + vault: + mountPath: "hub" + + # This controls how ESO connects to vault caProvider: enabled: true # If vault is exposed via a route that is signed by a non internal CA you might want to disable this - vaultHostCluster: + hostCluster: type: ConfigMap name: kube-root-ca.crt key: ca.crt namespace: golang-external-secrets - vaultClientCluster: + clientCluster: type: Secret name: hub-ca key: hub-kube-root-ca.crt @@ -22,6 +29,9 @@ global: hubClusterDomain: hub.example.com clusterDomain: foo.example.com + secretStore: + backend: "vault" + clusterGroup: isHubCluster: true diff --git a/common/hashicorp-vault/README.md b/common/hashicorp-vault/README.md index 84065ffd..26252b7e 100644 --- a/common/hashicorp-vault/README.md +++ b/common/hashicorp-vault/README.md @@ -10,12 +10,6 @@ ## Patches -### Issue 9136 - -**IMPORTANT**: Due to the fact that 'null' values do not work in helm charts -([GH#9136](https://github.com/helm/helm/issues/9136)), we need to patch the -chart to skip setting the host. - ### Issue 674 In order to be able to use vault ssl we need to patch the helm chart to fix diff --git a/common/hashicorp-vault/charts/vault-0.27.0.tgz b/common/hashicorp-vault/charts/vault-0.27.0.tgz index 574b3e74..24a07991 100644 Binary files a/common/hashicorp-vault/charts/vault-0.27.0.tgz and b/common/hashicorp-vault/charts/vault-0.27.0.tgz differ diff --git a/common/hashicorp-vault/local-patches/0002-Allow-per-service-annotations.patch b/common/hashicorp-vault/local-patches/0001-Allow-per-service-annotations.patch similarity index 100% rename from common/hashicorp-vault/local-patches/0002-Allow-per-service-annotations.patch rename to common/hashicorp-vault/local-patches/0001-Allow-per-service-annotations.patch diff --git a/common/hashicorp-vault/local-patches/0001-patch-server-route.patch b/common/hashicorp-vault/local-patches/0001-patch-server-route.patch deleted file mode 100644 index edc22c57..00000000 --- a/common/hashicorp-vault/local-patches/0001-patch-server-route.patch +++ /dev/null @@ -1,28 +0,0 @@ -diff -up vault/values.yaml.orig vault/values.yaml ---- vault/values.yaml.orig 2022-09-05 20:42:02.468428184 +0200 -+++ vault/values.yaml 2022-09-05 20:42:05.218435871 +0200 -@@ -406,7 +406,8 @@ server: - - labels: {} - annotations: {} -- host: chart-example.local -+ #host: chart-example.local -+ host: null - # tls will be passed directly to the route's TLS config, which - # can be used to configure other termination methods that terminate - # TLS at the router -diff -up vault/values.schema.json.orig vault/values.schema.json ---- vault/values.schema.json.orig 2022-09-11 21:00:34.834334961 +0200 -+++ vault/values.schema.json 2022-09-11 21:00:57.190368032 +0200 -@@ -838,7 +838,10 @@ - "type": "boolean" - }, - "host": { -- "type": "string" -+ "type": [ -+ "null", -+ "string" -+ ] - }, - "labels": { - "type": "object" diff --git a/common/hashicorp-vault/update-helm-dependency.sh b/common/hashicorp-vault/update-helm-dependency.sh index 76e4ac14..2551d888 100755 --- a/common/hashicorp-vault/update-helm-dependency.sh +++ b/common/hashicorp-vault/update-helm-dependency.sh @@ -1,5 +1,5 @@ #!/bin/bash -set -eu +set -eu -o pipefail # Get the version of the dependency and then unquote it TMPVER=$(sed -e '1,/^version:/ d' "Chart.yaml" | grep "version:" | awk '{ print $2 }') diff --git a/common/scripts/determine-main-clustergroup.sh b/common/scripts/determine-main-clustergroup.sh new file mode 100755 index 00000000..6271dbad --- /dev/null +++ b/common/scripts/determine-main-clustergroup.sh @@ -0,0 +1,16 @@ +#!/bin/bash + +PATTERN_DIR="$1" + +if [ -z "$PATTERN_DIR" ]; then + PATTERN_DIR="." +fi + +CGNAME=$(yq '.main.clusterGroupName' "$PATTERN_DIR/values-global.yaml") + +if [ -z "$CGNAME" ] || [ "$CGNAME" == "null" ]; then + echo "Error - cannot detrmine clusterGroupName" + exit 1 +fi + +echo "$CGNAME" diff --git a/common/scripts/determine-pattern-name.sh b/common/scripts/determine-pattern-name.sh new file mode 100755 index 00000000..fb503fe6 --- /dev/null +++ b/common/scripts/determine-pattern-name.sh @@ -0,0 +1,15 @@ +#!/bin/sh + +PATTERN_DIR="$1" + +if [ -z "$PATTERN_DIR" ]; then + PATTERN_DIR="." +fi + +PATNAME=$(yq '.global.pattern' "$PATTERN_DIR/values-global.yaml" 2>/dev/null) + +if [ -z "$PATNAME" ] || [ "$PATNAME" == "null" ]; then + PATNAME="$(basename "$PWD")" +fi + +echo "$PATNAME" diff --git a/common/scripts/determine-secretstore-backend.sh b/common/scripts/determine-secretstore-backend.sh new file mode 100755 index 00000000..ef784790 --- /dev/null +++ b/common/scripts/determine-secretstore-backend.sh @@ -0,0 +1,15 @@ +#!/bin/sh + +PATTERN_DIR="$1" + +if [ -z "$PATTERN_DIR" ]; then + PATTERN_DIR="." +fi + +BACKEND=$(yq '.global.secretStore.backend' "$PATTERN_DIR/values-global.yaml" 2>/dev/null) + +if [ -z "$BACKEND" -o "$BACKEND" == "null" ]; then + BACKEND="vault" +fi + +echo "$BACKEND" diff --git a/common/scripts/display-secrets-info.sh b/common/scripts/display-secrets-info.sh new file mode 100755 index 00000000..124a3454 --- /dev/null +++ b/common/scripts/display-secrets-info.sh @@ -0,0 +1,30 @@ +#!/usr/bin/env bash +set -eu + +get_abs_filename() { + # $1 : relative filename + echo "$(cd "$(dirname "$1")" && pwd)/$(basename "$1")" +} + +SCRIPT=$(get_abs_filename "$0") +SCRIPTPATH=$(dirname "${SCRIPT}") +COMMONPATH=$(dirname "${SCRIPTPATH}") +PATTERNPATH=$(dirname "${COMMONPATH}") +ANSIBLEPATH="$(dirname ${SCRIPTPATH})/ansible" +PLAYBOOKPATH="${ANSIBLEPATH}/playbooks" + +export ANSIBLE_CONFIG="${ANSIBLEPATH}/ansible.cfg" + +if [ "$#" -ge 1 ]; then + export VALUES_SECRET=$(get_abs_filename "${1}") +fi + +if [[ "$#" == 2 ]]; then + SECRETS_BACKING_STORE="$2" +else + SECRETS_BACKING_STORE="$($SCRIPTPATH/determine-secretstore-backend.sh)" +fi + +PATTERN_NAME=$(basename "`pwd`") + +ansible-playbook -e pattern_name="${PATTERN_NAME}" -e pattern_dir="${PATTERNPATH}" -e secrets_backing_store="${SECRETS_BACKING_STORE}" -e override_no_log=false "${PLAYBOOKPATH}/process_secrets/display_secrets_info.yml" diff --git a/common/scripts/load-k8s-secrets.sh b/common/scripts/load-k8s-secrets.sh new file mode 100755 index 00000000..33c2f9a5 --- /dev/null +++ b/common/scripts/load-k8s-secrets.sh @@ -0,0 +1,19 @@ +#!/usr/bin/env bash +set -eu + +get_abs_filename() { + # $1 : relative filename + echo "$(cd "$(dirname "$1")" && pwd)/$(basename "$1")" +} + +SCRIPT=$(get_abs_filename "$0") +SCRIPTPATH=$(dirname "${SCRIPT}") +COMMONPATH=$(dirname "${SCRIPTPATH}") +PATTERNPATH=$(dirname "${COMMONPATH}") +ANSIBLEPATH="$(dirname ${SCRIPTPATH})/ansible" +PLAYBOOKPATH="${ANSIBLEPATH}/playbooks" +export ANSIBLE_CONFIG="${ANSIBLEPATH}/ansible.cfg" + +PATTERN_NAME=${1:-$(basename "`pwd`")} + +ansible-playbook -e pattern_name="${PATTERN_NAME}" -e pattern_dir="${PATTERNPATH}" "${PLAYBOOKPATH}/k8s_secrets/k8s_secrets.yml" diff --git a/common/scripts/manage-secret-app.sh b/common/scripts/manage-secret-app.sh new file mode 100755 index 00000000..1ea0d0bb --- /dev/null +++ b/common/scripts/manage-secret-app.sh @@ -0,0 +1,49 @@ +#!/bin/sh + +APP=$1 +STATE=$2 + +MAIN_CLUSTERGROUP_FILE="./values-$(common/scripts/determine-main-clustergroup.sh).yaml" +MAIN_CLUSTERGROUP_PROJECT="$(common/scripts/determine-main-clustergroup.sh)" + +case "$APP" in + "vault") + APP_NAME="vault" + NAMESPACE="vault" + PROJECT="$MAIN_CLUSTERGROUP_PROJECT" + CHART_LOCATION="common/hashicorp-vault" + ;; + "golang-external-secrets") + APP_NAME="golang-external-secrets" + NAMESPACE="golang-external-secrets" + PROJECT="$MAIN_CLUSTERGROUP_PROJECT" + CHART_LOCATION="common/golang-external-secrets" + ;; + *) + echo "Error - cannot manage $APP can only manage vault and golang-external-secrets" + exit 1 + ;; +esac + +case "$STATE" in + "present") + common/scripts/manage-secret-namespace.sh "$NAMESPACE" "$STATE" + + RES=$(yq ".clusterGroup.applications[] | select(.path == \"$CHART_LOCATION\")" "$MAIN_CLUSTERGROUP_FILE" 2>/dev/null) + if [ -z "$RES" ]; then + echo "Application with chart location $CHART_LOCATION not found, adding" + yq -i ".clusterGroup.applications.$APP_NAME = { \"name\": \"$APP_NAME\", \"namespace\": \"$NAMESPACE\", \"project\": \"$PROJECT\", \"path\": \"$CHART_LOCATION\" }" "$MAIN_CLUSTERGROUP_FILE" + fi + ;; + "absent") + common/scripts/manage-secret-namespace.sh "$NAMESPACE" "$STATE" + echo "Removing application wth chart location $CHART_LOCATION" + yq -i "del(.clusterGroup.applications[] | select(.path == \"$CHART_LOCATION\"))" "$MAIN_CLUSTERGROUP_FILE" + ;; + *) + echo "$STATE not supported" + exit 1 + ;; +esac + +exit 0 diff --git a/common/scripts/manage-secret-namespace.sh b/common/scripts/manage-secret-namespace.sh new file mode 100755 index 00000000..bcb06742 --- /dev/null +++ b/common/scripts/manage-secret-namespace.sh @@ -0,0 +1,28 @@ +#!/bin/sh + +NAMESPACE=$1 +STATE=$2 + +MAIN_CLUSTERGROUP_FILE="./values-$(common/scripts/determine-main-clustergroup.sh).yaml" +MAIN_CLUSTERGROUP_PROJECT="$(common/scripts/determine-main-clustergroup.sh)" + +case "$STATE" in + "present") + + RES=$(yq ".clusterGroup.namespaces[] | select(. == \"$NAMESPACE\")" "$MAIN_CLUSTERGROUP_FILE" 2>/dev/null) + if [ -z "$RES" ]; then + echo "Namespace $NAMESPACE not found, adding" + yq -i ".clusterGroup.namespaces += [ \"$NAMESPACE\" ]" "$MAIN_CLUSTERGROUP_FILE" + fi + ;; + "absent") + echo "Removing namespace $NAMESPACE" + yq -i "del(.clusterGroup.namespaces[] | select(. == \"$NAMESPACE\"))" "$MAIN_CLUSTERGROUP_FILE" + ;; + *) + echo "$STATE not supported" + exit 1 + ;; +esac + +exit 0 diff --git a/common/scripts/pattern-util.sh b/common/scripts/pattern-util.sh index 745131b5..9cec19fa 100755 --- a/common/scripts/pattern-util.sh +++ b/common/scripts/pattern-util.sh @@ -35,9 +35,10 @@ if [ $(version "${PODMAN_VERSION}") -lt $(version "4.3.0") ]; then PODMAN_ARGS="-v ${HOME}:/root" else # We do not rely on bash's $UID and $GID because on MacOSX $GID is not set + MYNAME=$(id -n -u) MYUID=$(id -u) MYGID=$(id -g) - PODMAN_ARGS="--user ${MYUID}:${MYGID} --userns keep-id:uid=${MYUID},gid=${MYGID}" + PODMAN_ARGS="--passwd-entry ${MYNAME}:x:${MYUID}:${MYGID}:/pattern-home:/bin/bash --user ${MYUID}:${MYGID} --userns keep-id:uid=${MYUID},gid=${MYGID}" fi if [ -n "$KUBECONFIG" ]; then diff --git a/common/scripts/process-secrets.sh b/common/scripts/process-secrets.sh new file mode 100755 index 00000000..509d6d71 --- /dev/null +++ b/common/scripts/process-secrets.sh @@ -0,0 +1,20 @@ +#!/usr/bin/env bash +set -eu + +get_abs_filename() { + # $1 : relative filename + echo "$(cd "$(dirname "$1")" && pwd)/$(basename "$1")" +} + +SCRIPT=$(get_abs_filename "$0") +SCRIPTPATH=$(dirname "${SCRIPT}") +COMMONPATH=$(dirname "${SCRIPTPATH}") +PATTERNPATH=$(dirname "${COMMONPATH}") +ANSIBLEPATH="$(dirname ${SCRIPTPATH})/ansible" +PLAYBOOKPATH="${ANSIBLEPATH}/playbooks" +export ANSIBLE_CONFIG="${ANSIBLEPATH}/ansible.cfg" + +PATTERN_NAME=${1:-$(basename "`pwd`")} +SECRETS_BACKING_STORE="$($SCRIPTPATH/determine-secretstore-backend.sh)" + +ansible-playbook -e pattern_name="${PATTERN_NAME}" -e pattern_dir="${PATTERNPATH}" -e secrets_backing_store="${SECRETS_BACKING_STORE}" "${PLAYBOOKPATH}/process_secrets/process_secrets.yml" diff --git a/common/scripts/set-secret-backend.sh b/common/scripts/set-secret-backend.sh new file mode 100755 index 00000000..e07b15bf --- /dev/null +++ b/common/scripts/set-secret-backend.sh @@ -0,0 +1,5 @@ +#!/bin/sh + +BACKEND=$1 + +yq -i ".global.secretStore.backend = \"$BACKEND\"" values-global.yaml diff --git a/common/tests/clustergroup-industrial-edge-factory.expected.yaml b/common/tests/clustergroup-industrial-edge-factory.expected.yaml index aef52f65..948ec58b 100644 --- a/common/tests/clustergroup-industrial-edge-factory.expected.yaml +++ b/common/tests/clustergroup-industrial-edge-factory.expected.yaml @@ -186,6 +186,8 @@ data: useCSV: true pattern: mypattern repoURL: https://github.com/pattern-clone/mypattern + secretStore: + backend: vault targetRevision: main main: clusterGroupName: example diff --git a/common/tests/clustergroup-industrial-edge-hub.expected.yaml b/common/tests/clustergroup-industrial-edge-hub.expected.yaml index 3fcca694..541d6128 100644 --- a/common/tests/clustergroup-industrial-edge-hub.expected.yaml +++ b/common/tests/clustergroup-industrial-edge-hub.expected.yaml @@ -347,6 +347,8 @@ data: useCSV: true pattern: mypattern repoURL: https://github.com/pattern-clone/mypattern + secretStore: + backend: vault targetRevision: main main: clusterGroupName: example diff --git a/common/tests/clustergroup-medical-diagnosis-hub.expected.yaml b/common/tests/clustergroup-medical-diagnosis-hub.expected.yaml index 5678d8bc..e7c66202 100644 --- a/common/tests/clustergroup-medical-diagnosis-hub.expected.yaml +++ b/common/tests/clustergroup-medical-diagnosis-hub.expected.yaml @@ -306,6 +306,8 @@ data: useCSV: true pattern: mypattern repoURL: https://github.com/pattern-clone/mypattern + secretStore: + backend: vault targetRevision: main main: clusterGroupName: example diff --git a/common/tests/clustergroup-naked.expected.yaml b/common/tests/clustergroup-naked.expected.yaml index ec8099f3..de02651e 100644 --- a/common/tests/clustergroup-naked.expected.yaml +++ b/common/tests/clustergroup-naked.expected.yaml @@ -76,6 +76,8 @@ data: syncPolicy: Automatic useCSV: true pattern: common + secretStore: + backend: vault targetRevision: main secretStore: kind: ClusterSecretStore diff --git a/common/tests/clustergroup-normal.expected.yaml b/common/tests/clustergroup-normal.expected.yaml index a3dd7cd4..9bf39732 100644 --- a/common/tests/clustergroup-normal.expected.yaml +++ b/common/tests/clustergroup-normal.expected.yaml @@ -268,6 +268,8 @@ data: useCSV: false pattern: mypattern repoURL: https://github.com/pattern-clone/mypattern + secretStore: + backend: vault targetRevision: main main: clusterGroupName: example diff --git a/common/tests/golang-external-secrets-industrial-edge-factory.expected.yaml b/common/tests/golang-external-secrets-industrial-edge-factory.expected.yaml index d92ef427..e6b3d6fb 100644 --- a/common/tests/golang-external-secrets-industrial-edge-factory.expected.yaml +++ b/common/tests/golang-external-secrets-industrial-edge-factory.expected.yaml @@ -9225,7 +9225,7 @@ spec: secret: secretName: golang-external-secrets-webhook --- -# Source: golang-external-secrets/templates/golang-external-secrets-hub-secretstore.yaml +# Source: golang-external-secrets/templates/vault/golang-external-secrets-hub-secretstore.yaml apiVersion: external-secrets.io/v1beta1 kind: ClusterSecretStore metadata: diff --git a/common/tests/golang-external-secrets-industrial-edge-hub.expected.yaml b/common/tests/golang-external-secrets-industrial-edge-hub.expected.yaml index 43c5d3fc..3fca7728 100644 --- a/common/tests/golang-external-secrets-industrial-edge-hub.expected.yaml +++ b/common/tests/golang-external-secrets-industrial-edge-hub.expected.yaml @@ -9225,7 +9225,7 @@ spec: secret: secretName: golang-external-secrets-webhook --- -# Source: golang-external-secrets/templates/golang-external-secrets-hub-secretstore.yaml +# Source: golang-external-secrets/templates/vault/golang-external-secrets-hub-secretstore.yaml apiVersion: external-secrets.io/v1beta1 kind: ClusterSecretStore metadata: diff --git a/common/tests/golang-external-secrets-medical-diagnosis-hub.expected.yaml b/common/tests/golang-external-secrets-medical-diagnosis-hub.expected.yaml index 43c5d3fc..3fca7728 100644 --- a/common/tests/golang-external-secrets-medical-diagnosis-hub.expected.yaml +++ b/common/tests/golang-external-secrets-medical-diagnosis-hub.expected.yaml @@ -9225,7 +9225,7 @@ spec: secret: secretName: golang-external-secrets-webhook --- -# Source: golang-external-secrets/templates/golang-external-secrets-hub-secretstore.yaml +# Source: golang-external-secrets/templates/vault/golang-external-secrets-hub-secretstore.yaml apiVersion: external-secrets.io/v1beta1 kind: ClusterSecretStore metadata: diff --git a/common/tests/golang-external-secrets-naked.expected.yaml b/common/tests/golang-external-secrets-naked.expected.yaml index 6b9d3030..fda09175 100644 --- a/common/tests/golang-external-secrets-naked.expected.yaml +++ b/common/tests/golang-external-secrets-naked.expected.yaml @@ -9225,7 +9225,7 @@ spec: secret: secretName: golang-external-secrets-webhook --- -# Source: golang-external-secrets/templates/golang-external-secrets-hub-secretstore.yaml +# Source: golang-external-secrets/templates/vault/golang-external-secrets-hub-secretstore.yaml apiVersion: external-secrets.io/v1beta1 kind: ClusterSecretStore metadata: diff --git a/common/tests/golang-external-secrets-normal.expected.yaml b/common/tests/golang-external-secrets-normal.expected.yaml index 43c5d3fc..3fca7728 100644 --- a/common/tests/golang-external-secrets-normal.expected.yaml +++ b/common/tests/golang-external-secrets-normal.expected.yaml @@ -9225,7 +9225,7 @@ spec: secret: secretName: golang-external-secrets-webhook --- -# Source: golang-external-secrets/templates/golang-external-secrets-hub-secretstore.yaml +# Source: golang-external-secrets/templates/vault/golang-external-secrets-hub-secretstore.yaml apiVersion: external-secrets.io/v1beta1 kind: ClusterSecretStore metadata: diff --git a/common/values-global.yaml b/common/values-global.yaml index 24feccd5..684f89f2 100644 --- a/common/values-global.yaml +++ b/common/values-global.yaml @@ -12,6 +12,9 @@ global: email: someone@somewhere.com dev_revision: main + secretStore: + backend: vault + main: clusterGroupName: example diff --git a/tests/common-clustergroup-industrial-edge-factory.expected.yaml b/tests/common-clustergroup-industrial-edge-factory.expected.yaml index e2403445..66424e99 100644 --- a/tests/common-clustergroup-industrial-edge-factory.expected.yaml +++ b/tests/common-clustergroup-industrial-edge-factory.expected.yaml @@ -184,6 +184,8 @@ data: useCSV: false pattern: mypattern repoURL: https://github.com/pattern-clone/mypattern + secretStore: + backend: vault targetRevision: main main: clusterGroupName: hub diff --git a/tests/common-clustergroup-industrial-edge-hub.expected.yaml b/tests/common-clustergroup-industrial-edge-hub.expected.yaml index 0f566ec1..6df50dc0 100644 --- a/tests/common-clustergroup-industrial-edge-hub.expected.yaml +++ b/tests/common-clustergroup-industrial-edge-hub.expected.yaml @@ -345,6 +345,8 @@ data: useCSV: false pattern: mypattern repoURL: https://github.com/pattern-clone/mypattern + secretStore: + backend: vault targetRevision: main main: clusterGroupName: hub diff --git a/tests/common-clustergroup-medical-diagnosis-hub.expected.yaml b/tests/common-clustergroup-medical-diagnosis-hub.expected.yaml index c8088bf6..154c3ddc 100644 --- a/tests/common-clustergroup-medical-diagnosis-hub.expected.yaml +++ b/tests/common-clustergroup-medical-diagnosis-hub.expected.yaml @@ -304,6 +304,8 @@ data: useCSV: false pattern: mypattern repoURL: https://github.com/pattern-clone/mypattern + secretStore: + backend: vault targetRevision: main main: clusterGroupName: hub diff --git a/tests/common-clustergroup-naked.expected.yaml b/tests/common-clustergroup-naked.expected.yaml index ec8099f3..de02651e 100644 --- a/tests/common-clustergroup-naked.expected.yaml +++ b/tests/common-clustergroup-naked.expected.yaml @@ -76,6 +76,8 @@ data: syncPolicy: Automatic useCSV: true pattern: common + secretStore: + backend: vault targetRevision: main secretStore: kind: ClusterSecretStore diff --git a/tests/common-clustergroup-normal.expected.yaml b/tests/common-clustergroup-normal.expected.yaml index 4bff6f68..37046834 100644 --- a/tests/common-clustergroup-normal.expected.yaml +++ b/tests/common-clustergroup-normal.expected.yaml @@ -266,6 +266,8 @@ data: useCSV: false pattern: mypattern repoURL: https://github.com/pattern-clone/mypattern + secretStore: + backend: vault targetRevision: main main: clusterGroupName: hub diff --git a/tests/common-golang-external-secrets-industrial-edge-factory.expected.yaml b/tests/common-golang-external-secrets-industrial-edge-factory.expected.yaml index d1bf40a5..e47b1f88 100644 --- a/tests/common-golang-external-secrets-industrial-edge-factory.expected.yaml +++ b/tests/common-golang-external-secrets-industrial-edge-factory.expected.yaml @@ -9225,7 +9225,7 @@ spec: secret: secretName: common-golang-external-secrets-webhook --- -# Source: golang-external-secrets/templates/golang-external-secrets-hub-secretstore.yaml +# Source: golang-external-secrets/templates/vault/golang-external-secrets-hub-secretstore.yaml apiVersion: external-secrets.io/v1beta1 kind: ClusterSecretStore metadata: diff --git a/tests/common-golang-external-secrets-industrial-edge-hub.expected.yaml b/tests/common-golang-external-secrets-industrial-edge-hub.expected.yaml index 0c569a2d..be607bf5 100644 --- a/tests/common-golang-external-secrets-industrial-edge-hub.expected.yaml +++ b/tests/common-golang-external-secrets-industrial-edge-hub.expected.yaml @@ -9225,7 +9225,7 @@ spec: secret: secretName: common-golang-external-secrets-webhook --- -# Source: golang-external-secrets/templates/golang-external-secrets-hub-secretstore.yaml +# Source: golang-external-secrets/templates/vault/golang-external-secrets-hub-secretstore.yaml apiVersion: external-secrets.io/v1beta1 kind: ClusterSecretStore metadata: diff --git a/tests/common-golang-external-secrets-medical-diagnosis-hub.expected.yaml b/tests/common-golang-external-secrets-medical-diagnosis-hub.expected.yaml index 0c569a2d..be607bf5 100644 --- a/tests/common-golang-external-secrets-medical-diagnosis-hub.expected.yaml +++ b/tests/common-golang-external-secrets-medical-diagnosis-hub.expected.yaml @@ -9225,7 +9225,7 @@ spec: secret: secretName: common-golang-external-secrets-webhook --- -# Source: golang-external-secrets/templates/golang-external-secrets-hub-secretstore.yaml +# Source: golang-external-secrets/templates/vault/golang-external-secrets-hub-secretstore.yaml apiVersion: external-secrets.io/v1beta1 kind: ClusterSecretStore metadata: diff --git a/tests/common-golang-external-secrets-naked.expected.yaml b/tests/common-golang-external-secrets-naked.expected.yaml index 99f0d5cc..cbd12c28 100644 --- a/tests/common-golang-external-secrets-naked.expected.yaml +++ b/tests/common-golang-external-secrets-naked.expected.yaml @@ -9225,7 +9225,7 @@ spec: secret: secretName: common-golang-external-secrets-webhook --- -# Source: golang-external-secrets/templates/golang-external-secrets-hub-secretstore.yaml +# Source: golang-external-secrets/templates/vault/golang-external-secrets-hub-secretstore.yaml apiVersion: external-secrets.io/v1beta1 kind: ClusterSecretStore metadata: diff --git a/tests/common-golang-external-secrets-normal.expected.yaml b/tests/common-golang-external-secrets-normal.expected.yaml index 0c569a2d..be607bf5 100644 --- a/tests/common-golang-external-secrets-normal.expected.yaml +++ b/tests/common-golang-external-secrets-normal.expected.yaml @@ -9225,7 +9225,7 @@ spec: secret: secretName: common-golang-external-secrets-webhook --- -# Source: golang-external-secrets/templates/golang-external-secrets-hub-secretstore.yaml +# Source: golang-external-secrets/templates/vault/golang-external-secrets-hub-secretstore.yaml apiVersion: external-secrets.io/v1beta1 kind: ClusterSecretStore metadata: