From 3f0e3d840c0d32f37bc8ecb006a3385b818ac740 Mon Sep 17 00:00:00 2001 From: Martin Jackson Date: Tue, 12 May 2026 09:12:16 -0500 Subject: [PATCH 1/7] Initial concept of boostrap secrets --- README.md | 60 ++++++++++++++ playbooks/display_secrets_info.yml | 24 +++++- playbooks/load_bootstrap_secrets.yml | 11 +++ playbooks/load_bootstrap_secrets_only.yml | 23 ++++++ .../tasks/find_optional_bootstrap.yml | 80 +++++++++++++++++++ roles/find_vp_secrets/tasks/main.yml | 60 +++----------- .../tasks/read_secret_from_path.yml | 52 ++++++++++++ roles/load_secrets/defaults/main.yml | 8 +- .../tasks/bootstrap_inject_retry.yml | 50 ++++++++++++ roles/load_secrets/tasks/bootstrap_only.yml | 31 +++++++ roles/load_secrets/tasks/main.yml | 21 +++-- .../tasks/optional_bootstrap_load.yml | 34 ++++++++ roles/vault_utils/README.md | 2 + 13 files changed, 395 insertions(+), 61 deletions(-) create mode 100644 playbooks/load_bootstrap_secrets.yml create mode 100644 playbooks/load_bootstrap_secrets_only.yml create mode 100644 roles/find_vp_secrets/tasks/find_optional_bootstrap.yml create mode 100644 roles/find_vp_secrets/tasks/read_secret_from_path.yml create mode 100644 roles/load_secrets/tasks/bootstrap_inject_retry.yml create mode 100644 roles/load_secrets/tasks/bootstrap_only.yml create mode 100644 roles/load_secrets/tasks/optional_bootstrap_load.yml diff --git a/README.md b/README.md index 68af1ea..8e971ce 100644 --- a/README.md +++ b/README.md @@ -8,3 +8,63 @@ The main purpose of this collections are to: loading local secrets files into VP secrets stores. 2. Help manage imperative and other utility functions of the cluster + +## Secrets loading + +The collection distinguishes **primary** values-secret files (the usual pattern secrets) from optional **bootstrap** values-secret files (extra content loaded with the `none` backing store into the cluster, independent of `values-global.yaml` `secretStore.backend`). + +### Primary values-secret (standard load) + +- **Backing store** comes from `values-global.yaml`: `.global.secretStore.backend` (default `vault`). That drives parsing and whether secrets go to Vault or Kubernetes. +- **Discovery order** when `VALUES_SECRET` is unset (first existing file wins): + `~/.config/hybrid-cloud-patterns/values-secret-.yaml`, + `~/.config/validated-patterns/values-secret-.yaml`, + `~/values-secret-.yaml`, + `~/values-secret.yaml`, + then `/values-secret.yaml.template`. +- When `VALUES_SECRET` is set to an existing path, that file is used for the **primary** load. If bootstrap loading already consumed that same path because it was a bootstrap-named file, the primary pass temporarily ignores `VALUES_SECRET` so the primary search can fall back to the paths above. + +Files may be plain YAML or `ansible-vault` encrypted. + +### Bootstrap values-secret (optional) + +Bootstrap files are **never** read from `/` (no `values-secret-*-bootstrap.yaml` under the pattern tree). + +When not using `VALUES_SECRET` for bootstrap, candidates are checked in order (first existing file wins): + +- `~/.config/hybrid-cloud-patterns/values-secret--bootstrap.yaml` +- `~/.config/validated-patterns/values-secret--bootstrap.yaml` +- `~/values-secret--bootstrap.yaml` +- `~/values-secret-bootstrap.yaml` + +Alternatively, set `VALUES_SECRET` to an **existing** file whose name ends with `-bootstrap.yaml` (or `-bootstrap.yml`) to use that path for bootstrap discovery in flows that support it. + +**Bootstrap is always parsed and applied with backing store `none`** (Kubernetes secret injection path), which requires schema version 2.0 or newer in the bootstrap file. + +### Playbooks and flows + +| Playbook | What it runs | +|----------|----------------| +| `playbooks/load_secrets.yml` | Respects `.global.secretLoader.disabled` in `values-global.yaml`. When enabled: `cluster_pre_check`, optional **bootstrap** load (if a bootstrap file exists; **not** an error if missing), then **primary** discovery, parse, and load using the configured backend. | +| `playbooks/load_bootstrap_secrets.yml` | Convenience wrapper: `determine_pattern_dir`, `determine_pattern_name`, then imports `load_secrets.yml` (same combined bootstrap-then-primary behavior as install). | +| `playbooks/load_bootstrap_secrets_only.yml` | **Bootstrap only**: same pattern discovery plays and `pattern_settings`, then only bootstrap inject (with retries). **Fails** if no bootstrap file is found. Does **not** read `secretLoader.disabled` or load the primary file. | +| `playbooks/display_secrets_info.yml` | Loads and displays parsed primary secrets (using the backend from `values-global`). If a bootstrap file exists, also parses and displays it with backing store `none`. Missing bootstrap is not an error. | + +Typical usage sets `-e pattern_dir=...` to the pattern checkout (and relies on `values-global.yaml` there via `pattern_settings`). + +`playbooks/install.yml` imports `load_secrets.yml` after the pattern install playbook, so the combined bootstrap-then-primary flow runs during install when secret loading is enabled. + +### Bootstrap retries + +Bootstrap **inject** retries (parse plus Kubernetes apply) are controlled on the role defaults / extra-vars: + +- `vp_secrets_bootstrap_retry_max` (default `5`) +- `vp_secrets_bootstrap_retry_delay` (seconds between attempts, default `30`) + +These apply to the optional bootstrap phase inside `load_secrets` and to `load_bootstrap_secrets_only.yml`. + +### Roles (implementation notes) + +- `roles/load_secrets/tasks/main.yml` implements the **combined** flow (optional bootstrap, then primary). +- `roles/load_secrets/tasks/bootstrap_only.yml` is used only when you invoke the `load_secrets` role with `tasks_from: bootstrap_only.yml` (as `load_bootstrap_secrets_only.yml` does). +- `roles/find_vp_secrets` resolves primary files (`tasks/main.yml`) and optional bootstrap discovery (`tasks/find_optional_bootstrap.yml`). diff --git a/playbooks/display_secrets_info.yml b/playbooks/display_secrets_info.yml index 8ce8619..630a288 100644 --- a/playbooks/display_secrets_info.yml +++ b/playbooks/display_secrets_info.yml @@ -36,6 +36,28 @@ secrets_backing_store: "{{ secrets_backing_store }}" register: secrets_results - - name: Display secrets data + - name: Display primary secrets data ansible.builtin.debug: var: secrets_results + + - name: Snapshot primary secrets for optional bootstrap display + ansible.builtin.set_fact: + _primary_values_secrets_data_snapshot: "{{ values_secrets_data }}" + + - name: Discover optional bootstrap values-secret file + ansible.builtin.include_role: + name: find_vp_secrets + tasks_from: find_optional_bootstrap.yml + + - name: Parse bootstrap secrets data (none backend) + no_log: '{{ hide_sensitive_output }}' + parse_secrets_info: + values_secrets_plaintext: "{{ values_secrets_bootstrap_data }}" + secrets_backing_store: none + register: bootstrap_secrets_results + when: vp_bootstrap_secrets_present | default(false) | bool + + - name: Display bootstrap secrets data + ansible.builtin.debug: + var: bootstrap_secrets_results + when: vp_bootstrap_secrets_present | default(false) | bool diff --git a/playbooks/load_bootstrap_secrets.yml b/playbooks/load_bootstrap_secrets.yml new file mode 100644 index 0000000..14ab7e4 --- /dev/null +++ b/playbooks/load_bootstrap_secrets.yml @@ -0,0 +1,11 @@ +--- +# Post-install alias: runs the same secrets load as load_secrets.yml (optional bootstrap, then primary). +# Optional extra-vars: vp_secrets_bootstrap_retry_max, vp_secrets_bootstrap_retry_delay (seconds). +- name: Determine pattern directory + ansible.builtin.import_playbook: ./determine_pattern_dir.yml + +- name: Determine pattern name + ansible.builtin.import_playbook: ./determine_pattern_name.yml + +- name: Load secrets (optional bootstrap then standard) + ansible.builtin.import_playbook: ./load_secrets.yml diff --git a/playbooks/load_bootstrap_secrets_only.yml b/playbooks/load_bootstrap_secrets_only.yml new file mode 100644 index 0000000..6659bb5 --- /dev/null +++ b/playbooks/load_bootstrap_secrets_only.yml @@ -0,0 +1,23 @@ +--- +# Load only bootstrap values-secret files (none backend). Does not load the primary values-secret +# or honor secretLoader.disabled from values-global. Fails if no bootstrap file exists. +# Optional extra-vars: vp_secrets_bootstrap_retry_max, vp_secrets_bootstrap_retry_delay (seconds). +- name: Determine pattern directory + ansible.builtin.import_playbook: ./determine_pattern_dir.yml + +- name: Determine pattern name + ansible.builtin.import_playbook: ./determine_pattern_name.yml + +- name: Load bootstrap secrets only + hosts: localhost + connection: local + gather_facts: false + become: false + roles: + - pattern_settings + + tasks: + - name: Run bootstrap-only secrets load + ansible.builtin.include_role: + name: load_secrets + tasks_from: bootstrap_only.yml diff --git a/roles/find_vp_secrets/tasks/find_optional_bootstrap.yml b/roles/find_vp_secrets/tasks/find_optional_bootstrap.yml new file mode 100644 index 0000000..d1d9a48 --- /dev/null +++ b/roles/find_vp_secrets/tasks/find_optional_bootstrap.yml @@ -0,0 +1,80 @@ +--- +# Sets values_secrets_bootstrap_data when a bootstrap values-secret file exists; otherwise no-op. +# Expects: pattern_name, and _primary_values_secrets_data_snapshot when restoring after read. +- name: Clear bootstrap secrets facts from any prior play + ansible.builtin.set_fact: + values_secrets_bootstrap_data: '' + vp_bootstrap_secrets_present: false + found_bootstrap_file: '' + vp_bootstrap_loaded_via_values_secret_env: false + +- name: Read VALUES_SECRET for optional bootstrap discovery + ansible.builtin.set_fact: + bootstrap_custom_env_values_secret: "{{ lookup('ansible.builtin.env', 'VALUES_SECRET') }}" + +- name: Decide if VALUES_SECRET names a bootstrap file + ansible.builtin.set_fact: + _bootstrap_env_is_bootstrap_named: >- + {{ + (bootstrap_custom_env_values_secret | default('') | string | length > 0) + and (bootstrap_custom_env_values_secret | regex_search('-bootstrap\.ya?ml$') is not none) + }} + +- name: Check if VALUES_SECRET points to an existing file (bootstrap) + ansible.builtin.stat: + path: "{{ bootstrap_custom_env_values_secret }}" + register: bootstrap_custom_file_values_secret + when: + - bootstrap_custom_env_values_secret | default('') | length > 0 + - _bootstrap_env_is_bootstrap_named | default(false) | bool + +- name: Use VALUES_SECRET as bootstrap secrets file + ansible.builtin.set_fact: + found_bootstrap_file: "{{ bootstrap_custom_file_values_secret.stat.path }}" + vp_bootstrap_loaded_via_values_secret_env: true + when: + - bootstrap_custom_env_values_secret | default('') | length > 0 + - _bootstrap_env_is_bootstrap_named | default(false) | bool + - bootstrap_custom_file_values_secret.stat is defined + - bootstrap_custom_file_values_secret.stat.exists + +- name: Build bootstrap values-secret candidate paths + ansible.builtin.set_fact: + _vp_bootstrap_secret_candidates: + - "~/.config/hybrid-cloud-patterns/values-secret-{{ pattern_name }}-bootstrap.yaml" + - "~/.config/validated-patterns/values-secret-{{ pattern_name }}-bootstrap.yaml" + - "~/values-secret-{{ pattern_name }}-bootstrap.yaml" + - "~/values-secret-bootstrap.yaml" + when: (found_bootstrap_file | default('') | string | length) == 0 + +- name: Stat bootstrap candidate paths + ansible.builtin.stat: + path: "{{ item }}" + loop: "{{ _vp_bootstrap_secret_candidates }}" + register: _vp_bootstrap_stat_results + when: (found_bootstrap_file | default('') | string | length) == 0 + +- name: Pick first existing bootstrap secrets file from candidates + ansible.builtin.set_fact: + found_bootstrap_file: "{{ (_vp_bootstrap_stat_results.results | default([]) | selectattr('stat.exists') | map(attribute='item') | list | first) | default('') }}" + when: + - (found_bootstrap_file | default('') | string | length) == 0 + - _vp_bootstrap_stat_results.results is defined + +- name: Read bootstrap secrets when a bootstrap file was found + when: (found_bootstrap_file | default('') | string | length) > 0 + block: + - name: Load bootstrap secrets from file + ansible.builtin.include_tasks: read_secret_from_path.yml + vars: + found_file: "{{ found_bootstrap_file }}" + + - name: Publish bootstrap secrets data for display + ansible.builtin.set_fact: + values_secrets_bootstrap_data: "{{ values_secrets_data }}" + vp_bootstrap_secrets_present: true + + - name: Restore primary values_secrets_data after bootstrap read + ansible.builtin.set_fact: + values_secrets_data: "{{ _primary_values_secrets_data_snapshot }}" + when: _primary_values_secrets_data_snapshot is defined diff --git a/roles/find_vp_secrets/tasks/main.yml b/roles/find_vp_secrets/tasks/main.yml index 9c99590..7a2c4c3 100644 --- a/roles/find_vp_secrets/tasks/main.yml +++ b/roles/find_vp_secrets/tasks/main.yml @@ -5,9 +5,14 @@ ansible.builtin.set_fact: secret_template: "{{ pattern_dir }}/values-secret.yaml.template" -- name: Is a VALUES_SECRET env variable set? +- name: Resolve VALUES_SECRET for primary secrets search ansible.builtin.set_fact: - custom_env_values_secret: "{{ lookup('ansible.builtin.env', 'VALUES_SECRET') }}" + custom_env_values_secret: >- + {{ + '' + if (vp_skip_values_secret_env_for_primary | default(false) | bool) + else (lookup('ansible.builtin.env', 'VALUES_SECRET') | default('', true)) + }} - name: Check if VALUES_SECRET file exists ansible.builtin.stat: @@ -37,52 +42,5 @@ - "{{ pattern_dir }}/values-secret.yaml.template" when: custom_env_values_secret | default('') | length == 0 -- name: Is found values secret file encrypted - no_log: "{{ hide_sensitive_output | 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: "{{ hide_sensitive_output | 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: "{{ hide_sensitive_output | 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: '{{ hide_sensitive_output | 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: '{{ hide_sensitive_output | default(true) }}' - ansible.builtin.set_fact: - values_secrets_data: "{{ values_secret_plaintext.stdout | from_yaml }}" - when: is_encrypted - changed_when: false +- name: Read secrets from discovered file + ansible.builtin.include_tasks: read_secret_from_path.yml diff --git a/roles/find_vp_secrets/tasks/read_secret_from_path.yml b/roles/find_vp_secrets/tasks/read_secret_from_path.yml new file mode 100644 index 0000000..57d982b --- /dev/null +++ b/roles/find_vp_secrets/tasks/read_secret_from_path.yml @@ -0,0 +1,52 @@ +--- +# Reads YAML from found_file (vault-encrypted or plain) into values_secrets_data. +# Expects: found_file, hide_sensitive_output (optional) +- name: Is found values secret file encrypted + no_log: "{{ hide_sensitive_output | 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: "{{ hide_sensitive_output | 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: "{{ hide_sensitive_output | 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: "{{ hide_sensitive_output | 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: "{{ hide_sensitive_output | default(true) }}" + ansible.builtin.set_fact: + values_secrets_data: "{{ values_secret_plaintext.stdout | from_yaml }}" + when: is_encrypted + changed_when: false diff --git a/roles/load_secrets/defaults/main.yml b/roles/load_secrets/defaults/main.yml index 0999947..740b9de 100644 --- a/roles/load_secrets/defaults/main.yml +++ b/roles/load_secrets/defaults/main.yml @@ -1,3 +1,5 @@ -secrets_role: "vault_utils" -tasks_from: "push_parsed_secrets" -hide_sensitive_output: true +--- +vp_secrets_bootstrap_retry_max: 5 +vp_secrets_bootstrap_retry_delay: 30 +secrets_role: vault_utils +tasks_from: push_parsed_secrets diff --git a/roles/load_secrets/tasks/bootstrap_inject_retry.yml b/roles/load_secrets/tasks/bootstrap_inject_retry.yml new file mode 100644 index 0000000..31ccf77 --- /dev/null +++ b/roles/load_secrets/tasks/bootstrap_inject_retry.yml @@ -0,0 +1,50 @@ +--- +- name: Ensure bootstrap inject attempt counter + ansible.builtin.set_fact: + _bootstrap_inject_attempt: "{{ _bootstrap_inject_attempt | default(1) | int }}" + +- name: Determine bootstrap secrets YAML shape + ansible.builtin.set_fact: + secrets_bootstrap_yaml: "{{ values_secrets_bootstrap_data if values_secrets_bootstrap_data is not string else values_secrets_bootstrap_data | from_yaml }}" + +- name: Fail when bootstrap schema is too old for none backend loading + ansible.builtin.fail: + msg: Bootstrap secrets require values-secret schema version 2.0 or higher when using the none backend. + when: (secrets_bootstrap_yaml.version | default('2.0')) is version('2.0', '<') + +- name: Parse and inject bootstrap secrets (attempt {{ _bootstrap_inject_attempt }}/{{ vp_secrets_bootstrap_retry_max | default(5) }}) + block: + - name: Parse bootstrap secrets data + no_log: "{{ hide_sensitive_output | default(true) }}" + parse_secrets_info: + values_secrets_plaintext: "{{ values_secrets_bootstrap_data }}" + secrets_backing_store: none + register: bootstrap_secrets_results + + - name: Inject bootstrap secrets into the cluster + ansible.builtin.include_role: + name: k8s_secret_utils + tasks_from: inject_k8s_secrets + vars: + kubernetes_secret_objects: "{{ bootstrap_secrets_results.kubernetes_secret_objects }}" + vault_policies: "{{ bootstrap_secrets_results.vault_policies }}" + parsed_secrets: "{{ bootstrap_secrets_results.parsed_secrets }}" + unique_vault_prefixes: "{{ bootstrap_secrets_results.unique_vault_prefixes | default([]) }}" + + rescue: + - name: Fail when bootstrap secrets inject retries are exhausted + ansible.builtin.fail: + msg: | + Bootstrap secrets loading failed after {{ vp_secrets_bootstrap_retry_max | default(5) }} attempt(s). + when: (_bootstrap_inject_attempt | int) >= (vp_secrets_bootstrap_retry_max | default(5) | int) + + - name: Wait before retrying bootstrap secrets inject + ansible.builtin.pause: + seconds: "{{ vp_secrets_bootstrap_retry_delay | default(30) | int }}" + + - name: Bump bootstrap secrets inject attempt counter + ansible.builtin.set_fact: + _bootstrap_inject_attempt: "{{ (_bootstrap_inject_attempt | int) + 1 }}" + + - name: Retry bootstrap secrets inject + ansible.builtin.include_tasks: bootstrap_inject_retry.yml diff --git a/roles/load_secrets/tasks/bootstrap_only.yml b/roles/load_secrets/tasks/bootstrap_only.yml new file mode 100644 index 0000000..65514d8 --- /dev/null +++ b/roles/load_secrets/tasks/bootstrap_only.yml @@ -0,0 +1,31 @@ +--- +# Bootstrap values-secret only (none backend, k8s inject). Fails if no bootstrap file is found. +- name: Run cluster pre-check before bootstrap-only secrets load + ansible.builtin.include_role: + name: cluster_pre_check + +- name: Discover bootstrap values-secret file + ansible.builtin.include_role: + name: find_vp_secrets + tasks_from: find_optional_bootstrap.yml + +- name: Require a bootstrap values-secret file for bootstrap-only loading + ansible.builtin.fail: + msg: | + No bootstrap values-secret file was found. Install one of the expected bootstrap paths, + or set VALUES_SECRET to an existing file whose name ends with -bootstrap.yaml. + when: not (vp_bootstrap_secrets_present | default(false) | bool) + +- name: Note bootstrap values-secret file in use + ansible.builtin.debug: + msg: >- + Loading bootstrap values-secret only from {{ found_bootstrap_file }} + (none backend; up to {{ vp_secrets_bootstrap_retry_max | default(5) }} attempt(s), + {{ vp_secrets_bootstrap_retry_delay | default(30) }}s between retries on failure). + +- name: Initialize bootstrap secrets inject attempt counter + ansible.builtin.set_fact: + _bootstrap_inject_attempt: 1 + +- name: Inject bootstrap secrets with retries on failure + ansible.builtin.include_tasks: bootstrap_inject_retry.yml diff --git a/roles/load_secrets/tasks/main.yml b/roles/load_secrets/tasks/main.yml index 7d79b09..0761b14 100644 --- a/roles/load_secrets/tasks/main.yml +++ b/roles/load_secrets/tasks/main.yml @@ -1,14 +1,18 @@ --- -- name: Set fact for secretStore backend +- name: Run cluster pre-check once before optional bootstrap and primary secrets loading + ansible.builtin.include_role: + name: cluster_pre_check + +- name: Optional bootstrap values-secret discovery and load + ansible.builtin.include_tasks: optional_bootstrap_load.yml + +- name: Set fact for secretStore backend (from values-global) ansible.builtin.set_fact: secrets_backing_store: "{{ values_global.global.secretStore.backend | default('vault') }}" -- name: Run secret-loading pre-requisites +- name: Find primary values-secret file and read it ansible.builtin.include_role: - name: "{{ item }}" - loop: - - cluster_pre_check - - find_vp_secrets + name: find_vp_secrets - name: Fail if values_secrets_data is missing ansible.builtin.shell: | @@ -47,3 +51,8 @@ vault_policies: "{{ secrets_results.vault_policies }}" parsed_secrets: "{{ secrets_results.parsed_secrets }}" unique_vault_prefixes: "{{ secrets_results.unique_vault_prefixes }}" + +- name: Clear bootstrap-related VALUES_SECRET routing facts + ansible.builtin.set_fact: + vp_skip_values_secret_env_for_primary: false + vp_bootstrap_loaded_via_values_secret_env: false diff --git a/roles/load_secrets/tasks/optional_bootstrap_load.yml b/roles/load_secrets/tasks/optional_bootstrap_load.yml new file mode 100644 index 0000000..80a32bb --- /dev/null +++ b/roles/load_secrets/tasks/optional_bootstrap_load.yml @@ -0,0 +1,34 @@ +--- +- name: Discover optional bootstrap values-secret file + ansible.builtin.include_role: + name: find_vp_secrets + tasks_from: find_optional_bootstrap.yml + +- name: Note missing optional bootstrap values-secret file + ansible.builtin.debug: + msg: No bootstrap values-secret file was found (optional). Proceeding with standard secrets loading only. + when: not (vp_bootstrap_secrets_present | default(false) | bool) + +- name: Note optional bootstrap values-secret file found + ansible.builtin.debug: + msg: >- + Found bootstrap values-secret at {{ found_bootstrap_file }}; loading with the none backend + (up to {{ vp_secrets_bootstrap_retry_max | default(5) }} attempt(s), + {{ vp_secrets_bootstrap_retry_delay | default(30) }}s between retries on failure). + when: vp_bootstrap_secrets_present | default(false) | bool + +- name: Skip VALUES_SECRET for primary search when bootstrap consumed that path + ansible.builtin.set_fact: + vp_skip_values_secret_env_for_primary: true + when: + - vp_bootstrap_secrets_present | default(false) | bool + - vp_bootstrap_loaded_via_values_secret_env | default(false) | bool + +- name: Initialize bootstrap secrets inject attempt counter + ansible.builtin.set_fact: + _bootstrap_inject_attempt: 1 + when: vp_bootstrap_secrets_present | default(false) | bool + +- name: Load bootstrap secrets into the cluster with retries on failure + ansible.builtin.include_tasks: bootstrap_inject_retry.yml + when: vp_bootstrap_secrets_present | default(false) | bool diff --git a/roles/vault_utils/README.md b/roles/vault_utils/README.md index 50dbec1..0da04cf 100644 --- a/roles/vault_utils/README.md +++ b/roles/vault_utils/README.md @@ -69,6 +69,8 @@ By default, the first file that will looked up is The paths can be overridden by setting the environment variable `VALUES_SECRET` to the path of the secret file. +Optional **bootstrap** values-secret files (names ending with `-bootstrap.yaml`), the bootstrap-then-primary loading order, the bootstrap-only playbook, and `display_secrets_info.yml` are documented under **Secrets loading** in the collection `README.md` at the repository root. + The values secret YAML files can be encrypted with `ansible-vault`. If the role detects they are encrypted, the password to decrypt them will be prompted when needed. From 9beb5f9103ede18ef9a817e6de128a361652c727 Mon Sep 17 00:00:00 2001 From: Martin Jackson Date: Tue, 12 May 2026 09:42:24 -0500 Subject: [PATCH 2/7] Update determine_pattern_dir to allow defaults --- README.md | 2 +- playbooks/determine_pattern_dir.yml | 19 +++++++++++++------ 2 files changed, 14 insertions(+), 7 deletions(-) diff --git a/README.md b/README.md index 8e971ce..20b455c 100644 --- a/README.md +++ b/README.md @@ -50,7 +50,7 @@ Alternatively, set `VALUES_SECRET` to an **existing** file whose name ends with | `playbooks/load_bootstrap_secrets_only.yml` | **Bootstrap only**: same pattern discovery plays and `pattern_settings`, then only bootstrap inject (with retries). **Fails** if no bootstrap file is found. Does **not** read `secretLoader.disabled` or load the primary file. | | `playbooks/display_secrets_info.yml` | Loads and displays parsed primary secrets (using the backend from `values-global`). If a bootstrap file exists, also parses and displays it with backing store `none`. Missing bootstrap is not an error. | -Typical usage sets `-e pattern_dir=...` to the pattern checkout (and relies on `values-global.yaml` there via `pattern_settings`). +Typical usage passes the pattern checkout as `pattern_dir` (for example `-e pattern_dir=/path/to/pattern`). If you omit it, the same resolution as `pattern_settings` applies: `PATTERN_DIR`, then `PWD`, then the `pwd` command. `playbooks/install.yml` imports `load_secrets.yml` after the pattern install playbook, so the combined bootstrap-then-primary flow runs during install when secret loading is enabled. diff --git a/playbooks/determine_pattern_dir.yml b/playbooks/determine_pattern_dir.yml index 17b2cd7..83af0e8 100644 --- a/playbooks/determine_pattern_dir.yml +++ b/playbooks/determine_pattern_dir.yml @@ -1,17 +1,24 @@ --- +# Resolves pattern_dir the same way as the pattern_settings role (extra-vars, PATTERN_DIR, PWD, pwd), +# then fails if still unset. Used by display_secrets_info, load_values_global, load_bootstrap_secrets*, etc. - name: Determine pattern dir hosts: localhost connection: local gather_facts: false become: false - vars: - pattern_dir: '' tasks: - - name: Fail if directory is not set + - name: Resolve pattern_dir from extra-vars, PATTERN_DIR, PWD, or pwd + ansible.builtin.include_role: + name: pattern_settings + tasks_from: resolve_overrides.yml + + - name: Fail if pattern directory is not set after resolution ansible.builtin.fail: - msg: "pattern_dir variable must be set" - when: pattern_dir | length == 0 + msg: >- + pattern_dir is not set. Pass -e pattern_dir=/path/to/pattern, export PATTERN_DIR to that path, + or run the playbook from the pattern directory so PWD is correct. + when: pattern_dir | default('') | string | trim | length == 0 - name: Set pattern_dir fact for future plays ansible.builtin.set_fact: - pattern_dir: '{{ pattern_dir }}' + pattern_dir: "{{ pattern_dir | string | trim }}" From 4d69e10b8de438cbbeff62fa1f73a18c6a46f60d Mon Sep 17 00:00:00 2001 From: Martin Jackson Date: Tue, 12 May 2026 11:49:53 -0500 Subject: [PATCH 3/7] Fix markdown and extend retries a bit --- README.md | 4 ++-- roles/load_secrets/defaults/main.yml | 2 +- roles/load_secrets/tasks/bootstrap_inject_retry.yml | 6 +++--- roles/load_secrets/tasks/bootstrap_only.yml | 2 +- roles/load_secrets/tasks/optional_bootstrap_load.yml | 2 +- 5 files changed, 8 insertions(+), 8 deletions(-) diff --git a/README.md b/README.md index 20b455c..0271e27 100644 --- a/README.md +++ b/README.md @@ -44,7 +44,7 @@ Alternatively, set `VALUES_SECRET` to an **existing** file whose name ends with ### Playbooks and flows | Playbook | What it runs | -|----------|----------------| +| --- | --- | | `playbooks/load_secrets.yml` | Respects `.global.secretLoader.disabled` in `values-global.yaml`. When enabled: `cluster_pre_check`, optional **bootstrap** load (if a bootstrap file exists; **not** an error if missing), then **primary** discovery, parse, and load using the configured backend. | | `playbooks/load_bootstrap_secrets.yml` | Convenience wrapper: `determine_pattern_dir`, `determine_pattern_name`, then imports `load_secrets.yml` (same combined bootstrap-then-primary behavior as install). | | `playbooks/load_bootstrap_secrets_only.yml` | **Bootstrap only**: same pattern discovery plays and `pattern_settings`, then only bootstrap inject (with retries). **Fails** if no bootstrap file is found. Does **not** read `secretLoader.disabled` or load the primary file. | @@ -58,7 +58,7 @@ Typical usage passes the pattern checkout as `pattern_dir` (for example `-e patt Bootstrap **inject** retries (parse plus Kubernetes apply) are controlled on the role defaults / extra-vars: -- `vp_secrets_bootstrap_retry_max` (default `5`) +- `vp_secrets_bootstrap_retry_max` (default `20`) - `vp_secrets_bootstrap_retry_delay` (seconds between attempts, default `30`) These apply to the optional bootstrap phase inside `load_secrets` and to `load_bootstrap_secrets_only.yml`. diff --git a/roles/load_secrets/defaults/main.yml b/roles/load_secrets/defaults/main.yml index 740b9de..bf5dbcb 100644 --- a/roles/load_secrets/defaults/main.yml +++ b/roles/load_secrets/defaults/main.yml @@ -1,5 +1,5 @@ --- -vp_secrets_bootstrap_retry_max: 5 +vp_secrets_bootstrap_retry_max: 20 vp_secrets_bootstrap_retry_delay: 30 secrets_role: vault_utils tasks_from: push_parsed_secrets diff --git a/roles/load_secrets/tasks/bootstrap_inject_retry.yml b/roles/load_secrets/tasks/bootstrap_inject_retry.yml index 31ccf77..7000b64 100644 --- a/roles/load_secrets/tasks/bootstrap_inject_retry.yml +++ b/roles/load_secrets/tasks/bootstrap_inject_retry.yml @@ -12,7 +12,7 @@ msg: Bootstrap secrets require values-secret schema version 2.0 or higher when using the none backend. when: (secrets_bootstrap_yaml.version | default('2.0')) is version('2.0', '<') -- name: Parse and inject bootstrap secrets (attempt {{ _bootstrap_inject_attempt }}/{{ vp_secrets_bootstrap_retry_max | default(5) }}) +- name: Parse and inject bootstrap secrets (attempt {{ _bootstrap_inject_attempt }}/{{ vp_secrets_bootstrap_retry_max | default(20) }}) block: - name: Parse bootstrap secrets data no_log: "{{ hide_sensitive_output | default(true) }}" @@ -35,8 +35,8 @@ - name: Fail when bootstrap secrets inject retries are exhausted ansible.builtin.fail: msg: | - Bootstrap secrets loading failed after {{ vp_secrets_bootstrap_retry_max | default(5) }} attempt(s). - when: (_bootstrap_inject_attempt | int) >= (vp_secrets_bootstrap_retry_max | default(5) | int) + Bootstrap secrets loading failed after {{ vp_secrets_bootstrap_retry_max | default(20) }} attempt(s). + when: (_bootstrap_inject_attempt | int) >= (vp_secrets_bootstrap_retry_max | default(20) | int) - name: Wait before retrying bootstrap secrets inject ansible.builtin.pause: diff --git a/roles/load_secrets/tasks/bootstrap_only.yml b/roles/load_secrets/tasks/bootstrap_only.yml index 65514d8..bf5c202 100644 --- a/roles/load_secrets/tasks/bootstrap_only.yml +++ b/roles/load_secrets/tasks/bootstrap_only.yml @@ -20,7 +20,7 @@ ansible.builtin.debug: msg: >- Loading bootstrap values-secret only from {{ found_bootstrap_file }} - (none backend; up to {{ vp_secrets_bootstrap_retry_max | default(5) }} attempt(s), + (none backend; up to {{ vp_secrets_bootstrap_retry_max | default(20) }} attempt(s), {{ vp_secrets_bootstrap_retry_delay | default(30) }}s between retries on failure). - name: Initialize bootstrap secrets inject attempt counter diff --git a/roles/load_secrets/tasks/optional_bootstrap_load.yml b/roles/load_secrets/tasks/optional_bootstrap_load.yml index 80a32bb..d066eda 100644 --- a/roles/load_secrets/tasks/optional_bootstrap_load.yml +++ b/roles/load_secrets/tasks/optional_bootstrap_load.yml @@ -13,7 +13,7 @@ ansible.builtin.debug: msg: >- Found bootstrap values-secret at {{ found_bootstrap_file }}; loading with the none backend - (up to {{ vp_secrets_bootstrap_retry_max | default(5) }} attempt(s), + (up to {{ vp_secrets_bootstrap_retry_max | default(20) }} attempt(s), {{ vp_secrets_bootstrap_retry_delay | default(30) }}s between retries on failure). when: vp_bootstrap_secrets_present | default(false) | bool From 8a5fbaeee706ab88525ae8bfc02d67c90a08282f Mon Sep 17 00:00:00 2001 From: Martin Jackson Date: Tue, 12 May 2026 13:40:05 -0500 Subject: [PATCH 4/7] Be more aggressive about timers and show a bit more information while loading --- README.md | 2 ++ roles/k8s_secret_utils/defaults/main.yml | 3 ++ .../tasks/inject_k8s_secret.yml | 33 ++++++++++++++++--- .../tasks/inject_k8s_secrets.yml | 4 ++- 4 files changed, 36 insertions(+), 6 deletions(-) diff --git a/README.md b/README.md index 0271e27..d8c0fde 100644 --- a/README.md +++ b/README.md @@ -63,6 +63,8 @@ Bootstrap **inject** retries (parse plus Kubernetes apply) are controlled on the These apply to the optional bootstrap phase inside `load_secrets` and to `load_bootstrap_secrets_only.yml`. +Per-secret namespace readiness (before each `kubernetes.core.k8s` apply) uses role defaults on `k8s_secret_utils`: `k8s_secret_namespace_check_retries` (default `5`) and `k8s_secret_namespace_check_delay` (seconds between attempts, default `45`). If the namespace still does not exist after those attempts, the inject fails and the **outer** bootstrap retry re-runs parse plus all secret injections from the start. + ### Roles (implementation notes) - `roles/load_secrets/tasks/main.yml` implements the **combined** flow (optional bootstrap, then primary). diff --git a/roles/k8s_secret_utils/defaults/main.yml b/roles/k8s_secret_utils/defaults/main.yml index 7ebda20..b674cca 100644 --- a/roles/k8s_secret_utils/defaults/main.yml +++ b/roles/k8s_secret_utils/defaults/main.yml @@ -1,2 +1,5 @@ --- secrets_ns: 'validated-patterns-secrets' +# Namespace wait before injecting each Secret (then outer bootstrap retry can re-run the full inject). +k8s_secret_namespace_check_retries: 5 +k8s_secret_namespace_check_delay: 45 diff --git a/roles/k8s_secret_utils/tasks/inject_k8s_secret.yml b/roles/k8s_secret_utils/tasks/inject_k8s_secret.yml index 410e1a0..f25e2ad 100644 --- a/roles/k8s_secret_utils/tasks/inject_k8s_secret.yml +++ b/roles/k8s_secret_utils/tasks/inject_k8s_secret.yml @@ -1,15 +1,38 @@ --- -- name: Check for secrets namespace +- name: >- + Check for secrets namespace {{ item.metadata.namespace | default('unknown') }} + ({{ k8s_secret_namespace_check_retries | default(5) }} attempts) no_log: '{{ hide_sensitive_output | default(true) }}' 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 + retries: "{{ k8s_secret_namespace_check_retries | default(5) | int }}" + delay: "{{ k8s_secret_namespace_check_delay | default(45) | int }}" -- name: Inject k8s secret - no_log: '{{ hide_sensitive_output | default(True) }}' +- name: >- + Report namespace ready for {{ item.kind | default('Secret') }} {{ item.metadata.name | default('unknown') }} + (namespace {{ item.metadata.namespace | default('unknown') }}) + ansible.builtin.debug: + msg: >- + Namespace '{{ item.metadata.namespace | default('unknown') }}' is ready for + {{ item.kind | default('Secret') }} '{{ item.metadata.name | default('unknown') }}'. + +- name: >- + Inject Kubernetes {{ item.kind | default('Secret') }} {{ item.metadata.name | default('unknown') }} in namespace + {{ item.metadata.namespace | default('unknown') }} + no_log: '{{ hide_sensitive_output | default(true) }}' kubernetes.core.k8s: definition: '{{ item }}' + wait: true + register: k8s_secret_apply_result + changed_when: k8s_secret_apply_result is changed + +- name: >- + Report {{ item.kind | default('Secret') }} apply result for {{ item.metadata.namespace | default('unknown') }}/ + {{ item.metadata.name | default('unknown') }} + ansible.builtin.debug: + msg: >- + Applied {{ item.kind | default('Secret') }} namespace='{{ item.metadata.namespace | default('unknown') }}' + name='{{ item.metadata.name | default('unknown') }}' (changed={{ k8s_secret_apply_result.changed | default(false) }}). diff --git a/roles/k8s_secret_utils/tasks/inject_k8s_secrets.yml b/roles/k8s_secret_utils/tasks/inject_k8s_secrets.yml index fab658f..126a1c6 100644 --- a/roles/k8s_secret_utils/tasks/inject_k8s_secrets.yml +++ b/roles/k8s_secret_utils/tasks/inject_k8s_secrets.yml @@ -1,5 +1,7 @@ --- - name: Inject secrets - no_log: '{{ hide_sensitive_output | default(True) }}' + no_log: '{{ hide_sensitive_output | default(true) }}' ansible.builtin.include_tasks: inject_k8s_secret.yml loop: '{{ kubernetes_secret_objects }}' + loop_control: + label: "{{ item.metadata.namespace | default('?') }}/{{ item.kind | default('Secret') }}/{{ item.metadata.name | default('?') }}" From b913b75291d4303ea1b27df72630d6626fcd5d3a Mon Sep 17 00:00:00 2001 From: Martin Jackson Date: Tue, 12 May 2026 13:59:28 -0500 Subject: [PATCH 5/7] Update docs and fix linter error --- README.md | 19 +++++++++++++------ 1 file changed, 13 insertions(+), 6 deletions(-) diff --git a/README.md b/README.md index d8c0fde..67812f6 100644 --- a/README.md +++ b/README.md @@ -30,6 +30,8 @@ Files may be plain YAML or `ansible-vault` encrypted. Bootstrap files are **never** read from `/` (no `values-secret-*-bootstrap.yaml` under the pattern tree). +Bootstrap files may be **plain YAML or `ansible-vault` encrypted**, the same as primary values-secret files: when encrypted, Ansible prompts for the vault password (or uses your usual `ansible-playbook` vault options). + When not using `VALUES_SECRET` for bootstrap, candidates are checked in order (first existing file wins): - `~/.config/hybrid-cloud-patterns/values-secret--bootstrap.yaml` @@ -43,12 +45,17 @@ Alternatively, set `VALUES_SECRET` to an **existing** file whose name ends with ### Playbooks and flows -| Playbook | What it runs | -| --- | --- | -| `playbooks/load_secrets.yml` | Respects `.global.secretLoader.disabled` in `values-global.yaml`. When enabled: `cluster_pre_check`, optional **bootstrap** load (if a bootstrap file exists; **not** an error if missing), then **primary** discovery, parse, and load using the configured backend. | -| `playbooks/load_bootstrap_secrets.yml` | Convenience wrapper: `determine_pattern_dir`, `determine_pattern_name`, then imports `load_secrets.yml` (same combined bootstrap-then-primary behavior as install). | -| `playbooks/load_bootstrap_secrets_only.yml` | **Bootstrap only**: same pattern discovery plays and `pattern_settings`, then only bootstrap inject (with retries). **Fails** if no bootstrap file is found. Does **not** read `secretLoader.disabled` or load the primary file. | -| `playbooks/display_secrets_info.yml` | Loads and displays parsed primary secrets (using the backend from `values-global`). If a bootstrap file exists, also parses and displays it with backing store `none`. Missing bootstrap is not an error. | +- **`playbooks/load_secrets.yml`** + Respects `.global.secretLoader.disabled` in `values-global.yaml`. When enabled: `cluster_pre_check`, optional **bootstrap** load (if a bootstrap file exists; **not** an error if missing), then **primary** discovery, parse, and load using the configured backend. + +- **`playbooks/load_bootstrap_secrets.yml`** + Convenience wrapper: `determine_pattern_dir`, `determine_pattern_name`, then imports `load_secrets.yml` (same combined bootstrap-then-primary behavior as install). + +- **`playbooks/load_bootstrap_secrets_only.yml`** + **Bootstrap only**: same pattern discovery plays and `pattern_settings`, then only bootstrap inject (with retries). **Fails** if no bootstrap file is found. Does **not** read `secretLoader.disabled` or load the primary file. + +- **`playbooks/display_secrets_info.yml`** + Loads and displays parsed primary secrets (using the backend from `values-global`). If a bootstrap file exists, also parses and displays it with backing store `none`. Missing bootstrap is not an error. Typical usage passes the pattern checkout as `pattern_dir` (for example `-e pattern_dir=/path/to/pattern`). If you omit it, the same resolution as `pattern_settings` applies: `PATTERN_DIR`, then `PWD`, then the `pwd` command. From aadf42126762d309ee286d6cb8ea49cca0da3ea6 Mon Sep 17 00:00:00 2001 From: Martin Jackson Date: Tue, 12 May 2026 14:10:37 -0500 Subject: [PATCH 6/7] Enhance super-linter, fix README, and armor ansible python interpreter discovery --- .github/workflows/superlinter.yml | 2 ++ Makefile | 3 ++- README.md | 6 +++++- roles/cluster_pre_check/tasks/main.yml | 6 +++++- 4 files changed, 14 insertions(+), 3 deletions(-) diff --git a/.github/workflows/superlinter.yml b/.github/workflows/superlinter.yml index 9492da0..509672f 100644 --- a/.github/workflows/superlinter.yml +++ b/.github/workflows/superlinter.yml @@ -27,6 +27,8 @@ jobs: VALIDATE_ALL_CODEBASE: true DEFAULT_BRANCH: main GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + # Skip installed collection copies under .ansible/ (duplicate paths break PYTHON_MYPY and other tools). + FILTER_REGEX_EXCLUDE: '(^|/)\.ansible/' # These are the validation we disable atm VALIDATE_ANSIBLE: false VALIDATE_BIOME_FORMAT: false diff --git a/Makefile b/Makefile index f62ebfb..c2ff280 100644 --- a/Makefile +++ b/Makefile @@ -7,8 +7,9 @@ help: ## This help message .PHONY: super-linter super-linter: ## Runs super linter locally - rm -rf .mypy_cache + rm -rf .mypy_cache .ansible podman run -e RUN_LOCAL=true -e USE_FIND_ALGORITHM=true \ + -e FILTER_REGEX_EXCLUDE='(^|/)\\.ansible/' \ -e VALIDATE_ANSIBLE=false \ -e VALIDATE_BASH=false \ -e VALIDATE_BIOME_FORMAT=false \ diff --git a/README.md b/README.md index 67812f6..6b36599 100644 --- a/README.md +++ b/README.md @@ -70,7 +70,11 @@ Bootstrap **inject** retries (parse plus Kubernetes apply) are controlled on the These apply to the optional bootstrap phase inside `load_secrets` and to `load_bootstrap_secrets_only.yml`. -Per-secret namespace readiness (before each `kubernetes.core.k8s` apply) uses role defaults on `k8s_secret_utils`: `k8s_secret_namespace_check_retries` (default `5`) and `k8s_secret_namespace_check_delay` (seconds between attempts, default `45`). If the namespace still does not exist after those attempts, the inject fails and the **outer** bootstrap retry re-runs parse plus all secret injections from the start. +Per-secret namespace readiness (before each `kubernetes.core.k8s` apply) uses role defaults on `k8s_secret_utils`: + +- `k8s_secret_namespace_check_retries` (default `5`) and `k8s_secret_namespace_check_delay` (seconds between attempts, default `45`). + +If the namespace still does not exist after those attempts, the inject fails and the **outer** bootstrap retry re-runs parse plus all secret injections from the start. ### Roles (implementation notes) diff --git a/roles/cluster_pre_check/tasks/main.yml b/roles/cluster_pre_check/tasks/main.yml index 1dc5f44..036dd7e 100644 --- a/roles/cluster_pre_check/tasks/main.yml +++ b/roles/cluster_pre_check/tasks/main.yml @@ -1,6 +1,10 @@ --- - name: Check if the kubernetes python module is usable from ansible - ansible.builtin.command: "{{ ansible_python_interpreter }} -c 'import kubernetes'" + ansible.builtin.command: + argv: + - "{{ ansible_python_interpreter | default(ansible_playbook_python | default('python3')) }}" + - "-c" + - "import kubernetes" changed_when: false - name: Check if KUBECONFIG is correctly set From a520daafa3b64767a6fe3089938cfe43d19b8b82 Mon Sep 17 00:00:00 2001 From: Martin Jackson Date: Tue, 12 May 2026 14:46:33 -0500 Subject: [PATCH 7/7] Separate VALUES_SECRET_BOOTSTRAP override for bootstrap secrets --- README.md | 8 +++-- playbooks/display_secrets_info.yml | 2 +- .../tasks/find_optional_bootstrap.yml | 34 +++++++++++++++++++ roles/load_secrets/tasks/bootstrap_only.yml | 3 +- 4 files changed, 43 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index 6b36599..5309512 100644 --- a/README.md +++ b/README.md @@ -32,14 +32,18 @@ Bootstrap files are **never** read from `/` (no `values-secret-*-bo Bootstrap files may be **plain YAML or `ansible-vault` encrypted**, the same as primary values-secret files: when encrypted, Ansible prompts for the vault password (or uses your usual `ansible-playbook` vault options). -When not using `VALUES_SECRET` for bootstrap, candidates are checked in order (first existing file wins): +When not using environment overrides, bootstrap candidates are checked in order (first existing file wins): - `~/.config/hybrid-cloud-patterns/values-secret--bootstrap.yaml` - `~/.config/validated-patterns/values-secret--bootstrap.yaml` - `~/values-secret--bootstrap.yaml` - `~/values-secret-bootstrap.yaml` -Alternatively, set `VALUES_SECRET` to an **existing** file whose name ends with `-bootstrap.yaml` (or `-bootstrap.yml`) to use that path for bootstrap discovery in flows that support it. +Bootstrap discovery precedence: + +1. **`VALUES_SECRET_BOOTSTRAP`** – if set to a path that exists, that file is used for bootstrap only (any filename). Primary `VALUES_SECRET` is unchanged. +2. **`VALUES_SECRET`** – if set to an **existing** file whose name ends with `-bootstrap.yaml` or `-bootstrap.yml`, that file is used for bootstrap (and primary loading will ignore `VALUES_SECRET` for the primary file search so a separate primary file can be found). +3. Otherwise the candidate paths above are searched. **Bootstrap is always parsed and applied with backing store `none`** (Kubernetes secret injection path), which requires schema version 2.0 or newer in the bootstrap file. diff --git a/playbooks/display_secrets_info.yml b/playbooks/display_secrets_info.yml index 630a288..0d1f280 100644 --- a/playbooks/display_secrets_info.yml +++ b/playbooks/display_secrets_info.yml @@ -18,7 +18,7 @@ vars: hide_sensitive_output: false tasks: - # Set the VALUES_SECRET environment variable to the file to parse + # Primary file: VALUES_SECRET. Bootstrap file: VALUES_SECRET_BOOTSTRAP, bootstrap-named VALUES_SECRET, or search paths. - name: Find and decrypt secrets if needed ansible.builtin.include_role: name: find_vp_secrets diff --git a/roles/find_vp_secrets/tasks/find_optional_bootstrap.yml b/roles/find_vp_secrets/tasks/find_optional_bootstrap.yml index d1d9a48..50aa01c 100644 --- a/roles/find_vp_secrets/tasks/find_optional_bootstrap.yml +++ b/roles/find_vp_secrets/tasks/find_optional_bootstrap.yml @@ -8,9 +8,40 @@ found_bootstrap_file: '' vp_bootstrap_loaded_via_values_secret_env: false +# Dedicated bootstrap override (any path). Does not affect primary VALUES_SECRET resolution. +- name: Read VALUES_SECRET_BOOTSTRAP for optional bootstrap discovery + ansible.builtin.set_fact: + bootstrap_dedicated_env_values_secret: "{{ lookup('ansible.builtin.env', 'VALUES_SECRET_BOOTSTRAP') | default('', true) }}" + +- name: Check if VALUES_SECRET_BOOTSTRAP points to an existing file + ansible.builtin.stat: + path: "{{ bootstrap_dedicated_env_values_secret }}" + register: bootstrap_dedicated_file_stat + when: bootstrap_dedicated_env_values_secret | default('') | string | length > 0 + +- name: Fail if VALUES_SECRET_BOOTSTRAP is set but file is missing + ansible.builtin.fail: + msg: >- + VALUES_SECRET_BOOTSTRAP is set to {{ bootstrap_dedicated_env_values_secret }} but that path does not exist. + when: + - bootstrap_dedicated_env_values_secret | default('') | string | length > 0 + - bootstrap_dedicated_file_stat.stat is defined + - not bootstrap_dedicated_file_stat.stat.exists + +- name: Use VALUES_SECRET_BOOTSTRAP as bootstrap secrets file + ansible.builtin.set_fact: + found_bootstrap_file: "{{ bootstrap_dedicated_file_stat.stat.path }}" + vp_bootstrap_loaded_via_values_secret_env: false + when: + - bootstrap_dedicated_env_values_secret | default('') | string | length > 0 + - bootstrap_dedicated_file_stat.stat is defined + - bootstrap_dedicated_file_stat.stat.exists + +# Legacy: VALUES_SECRET only if its basename looks like a bootstrap file (primary search still uses VALUES_SECRET otherwise). - name: Read VALUES_SECRET for optional bootstrap discovery ansible.builtin.set_fact: bootstrap_custom_env_values_secret: "{{ lookup('ansible.builtin.env', 'VALUES_SECRET') }}" + when: (found_bootstrap_file | default('') | string | length) == 0 - name: Decide if VALUES_SECRET names a bootstrap file ansible.builtin.set_fact: @@ -19,12 +50,14 @@ (bootstrap_custom_env_values_secret | default('') | string | length > 0) and (bootstrap_custom_env_values_secret | regex_search('-bootstrap\.ya?ml$') is not none) }} + when: (found_bootstrap_file | default('') | string | length) == 0 - name: Check if VALUES_SECRET points to an existing file (bootstrap) ansible.builtin.stat: path: "{{ bootstrap_custom_env_values_secret }}" register: bootstrap_custom_file_values_secret when: + - (found_bootstrap_file | default('') | string | length) == 0 - bootstrap_custom_env_values_secret | default('') | length > 0 - _bootstrap_env_is_bootstrap_named | default(false) | bool @@ -33,6 +66,7 @@ found_bootstrap_file: "{{ bootstrap_custom_file_values_secret.stat.path }}" vp_bootstrap_loaded_via_values_secret_env: true when: + - (found_bootstrap_file | default('') | string | length) == 0 - bootstrap_custom_env_values_secret | default('') | length > 0 - _bootstrap_env_is_bootstrap_named | default(false) | bool - bootstrap_custom_file_values_secret.stat is defined diff --git a/roles/load_secrets/tasks/bootstrap_only.yml b/roles/load_secrets/tasks/bootstrap_only.yml index bf5c202..7f1c8b7 100644 --- a/roles/load_secrets/tasks/bootstrap_only.yml +++ b/roles/load_secrets/tasks/bootstrap_only.yml @@ -13,7 +13,8 @@ ansible.builtin.fail: msg: | No bootstrap values-secret file was found. Install one of the expected bootstrap paths, - or set VALUES_SECRET to an existing file whose name ends with -bootstrap.yaml. + set VALUES_SECRET_BOOTSTRAP to an existing file, or set VALUES_SECRET to an existing file + whose name ends with -bootstrap.yaml. when: not (vp_bootstrap_secrets_present | default(false) | bool) - name: Note bootstrap values-secret file in use