Files
config-public/ansible/roles/compose_stack/tasks/secrets.yml
T
Lucas WintherandClaude Opus 5.5 665ac7d52f Render stack secrets from OpenBao in compose_stack
stack_secrets reads KV v2 on the target host and writes the stack's .env at
0600, failing loudly on a sealed vault, a bad token, a missing field or an
unsafe value. Adds an example OpenBao stack on app and moves webapp onto it;
metrics keeps a hand-placed .env.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-30 15:15:04 +02:00

177 lines
7.4 KiB
YAML

---
# Rendering the stack's .env from the vault. Included at the end of sync.yml,
# because the .env is payload delivery: every path that syncs gets it, including
# `-e start=false` and two-phase playbooks that call tasks_from: sync. There is
# deliberately no third entry point for callers to remember.
- name: Verify a vault token is available
ansible.builtin.assert:
that:
- stack_vault_token | length > 0
fail_msg: >-
{{ stack_name }} declares stack_secrets, but VAULT_TOKEN is not set in the
environment this deploy is running from. See docs/secrets.md.
quiet: true
# 200 active, 429 standby, 501 not initialised, 503 sealed. All four are answers
# rather than errors, so accept them and let the assert below say which it was.
- name: Ask the vault whether it is ready
ansible.builtin.uri:
url: "{{ stack_vault_addr }}/v1/sys/health"
status_code: [200, 429, 501, 503]
register: compose_stack_vault_health
# uri does not implement check mode. Without this a --check run skips the read
# and the render below has nothing to write. It is a GET; running it during a
# check is correct.
check_mode: false
changed_when: false
- name: Verify the vault is initialised and unsealed
ansible.builtin.assert:
that:
- compose_stack_vault_health.json.initialized | default(false)
- not (compose_stack_vault_health.json.sealed | default(true))
fail_msg: >-
the vault at {{ stack_vault_addr }} on {{ inventory_hostname }} is not
ready to answer (initialised={{ compose_stack_vault_health.json.initialized | default('?') }},
sealed={{ compose_stack_vault_health.json.sealed | default('?') }}).
Unseal it before deploying — see docs/secrets.md.
quiet: true
- name: Read the stack's secrets from the vault
ansible.builtin.uri:
url: "{{ stack_vault_addr }}/v1/{{ stack_vault_mount }}/data/{{ item.value.path }}"
headers:
X-Vault-Token: "{{ stack_vault_token }}"
# 404 (wrong path or mount) and 403 (expired, revoked or under-scoped
# token) are the two most likely real failures. Accept them here too and
# let the assert below say which one it was, instead of a bare "censored"
# failure that names neither.
status_code: [200, 403, 404]
register: compose_stack_vault_reads
loop: "{{ stack_secrets | dict2items }}"
loop_control:
label: "{{ item.key }}"
no_log: true
check_mode: false
changed_when: false
# Reset first: facts persist for the host across every stack in a site.yml run,
# so without this the list accumulates and the second stack inherits the first
# stack's verdict.
- name: Start a fresh list of secrets the vault could not read
ansible.builtin.set_fact:
compose_stack_vault_failed: []
- name: Work out which reads the vault rejected
ansible.builtin.set_fact:
compose_stack_vault_failed: >-
{{ compose_stack_vault_failed
+ (["%s (path %s, mount %s): HTTP %s" | format(
item.item.key, item.item.value.path, stack_vault_mount, item.status)]
if item.status != 200 else []) }}
loop: "{{ compose_stack_vault_reads.results }}"
loop_control:
label: "{{ item.item.key }}"
no_log: true
# Deliberately without no_log: this task sees only variable names, paths and
# HTTP status codes, never values, and a censored failure message would be
# useless — a bare "censored" failure names neither the variable nor the cause.
- name: Verify every secret was readable
ansible.builtin.assert:
that:
- compose_stack_vault_failed | length == 0
fail_msg: >-
the vault rejected: {{ compose_stack_vault_failed | join(', ') }}. A 404
means the path or mount is wrong; a 403 means stack_vault_token is
expired, revoked or under-scoped. See docs/secrets.md.
quiet: true
# Reset first, for the same reason as compose_stack_vault_failed above.
- name: Start a fresh list of secrets the vault did not return
ansible.builtin.set_fact:
compose_stack_vault_missing: []
- name: Work out which requested fields the vault did not return
ansible.builtin.set_fact:
compose_stack_vault_missing: >-
{{ compose_stack_vault_missing
+ ([item.item.key] if item.item.value.field not in (item.json.data.data | default({}, true)) else []) }}
loop: "{{ compose_stack_vault_reads.results }}"
loop_control:
label: "{{ item.item.key }}"
no_log: true
# Deliberately without no_log: this task sees only variable names, never values,
# and a censored failure message would be useless.
- name: Verify every secret the stack asked for was found
ansible.builtin.assert:
that:
- compose_stack_vault_missing | length == 0
fail_msg: >-
the vault returned no value for: {{ compose_stack_vault_missing | join(', ') }}.
Check the path and field each one names in stack_secrets against what is
under {{ stack_vault_mount }}/ on {{ inventory_hostname }}.
quiet: true
# The rendered file is Compose's project .env, whose dotenv parser trims
# unquoted whitespace, treats a whitespace-preceded '#' as a comment, and
# interpolates '$'. A newline corrupts the file outright. Refuse rather than
# silently truncate or expand a credential.
#
# Reset first, for the same reason as compose_stack_vault_failed above.
- name: Start a fresh list of secrets that cannot be safely rendered
ansible.builtin.set_fact:
compose_stack_vault_unsafe: []
- name: Work out which values contain a newline or a single quote
ansible.builtin.set_fact:
compose_stack_vault_unsafe: >-
{{ compose_stack_vault_unsafe
+ ([item.item.key]
if (item.json.data.data[item.item.value.field] is search('\n')
or item.json.data.data[item.item.value.field] is search('\x27'))
else []) }}
loop: "{{ compose_stack_vault_reads.results }}"
loop_control:
label: "{{ item.item.key }}"
no_log: true
# Deliberately without no_log, and deliberately not looped: this task sees
# only variable names, never values, and needs no "item" for a failure to
# dump — a per-item assert here would fail with the loop's current item
# (the plaintext value) attached to the result, which is exactly the leak
# this whole check exists to prevent.
- name: Verify no secret value contains a newline or a single quote
ansible.builtin.assert:
that:
- compose_stack_vault_unsafe | length == 0
fail_msg: >-
{{ compose_stack_vault_unsafe | join(', ') }} cannot be safely rendered
into {{ stack_dest }}/.env: the value contains a newline or a single
quote. Change the value in the vault. See docs/secrets.md.
quiet: true
- name: Render the stack's .env from the vault
ansible.builtin.copy:
content: |
# Rendered by the compose_stack role from {{ stack_vault_addr }}.
# Edits here are overwritten on the next deploy — change the value in the
# vault instead. See docs/secrets.md.
{% for read in compose_stack_vault_reads.results %}
{{ read.item.key }}='{{ read.json.data.data[read.item.value.field] }}'
{% endfor %}
dest: "{{ stack_dest }}/.env"
mode: "0600"
no_log: true
# compose_stack_vault_reads holds every plaintext value in host vars for the
# rest of the play, available to a later post_tasks debug or a callback
# plugin. Nothing here dumps it, but there is no reason to leave it lying
# around once the .env is written.
- name: Clear the vault reads now that the .env is rendered
ansible.builtin.set_fact:
compose_stack_vault_reads: {}
no_log: true