Skip to content

Commit 3f0e3d8

Browse files
author
Martin Jackson
committed
Initial concept of boostrap secrets
1 parent 653f095 commit 3f0e3d8

13 files changed

Lines changed: 395 additions & 61 deletions

README.md

Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,3 +8,63 @@ The main purpose of this collections are to:
88
loading local secrets files into VP secrets stores.
99

1010
2. Help manage imperative and other utility functions of the cluster
11+
12+
## Secrets loading
13+
14+
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`).
15+
16+
### Primary values-secret (standard load)
17+
18+
- **Backing store** comes from `values-global.yaml`: `.global.secretStore.backend` (default `vault`). That drives parsing and whether secrets go to Vault or Kubernetes.
19+
- **Discovery order** when `VALUES_SECRET` is unset (first existing file wins):
20+
`~/.config/hybrid-cloud-patterns/values-secret-<pattern>.yaml`,
21+
`~/.config/validated-patterns/values-secret-<pattern>.yaml`,
22+
`~/values-secret-<pattern>.yaml`,
23+
`~/values-secret.yaml`,
24+
then `<pattern_dir>/values-secret.yaml.template`.
25+
- 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.
26+
27+
Files may be plain YAML or `ansible-vault` encrypted.
28+
29+
### Bootstrap values-secret (optional)
30+
31+
Bootstrap files are **never** read from `<pattern_dir>/` (no `values-secret-*-bootstrap.yaml` under the pattern tree).
32+
33+
When not using `VALUES_SECRET` for bootstrap, candidates are checked in order (first existing file wins):
34+
35+
- `~/.config/hybrid-cloud-patterns/values-secret-<pattern>-bootstrap.yaml`
36+
- `~/.config/validated-patterns/values-secret-<pattern>-bootstrap.yaml`
37+
- `~/values-secret-<pattern>-bootstrap.yaml`
38+
- `~/values-secret-bootstrap.yaml`
39+
40+
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.
41+
42+
**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.
43+
44+
### Playbooks and flows
45+
46+
| Playbook | What it runs |
47+
|----------|----------------|
48+
| `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. |
49+
| `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). |
50+
| `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. |
51+
| `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. |
52+
53+
Typical usage sets `-e pattern_dir=...` to the pattern checkout (and relies on `values-global.yaml` there via `pattern_settings`).
54+
55+
`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.
56+
57+
### Bootstrap retries
58+
59+
Bootstrap **inject** retries (parse plus Kubernetes apply) are controlled on the role defaults / extra-vars:
60+
61+
- `vp_secrets_bootstrap_retry_max` (default `5`)
62+
- `vp_secrets_bootstrap_retry_delay` (seconds between attempts, default `30`)
63+
64+
These apply to the optional bootstrap phase inside `load_secrets` and to `load_bootstrap_secrets_only.yml`.
65+
66+
### Roles (implementation notes)
67+
68+
- `roles/load_secrets/tasks/main.yml` implements the **combined** flow (optional bootstrap, then primary).
69+
- `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).
70+
- `roles/find_vp_secrets` resolves primary files (`tasks/main.yml`) and optional bootstrap discovery (`tasks/find_optional_bootstrap.yml`).

playbooks/display_secrets_info.yml

Lines changed: 23 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,28 @@
3636
secrets_backing_store: "{{ secrets_backing_store }}"
3737
register: secrets_results
3838

39-
- name: Display secrets data
39+
- name: Display primary secrets data
4040
ansible.builtin.debug:
4141
var: secrets_results
42+
43+
- name: Snapshot primary secrets for optional bootstrap display
44+
ansible.builtin.set_fact:
45+
_primary_values_secrets_data_snapshot: "{{ values_secrets_data }}"
46+
47+
- name: Discover optional bootstrap values-secret file
48+
ansible.builtin.include_role:
49+
name: find_vp_secrets
50+
tasks_from: find_optional_bootstrap.yml
51+
52+
- name: Parse bootstrap secrets data (none backend)
53+
no_log: '{{ hide_sensitive_output }}'
54+
parse_secrets_info:
55+
values_secrets_plaintext: "{{ values_secrets_bootstrap_data }}"
56+
secrets_backing_store: none
57+
register: bootstrap_secrets_results
58+
when: vp_bootstrap_secrets_present | default(false) | bool
59+
60+
- name: Display bootstrap secrets data
61+
ansible.builtin.debug:
62+
var: bootstrap_secrets_results
63+
when: vp_bootstrap_secrets_present | default(false) | bool
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
---
2+
# Post-install alias: runs the same secrets load as load_secrets.yml (optional bootstrap, then primary).
3+
# Optional extra-vars: vp_secrets_bootstrap_retry_max, vp_secrets_bootstrap_retry_delay (seconds).
4+
- name: Determine pattern directory
5+
ansible.builtin.import_playbook: ./determine_pattern_dir.yml
6+
7+
- name: Determine pattern name
8+
ansible.builtin.import_playbook: ./determine_pattern_name.yml
9+
10+
- name: Load secrets (optional bootstrap then standard)
11+
ansible.builtin.import_playbook: ./load_secrets.yml
Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
---
2+
# Load only bootstrap values-secret files (none backend). Does not load the primary values-secret
3+
# or honor secretLoader.disabled from values-global. Fails if no bootstrap file exists.
4+
# Optional extra-vars: vp_secrets_bootstrap_retry_max, vp_secrets_bootstrap_retry_delay (seconds).
5+
- name: Determine pattern directory
6+
ansible.builtin.import_playbook: ./determine_pattern_dir.yml
7+
8+
- name: Determine pattern name
9+
ansible.builtin.import_playbook: ./determine_pattern_name.yml
10+
11+
- name: Load bootstrap secrets only
12+
hosts: localhost
13+
connection: local
14+
gather_facts: false
15+
become: false
16+
roles:
17+
- pattern_settings
18+
19+
tasks:
20+
- name: Run bootstrap-only secrets load
21+
ansible.builtin.include_role:
22+
name: load_secrets
23+
tasks_from: bootstrap_only.yml
Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,80 @@
1+
---
2+
# Sets values_secrets_bootstrap_data when a bootstrap values-secret file exists; otherwise no-op.
3+
# Expects: pattern_name, and _primary_values_secrets_data_snapshot when restoring after read.
4+
- name: Clear bootstrap secrets facts from any prior play
5+
ansible.builtin.set_fact:
6+
values_secrets_bootstrap_data: ''
7+
vp_bootstrap_secrets_present: false
8+
found_bootstrap_file: ''
9+
vp_bootstrap_loaded_via_values_secret_env: false
10+
11+
- name: Read VALUES_SECRET for optional bootstrap discovery
12+
ansible.builtin.set_fact:
13+
bootstrap_custom_env_values_secret: "{{ lookup('ansible.builtin.env', 'VALUES_SECRET') }}"
14+
15+
- name: Decide if VALUES_SECRET names a bootstrap file
16+
ansible.builtin.set_fact:
17+
_bootstrap_env_is_bootstrap_named: >-
18+
{{
19+
(bootstrap_custom_env_values_secret | default('') | string | length > 0)
20+
and (bootstrap_custom_env_values_secret | regex_search('-bootstrap\.ya?ml$') is not none)
21+
}}
22+
23+
- name: Check if VALUES_SECRET points to an existing file (bootstrap)
24+
ansible.builtin.stat:
25+
path: "{{ bootstrap_custom_env_values_secret }}"
26+
register: bootstrap_custom_file_values_secret
27+
when:
28+
- bootstrap_custom_env_values_secret | default('') | length > 0
29+
- _bootstrap_env_is_bootstrap_named | default(false) | bool
30+
31+
- name: Use VALUES_SECRET as bootstrap secrets file
32+
ansible.builtin.set_fact:
33+
found_bootstrap_file: "{{ bootstrap_custom_file_values_secret.stat.path }}"
34+
vp_bootstrap_loaded_via_values_secret_env: true
35+
when:
36+
- bootstrap_custom_env_values_secret | default('') | length > 0
37+
- _bootstrap_env_is_bootstrap_named | default(false) | bool
38+
- bootstrap_custom_file_values_secret.stat is defined
39+
- bootstrap_custom_file_values_secret.stat.exists
40+
41+
- name: Build bootstrap values-secret candidate paths
42+
ansible.builtin.set_fact:
43+
_vp_bootstrap_secret_candidates:
44+
- "~/.config/hybrid-cloud-patterns/values-secret-{{ pattern_name }}-bootstrap.yaml"
45+
- "~/.config/validated-patterns/values-secret-{{ pattern_name }}-bootstrap.yaml"
46+
- "~/values-secret-{{ pattern_name }}-bootstrap.yaml"
47+
- "~/values-secret-bootstrap.yaml"
48+
when: (found_bootstrap_file | default('') | string | length) == 0
49+
50+
- name: Stat bootstrap candidate paths
51+
ansible.builtin.stat:
52+
path: "{{ item }}"
53+
loop: "{{ _vp_bootstrap_secret_candidates }}"
54+
register: _vp_bootstrap_stat_results
55+
when: (found_bootstrap_file | default('') | string | length) == 0
56+
57+
- name: Pick first existing bootstrap secrets file from candidates
58+
ansible.builtin.set_fact:
59+
found_bootstrap_file: "{{ (_vp_bootstrap_stat_results.results | default([]) | selectattr('stat.exists') | map(attribute='item') | list | first) | default('') }}"
60+
when:
61+
- (found_bootstrap_file | default('') | string | length) == 0
62+
- _vp_bootstrap_stat_results.results is defined
63+
64+
- name: Read bootstrap secrets when a bootstrap file was found
65+
when: (found_bootstrap_file | default('') | string | length) > 0
66+
block:
67+
- name: Load bootstrap secrets from file
68+
ansible.builtin.include_tasks: read_secret_from_path.yml
69+
vars:
70+
found_file: "{{ found_bootstrap_file }}"
71+
72+
- name: Publish bootstrap secrets data for display
73+
ansible.builtin.set_fact:
74+
values_secrets_bootstrap_data: "{{ values_secrets_data }}"
75+
vp_bootstrap_secrets_present: true
76+
77+
- name: Restore primary values_secrets_data after bootstrap read
78+
ansible.builtin.set_fact:
79+
values_secrets_data: "{{ _primary_values_secrets_data_snapshot }}"
80+
when: _primary_values_secrets_data_snapshot is defined

roles/find_vp_secrets/tasks/main.yml

Lines changed: 9 additions & 51 deletions
Original file line numberDiff line numberDiff line change
@@ -5,9 +5,14 @@
55
ansible.builtin.set_fact:
66
secret_template: "{{ pattern_dir }}/values-secret.yaml.template"
77

8-
- name: Is a VALUES_SECRET env variable set?
8+
- name: Resolve VALUES_SECRET for primary secrets search
99
ansible.builtin.set_fact:
10-
custom_env_values_secret: "{{ lookup('ansible.builtin.env', 'VALUES_SECRET') }}"
10+
custom_env_values_secret: >-
11+
{{
12+
''
13+
if (vp_skip_values_secret_env_for_primary | default(false) | bool)
14+
else (lookup('ansible.builtin.env', 'VALUES_SECRET') | default('', true))
15+
}}
1116
1217
- name: Check if VALUES_SECRET file exists
1318
ansible.builtin.stat:
@@ -37,52 +42,5 @@
3742
- "{{ pattern_dir }}/values-secret.yaml.template"
3843
when: custom_env_values_secret | default('') | length == 0
3944

40-
- name: Is found values secret file encrypted
41-
no_log: "{{ hide_sensitive_output | default(true) }}"
42-
ansible.builtin.shell: |
43-
set -o pipefail
44-
head -1 "{{ found_file }}" | grep -q \$ANSIBLE_VAULT
45-
changed_when: false
46-
register: encrypted
47-
failed_when: (encrypted.rc not in [0, 1])
48-
49-
# When HOME is set we replace it with '~' in this debug message
50-
# because when run from inside the container the HOME is /pattern-home
51-
# which is confusing for users
52-
- name: Is found values secret file encrypted
53-
ansible.builtin.debug:
54-
msg: "Using {{ (lookup('env', 'HOME') | length > 0) | ternary(found_file | regex_replace('^' + lookup('env', 'HOME'), '~'), found_file) }} to parse secrets"
55-
56-
- name: Set encryption bool fact
57-
no_log: "{{ hide_sensitive_output | default(true) }}"
58-
ansible.builtin.set_fact:
59-
is_encrypted: "{{ encrypted.rc == 0 | bool }}"
60-
61-
- name: Get password for "{{ found_file }}"
62-
ansible.builtin.pause:
63-
prompt: "Input the password for {{ found_file }}"
64-
echo: false
65-
when: is_encrypted
66-
register: vault_pass
67-
68-
- name: Get decrypted content if {{ found_file }} was encrypted
69-
no_log: "{{ hide_sensitive_output | default(true) }}"
70-
ansible.builtin.shell:
71-
ansible-vault view --vault-password-file <(cat <<<"{{ vault_pass.user_input }}") "{{ found_file }}"
72-
register: values_secret_plaintext
73-
when: is_encrypted
74-
changed_when: false
75-
76-
- name: Normalize secrets format (un-encrypted)
77-
no_log: '{{ hide_sensitive_output | default(true) }}'
78-
ansible.builtin.set_fact:
79-
values_secrets_data: "{{ lookup('file', found_file) | from_yaml }}"
80-
when: not is_encrypted
81-
changed_when: false
82-
83-
- name: Normalize secrets format (encrypted)
84-
no_log: '{{ hide_sensitive_output | default(true) }}'
85-
ansible.builtin.set_fact:
86-
values_secrets_data: "{{ values_secret_plaintext.stdout | from_yaml }}"
87-
when: is_encrypted
88-
changed_when: false
45+
- name: Read secrets from discovered file
46+
ansible.builtin.include_tasks: read_secret_from_path.yml
Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
---
2+
# Reads YAML from found_file (vault-encrypted or plain) into values_secrets_data.
3+
# Expects: found_file, hide_sensitive_output (optional)
4+
- name: Is found values secret file encrypted
5+
no_log: "{{ hide_sensitive_output | default(true) }}"
6+
ansible.builtin.shell: |
7+
set -o pipefail
8+
head -1 "{{ found_file }}" | grep -q \$ANSIBLE_VAULT
9+
changed_when: false
10+
register: encrypted
11+
failed_when: (encrypted.rc not in [0, 1])
12+
13+
# When HOME is set we replace it with '~' in this debug message
14+
# because when run from inside the container the HOME is /pattern-home
15+
# which is confusing for users
16+
- name: Is found values secret file encrypted
17+
ansible.builtin.debug:
18+
msg: "Using {{ (lookup('env', 'HOME') | length > 0) | ternary(found_file | regex_replace('^' + lookup('env', 'HOME'), '~'), found_file) }} to parse secrets"
19+
20+
- name: Set encryption bool fact
21+
no_log: "{{ hide_sensitive_output | default(true) }}"
22+
ansible.builtin.set_fact:
23+
is_encrypted: "{{ encrypted.rc == 0 | bool }}"
24+
25+
- name: Get password for "{{ found_file }}"
26+
ansible.builtin.pause:
27+
prompt: "Input the password for {{ found_file }}"
28+
echo: false
29+
when: is_encrypted
30+
register: vault_pass
31+
32+
- name: Get decrypted content if {{ found_file }} was encrypted
33+
no_log: "{{ hide_sensitive_output | default(true) }}"
34+
ansible.builtin.shell:
35+
ansible-vault view --vault-password-file <(cat <<<"{{ vault_pass.user_input }}") "{{ found_file }}"
36+
register: values_secret_plaintext
37+
when: is_encrypted
38+
changed_when: false
39+
40+
- name: Normalize secrets format (un-encrypted)
41+
no_log: "{{ hide_sensitive_output | default(true) }}"
42+
ansible.builtin.set_fact:
43+
values_secrets_data: "{{ lookup('file', found_file) | from_yaml }}"
44+
when: not is_encrypted
45+
changed_when: false
46+
47+
- name: Normalize secrets format (encrypted)
48+
no_log: "{{ hide_sensitive_output | default(true) }}"
49+
ansible.builtin.set_fact:
50+
values_secrets_data: "{{ values_secret_plaintext.stdout | from_yaml }}"
51+
when: is_encrypted
52+
changed_when: false
Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
1-
secrets_role: "vault_utils"
2-
tasks_from: "push_parsed_secrets"
3-
hide_sensitive_output: true
1+
---
2+
vp_secrets_bootstrap_retry_max: 5
3+
vp_secrets_bootstrap_retry_delay: 30
4+
secrets_role: vault_utils
5+
tasks_from: push_parsed_secrets
Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
---
2+
- name: Ensure bootstrap inject attempt counter
3+
ansible.builtin.set_fact:
4+
_bootstrap_inject_attempt: "{{ _bootstrap_inject_attempt | default(1) | int }}"
5+
6+
- name: Determine bootstrap secrets YAML shape
7+
ansible.builtin.set_fact:
8+
secrets_bootstrap_yaml: "{{ values_secrets_bootstrap_data if values_secrets_bootstrap_data is not string else values_secrets_bootstrap_data | from_yaml }}"
9+
10+
- name: Fail when bootstrap schema is too old for none backend loading
11+
ansible.builtin.fail:
12+
msg: Bootstrap secrets require values-secret schema version 2.0 or higher when using the none backend.
13+
when: (secrets_bootstrap_yaml.version | default('2.0')) is version('2.0', '<')
14+
15+
- name: Parse and inject bootstrap secrets (attempt {{ _bootstrap_inject_attempt }}/{{ vp_secrets_bootstrap_retry_max | default(5) }})
16+
block:
17+
- name: Parse bootstrap secrets data
18+
no_log: "{{ hide_sensitive_output | default(true) }}"
19+
parse_secrets_info:
20+
values_secrets_plaintext: "{{ values_secrets_bootstrap_data }}"
21+
secrets_backing_store: none
22+
register: bootstrap_secrets_results
23+
24+
- name: Inject bootstrap secrets into the cluster
25+
ansible.builtin.include_role:
26+
name: k8s_secret_utils
27+
tasks_from: inject_k8s_secrets
28+
vars:
29+
kubernetes_secret_objects: "{{ bootstrap_secrets_results.kubernetes_secret_objects }}"
30+
vault_policies: "{{ bootstrap_secrets_results.vault_policies }}"
31+
parsed_secrets: "{{ bootstrap_secrets_results.parsed_secrets }}"
32+
unique_vault_prefixes: "{{ bootstrap_secrets_results.unique_vault_prefixes | default([]) }}"
33+
34+
rescue:
35+
- name: Fail when bootstrap secrets inject retries are exhausted
36+
ansible.builtin.fail:
37+
msg: |
38+
Bootstrap secrets loading failed after {{ vp_secrets_bootstrap_retry_max | default(5) }} attempt(s).
39+
when: (_bootstrap_inject_attempt | int) >= (vp_secrets_bootstrap_retry_max | default(5) | int)
40+
41+
- name: Wait before retrying bootstrap secrets inject
42+
ansible.builtin.pause:
43+
seconds: "{{ vp_secrets_bootstrap_retry_delay | default(30) | int }}"
44+
45+
- name: Bump bootstrap secrets inject attempt counter
46+
ansible.builtin.set_fact:
47+
_bootstrap_inject_attempt: "{{ (_bootstrap_inject_attempt | int) + 1 }}"
48+
49+
- name: Retry bootstrap secrets inject
50+
ansible.builtin.include_tasks: bootstrap_inject_retry.yml

0 commit comments

Comments
 (0)