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>
177 lines
7.4 KiB
YAML
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
|