--- # 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