diff --git a/bin/disinto b/bin/disinto index f9bfe04..5f57927 100755 --- a/bin/disinto +++ b/bin/disinto @@ -783,9 +783,29 @@ _disinto_init_nomad() { fi if [ -n "$with_services" ]; then + # Vault seed plan (S2.6, #928): one line per service whose + # tools/vault-seed-.sh ships. Services without a seeder are + # silently skipped — the real-run loop below mirrors this, + # making `--with woodpecker` in Step 3 auto-invoke + # tools/vault-seed-woodpecker.sh once that file lands without + # any further change to bin/disinto. + local seed_hdr_printed=false + local IFS=',' + for svc in $with_services; do + svc=$(echo "$svc" | xargs) # trim whitespace + local seed_script="${FACTORY_ROOT}/tools/vault-seed-${svc}.sh" + if [ -x "$seed_script" ]; then + if [ "$seed_hdr_printed" = false ]; then + echo "── Vault seed dry-run ─────────────────────────────────" + seed_hdr_printed=true + fi + echo "[seed] [dry-run] ${seed_script} --dry-run" + fi + done + [ "$seed_hdr_printed" = true ] && echo "" + echo "── Deploy services dry-run ────────────────────────────" echo "[deploy] services to deploy: ${with_services}" - local IFS=',' for svc in $with_services; do svc=$(echo "$svc" | xargs) # trim whitespace # Validate known services first @@ -893,6 +913,50 @@ _disinto_init_nomad() { echo "[import] no --import-env/--import-sops — skipping; set them or seed kv/disinto/* manually before deploying secret-dependent services" fi + # Seed Vault for services that ship their own seeder (S2.6, #928). + # Convention: tools/vault-seed-.sh — auto-invoked when --with + # is requested. Runs AFTER vault-import so that real imported values + # win over generated seeds when both are present; each seeder is + # idempotent on a per-key basis (see vault-seed-forgejo.sh's + # "missing → generate, present → unchanged" contract), so re-running + # init does not rotate existing keys. Services without a seeder are + # silently skipped — keeps this loop forward-compatible with Step 3+ + # services that may ship their own seeder without touching bin/disinto. + # + # VAULT_ADDR is passed explicitly because cluster-up.sh writes the + # profile.d export *during* this same init run, so the current shell + # hasn't sourced it yet; sibling vault-* scripts (engines/policies/ + # auth/import) default VAULT_ADDR internally via _hvault_default_env, + # but vault-seed-forgejo.sh requires the caller to set it. + # + # The non-root branch invokes the seeder as `sudo -n -- env VAR=val + # script` rather than `sudo -n VAR=val -- script`: sudo treats bare + # `VAR=val` args as sudoers env-assignments, which the default + # `env_reset=on` policy silently discards unless the variable is in + # `env_keep` (VAULT_ADDR is not). Using `env` as the actual command + # sets VAULT_ADDR in the child process regardless of sudoers policy. + if [ -n "$with_services" ]; then + local vault_addr="${VAULT_ADDR:-http://127.0.0.1:8200}" + local IFS=',' + for svc in $with_services; do + svc=$(echo "$svc" | xargs) # trim whitespace + local seed_script="${FACTORY_ROOT}/tools/vault-seed-${svc}.sh" + if [ -x "$seed_script" ]; then + echo "" + echo "── Seeding Vault for ${svc} ───────────────────────────" + if [ "$(id -u)" -eq 0 ]; then + VAULT_ADDR="$vault_addr" "$seed_script" || exit $? + else + if ! command -v sudo >/dev/null 2>&1; then + echo "Error: vault-seed-${svc}.sh must run as root and sudo is not installed" >&2 + exit 1 + fi + sudo -n -- env "VAULT_ADDR=$vault_addr" "$seed_script" || exit $? + fi + fi + done + fi + # Deploy services if requested if [ -n "$with_services" ]; then echo "" diff --git a/tests/disinto-init-nomad.bats b/tests/disinto-init-nomad.bats index f38805e..21f4303 100644 --- a/tests/disinto-init-nomad.bats +++ b/tests/disinto-init-nomad.bats @@ -155,6 +155,44 @@ setup_file() { [[ "$output" == *"[deploy] dry-run complete"* ]] } +# S2.6 / #928 — every --with that ships tools/vault-seed-.sh +# must auto-invoke the seeder before deploy.sh runs. forgejo is the +# only service with a seeder today, so the dry-run plan must include +# its seed line when --with forgejo is set. The seed block must also +# appear BEFORE the deploy block (seeded secrets must exist before +# nomad reads the template stanza) — pinned here by scanning output +# order. Services without a seeder (e.g. unknown hypothetical future +# ones) are silently skipped by the loop convention. +@test "disinto init --backend=nomad --with forgejo --dry-run prints seed plan before deploy plan" { + run "$DISINTO_BIN" init placeholder/repo --backend=nomad --with forgejo --dry-run + [ "$status" -eq 0 ] + [[ "$output" == *"Vault seed dry-run"* ]] + [[ "$output" == *"tools/vault-seed-forgejo.sh --dry-run"* ]] + # Order: seed header must appear before deploy header. + local seed_line deploy_line + seed_line=$(echo "$output" | grep -n "Vault seed dry-run" | head -1 | cut -d: -f1) + deploy_line=$(echo "$output" | grep -n "Deploy services dry-run" | head -1 | cut -d: -f1) + [ -n "$seed_line" ] + [ -n "$deploy_line" ] + [ "$seed_line" -lt "$deploy_line" ] +} + +# Regression guard (PR #929 review): `sudo -n VAR=val -- cmd` is subject +# to sudoers env_reset policy and silently drops VAULT_ADDR unless it's +# in env_keep (it isn't in default configs). vault-seed-forgejo.sh +# requires VAULT_ADDR and dies at its own precondition check if unset, +# so the non-root branch MUST invoke `sudo -n -- env VAR=val cmd` so +# that `env` sets the variable in the child process regardless of +# sudoers policy. This grep-level guard catches a revert to the unsafe +# form that silently broke non-root seed runs on a fresh LXC. +@test "seed loop invokes sudo via 'env VAR=val' (bypasses sudoers env_reset)" { + run grep -F 'sudo -n -- env "VAULT_ADDR=' "$DISINTO_BIN" + [ "$status" -eq 0 ] + # Negative: no bare `sudo -n "VAR=val" --` form anywhere in the file. + run grep -F 'sudo -n "VAULT_ADDR=' "$DISINTO_BIN" + [ "$status" -ne 0 ] +} + @test "disinto init --backend=nomad --with forgejo,forgejo --dry-run handles comma-separated services" { run "$DISINTO_BIN" init placeholder/repo --backend=nomad --with forgejo,forgejo --dry-run [ "$status" -eq 0 ]