diff --git a/.gitignore b/.gitignore index a7e059f..0554506 100644 --- a/.gitignore +++ b/.gitignore @@ -39,3 +39,4 @@ primitive.txt # mutation testing .mutmut-cache mutants/ +.vault-ca.crt diff --git a/Makefile b/Makefile index 79bf378..2639da8 100644 --- a/Makefile +++ b/Makefile @@ -29,6 +29,12 @@ build: # Main Development Tasks # ============================================================================= +# Anything needing Vault goes through tools/vault-exec.sh, which fixes the three +# ways host credentials fail to reach the container: creation-time +# interpolation, 127.0.0.1 meaning the container's own loopback, and TLS +# degrading to "disabled" because the CA path is a host path. See its header. +VAULT_EXEC := ./tools/vault-exec.sh + validate: grammar @echo "--- Validating all schemas against the meta-schema ---" @docker compose exec builder python tools/compiler.py validate @@ -97,22 +103,22 @@ gate.local: validate.local test.local provenance pledge: @echo "--- Developer commitment: validity + createdBy signed by Vault ---" - @docker compose exec builder python tools/compiler.py pledge + @$(VAULT_EXEC) python tools/compiler.py pledge # The grammar gate runs INSIDE compiler.py release, not here: a step only the # Makefile performs is bypassed by calling the tool directly. release: @echo "--- Building and signing release schemas ---" - @docker compose exec builder python tools/compiler.py release + @$(VAULT_EXEC) python tools/compiler.py release verify-release: @if [ -z "$(FILE)" ]; then echo "Usage: make verify-release FILE=release/-vX.Y.Z.yaml [STRICT=1] [TRUST_ROOT=path/to/root.pem]"; exit 1; fi - @docker compose exec builder python tools/compiler.py verify-release $(FILE) \ + @$(VAULT_EXEC) python tools/compiler.py verify-release $(FILE) \ $(if $(STRICT),--strict,) $(if $(TRUST_ROOT),--trust-root $(TRUST_ROOT),) verify-release-strict: @if [ -z "$(FILE)" ]; then echo "Usage: make verify-release-strict FILE=release/-vX.Y.Z.yaml"; exit 1; fi - @docker compose exec builder python tools/compiler.py verify-release $(FILE) --strict + @$(VAULT_EXEC) python tools/compiler.py verify-release $(FILE) --strict test: @echo "--- Running pytest for the compiler infrastructure ---" diff --git a/tools/vault-exec.sh b/tools/vault-exec.sh new file mode 100755 index 0000000..3fa65fd --- /dev/null +++ b/tools/vault-exec.sh @@ -0,0 +1,66 @@ +#!/usr/bin/env bash +# +# Run a command in the builder container with working Vault access. +# +# Three things go wrong between a host shell that can reach Vault and a +# container that cannot, and each one produces a misleading error: +# +# 1. The container never sees the credentials. +# docker-compose.yml interpolates ${VAULT_ADDR} and ${VAULT_TOKEN} when the +# container is CREATED. A builder started before the token was exported +# captures empty strings for its whole life, and `make release` then reports +# "VAULT_ADDR and VAULT_TOKEN must be set" while the caller's shell has both. +# Passing them at exec time removes the dependency on who started it, when. +# +# 2. 127.0.0.1 means something else inside a container. +# VAULT_ADDR=https://127.0.0.1:18200 is correct on the host and points at the +# container's own loopback inside it — "Connection refused", with a traceback +# that says nothing about namespaces. The compose file already provides +# host.docker.internal; this rewrites the host part to use it. +# +# 3. TLS gets switched off for the wrong reason. +# compiler.py reads VAULT_CACERT; the host convention here is +# VAULT_CA_CERT_FILE, and the file lives under $XDG_RUNTIME_DIR, which is not +# mounted. So verification silently degraded to "disabled" even though the +# server certificate lists host.docker.internal in its SAN and verification +# would succeed. The CA is copied into the mounted tree and pointed at. +# +# Usage: tools/vault-exec.sh [args...] + +set -euo pipefail + +REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +cd "$REPO_ROOT" + +if [ -z "${VAULT_ADDR:-}" ] || [ -z "${VAULT_TOKEN:-}" ]; then + echo "[!] VAULT_ADDR and VAULT_TOKEN must be set in this shell." >&2 + exit 1 +fi + +# The host address, as the container has to say it. +CONTAINER_ADDR="${VAULT_ADDR/127.0.0.1/host.docker.internal}" +CONTAINER_ADDR="${CONTAINER_ADDR/localhost/host.docker.internal}" + +# The CA, if the host has one. VAULT_CACERT is what compiler.py reads; +# VAULT_CA_CERT_FILE is what the host tooling sets. Accept either. +CA_SRC="${VAULT_CACERT:-${VAULT_CA_CERT_FILE:-}}" +CA_ARGS=() +CA_LOCAL=".vault-ca.crt" +if [ -n "$CA_SRC" ] && [ -f "$CA_SRC" ]; then + cp "$CA_SRC" "$CA_LOCAL" + chmod 600 "$CA_LOCAL" + CA_ARGS=(-e "VAULT_CACERT=/app/$CA_LOCAL") + trap 'rm -f "$REPO_ROOT/$CA_LOCAL"' EXIT +else + echo "[!] No Vault CA certificate found (VAULT_CACERT / VAULT_CA_CERT_FILE)." >&2 + echo " The request would fall back to an unverified TLS connection, which" >&2 + echo " sends the signing token to whatever answers on $CONTAINER_ADDR." >&2 + echo " Set one of those variables, or start Vault so its CA is written." >&2 + exit 1 +fi + +exec docker compose exec \ + -e "VAULT_ADDR=$CONTAINER_ADDR" \ + -e VAULT_TOKEN \ + "${CA_ARGS[@]}" \ + builder "$@"