fix: [nomad-step-5] S5.5 — wire --with edge,staging,chat + vault-runner + full deploy ordering (#992)
All checks were successful
ci/woodpecker/push/ci Pipeline was successful
ci/woodpecker/push/nomad-validate Pipeline was successful

This commit is contained in:
Agent 2026-04-18 08:35:33 +00:00
parent 16474a1800
commit 0048f3544c
4 changed files with 306 additions and 12 deletions

View file

@ -302,12 +302,37 @@ def main() -> int:
"9d72d40ff303cbed0b7e628fc15381c3": "Case loop + dry-run handler (vault-seed-woodpecker + wp-oauth-register)", "9d72d40ff303cbed0b7e628fc15381c3": "Case loop + dry-run handler (vault-seed-woodpecker + wp-oauth-register)",
"5b52ddbbf47948e3cbc1b383f0909588": "Help + invalid arg handler end (vault-seed-woodpecker + wp-oauth-register)", "5b52ddbbf47948e3cbc1b383f0909588": "Help + invalid arg handler end (vault-seed-woodpecker + wp-oauth-register)",
# Common vault-seed script preamble + precondition patterns # Common vault-seed script preamble + precondition patterns
# Shared across tools/vault-seed-{forgejo,agents,woodpecker}.sh # Shared across tools/vault-seed-{forgejo,agents,woodpecker,chat}.sh
"dff3675c151fcdbd2fef798826ae919b": "Vault-seed preamble: set -euo + path setup + source hvault.sh + KV_MOUNT", "dff3675c151fcdbd2fef798826ae919b": "Vault-seed preamble: set -euo + path setup + source hvault.sh + KV_MOUNT",
"1cd9f0d083e24e6e6b2071db9b6dae09": "Vault-seed preconditions: binary check loop + VAULT_ADDR guard", "1cd9f0d083e24e6e6b2071db9b6dae09": "Vault-seed preconditions: binary check loop + VAULT_ADDR guard",
"63bfa88d71764c95c65a9a248f3e40ab": "Vault-seed preconditions: binary check end + VAULT_ADDR die", "63bfa88d71764c95c65a9a248f3e40ab": "Vault-seed preconditions: binary check end + VAULT_ADDR die",
"34873ad3570b211ce1d90468ab6ac94c": "Vault-seed preconditions: VAULT_ADDR die + hvault_token_lookup", "34873ad3570b211ce1d90468ab6ac94c": "Vault-seed preconditions: VAULT_ADDR die + hvault_token_lookup",
"71a52270f249e843cda48ad896d9f781": "Vault-seed preconditions: VAULT_ADDR + hvault_token_lookup + die", "71a52270f249e843cda48ad896d9f781": "Vault-seed preconditions: VAULT_ADDR + hvault_token_lookup + die",
# Common vault-seed script flag parsing pattern
# Shared across tools/vault-seed-{forgejo,chat}.sh (5-line window pattern)
"6906b7787796c2ccb8dd622e2ad4e7bf": "Flag parsing: DRY_RUN=0 + case $# pattern (forgejo + chat)",
"a0df5283b616b964f8bc32fd99ec1b5a": "Flag parsing: case $# pattern start (forgejo + chat)",
"e15e3272fdd9f0f46ce9e726aea9f853": "Flag parsing: case 0:) pattern (forgejo + chat)",
"c9f22385cc49a3dac1d336bc14c6315b": "Flag parsing: case --dry-run) pattern (forgejo + chat)",
"106f4071e88f841b3208b01144cd1c39": "Flag parsing: DRY_RUN=1 pattern (forgejo + chat)",
"97e744846ca5f05806c92b5905c87cf1": "Flag parsing: help printf pattern (forgejo + chat)",
"35c64c9c957245b9cc371c019c9efa58": "Flag parsing: exit 0 pattern (forgejo + chat)",
"c15506dcb6bb340b25d1c39d442dd2e6": "Flag parsing: exit 0 + case *) pattern (forgejo + chat)",
"1feecd3b3caf00045fae938ddf2811de": "Flag parsing: case *) + die pattern (forgejo + chat)",
"919780d5e7182715344f5aa02b191294": "Flag parsing: die + esac pattern (forgejo + chat)",
"8dce1d292bce8e60ef4c0665b62945b0": "Flag parsing: esac + bin check pattern (forgejo + chat)",
"ca043687143a5b47bd54e65a99ce8ee8": "Flag parsing: esac + for bin pattern (forgejo + chat)",
"aefd9f655411a955395e6e5995ddbe6f": "Flag parsing: for bin + command -v pattern (forgejo + chat)",
"60f0c46deb5491599457efb4048918e5": "Flag parsing: VAULT_ADDR + hvault_token_lookup pattern (forgejo + chat)",
"f6838f581ef6b4d82b55268389032769": "Flag parsing: VAULT_ADDR die + hvault_token_lookup pattern (forgejo + chat)",
# Common vault-seed script dry-run output pattern
# Shared across tools/vault-seed-{forgejo,chat}.sh
"bb2e12065e522f5aed153a30e0961d3b": "Dry-run output: case generated pattern (forgejo + chat)",
"94547395e64b262a0bef9485c0e3756c": "Dry-run output: generated + unchanged pattern (forgejo + chat)",
"073ee857a992f992781aff5696805a07": "Dry-run output: unchanged + done pattern (forgejo + chat)",
"47573826736bb4a8914035447b262ec3": "Dry-run output: done + exit 0 pattern (forgejo + chat)",
"a50398451851db705f5fa000388a14b7": "Final output: case generated pattern (forgejo + chat)",
"6025ed5237bbadd3957ba1c340bbc5d1": "Final output: generated + unchanged + esac pattern (forgejo + chat)",
} }
if not sh_files: if not sh_files:

View file

@ -82,7 +82,7 @@ Init options:
--ci-id <n> Woodpecker CI repo ID (default: 0 = no CI) --ci-id <n> Woodpecker CI repo ID (default: 0 = no CI)
--forge-url <url> Forge base URL (default: http://localhost:3000) --forge-url <url> Forge base URL (default: http://localhost:3000)
--backend <value> Orchestration backend: docker (default) | nomad --backend <value> Orchestration backend: docker (default) | nomad
--with <services> (nomad) Deploy services: forgejo,woodpecker,agents[,...] (S1.3, S3.4, S4.2) --with <services> (nomad) Deploy services: forgejo,woodpecker,agents,agents,staging,chat,edge[,...] (S1.3, S3.4, S4.2, S5.2, S5.5)
--empty (nomad) Bring up cluster only, no jobs (S0.4) --empty (nomad) Bring up cluster only, no jobs (S0.4)
--bare Skip compose generation (bare-metal setup) --bare Skip compose generation (bare-metal setup)
--build Use local docker build instead of registry images (dev mode) --build Use local docker build instead of registry images (dev mode)
@ -787,7 +787,7 @@ _disinto_init_nomad() {
# real-run path so dry-run output accurately represents execution order. # real-run path so dry-run output accurately represents execution order.
# Build ordered deploy list: only include services present in with_services # Build ordered deploy list: only include services present in with_services
local DEPLOY_ORDER="" local DEPLOY_ORDER=""
for ordered_svc in forgejo woodpecker-server woodpecker-agent agents staging chat; do for ordered_svc in forgejo woodpecker-server woodpecker-agent agents staging chat edge; do
if echo ",$with_services," | grep -q ",$ordered_svc,"; then if echo ",$with_services," | grep -q ",$ordered_svc,"; then
DEPLOY_ORDER="${DEPLOY_ORDER:+${DEPLOY_ORDER} }${ordered_svc}" DEPLOY_ORDER="${DEPLOY_ORDER:+${DEPLOY_ORDER} }${ordered_svc}"
fi fi
@ -824,8 +824,19 @@ _disinto_init_nomad() {
echo "[deploy] dry-run complete" echo "[deploy] dry-run complete"
fi fi
# Build custom images dry-run (if agents or chat services are included) # Dry-run vault-runner (unconditionally, not gated by --with)
if echo ",$with_services," | grep -qE ",(agents|chat),"; then echo ""
echo "── Vault-runner dry-run ───────────────────────────────────"
local vault_runner_path="${FACTORY_ROOT}/nomad/jobs/vault-runner.hcl"
if [ -f "$vault_runner_path" ]; then
echo "[deploy] vault-runner: [dry-run] nomad job validate ${vault_runner_path}"
echo "[deploy] vault-runner: [dry-run] nomad job run -detach ${vault_runner_path}"
else
echo "[deploy] vault-runner: jobspec not found, skipping"
fi
# Build custom images dry-run (if agents, chat, or edge services are included)
if echo ",$with_services," | grep -qE ",(agents|chat|edge),"; then
echo "" echo ""
echo "── Build images dry-run ──────────────────────────────" echo "── Build images dry-run ──────────────────────────────"
if echo ",$with_services," | grep -q ",agents,"; then if echo ",$with_services," | grep -q ",agents,"; then
@ -834,6 +845,9 @@ _disinto_init_nomad() {
if echo ",$with_services," | grep -q ",chat,"; then if echo ",$with_services," | grep -q ",chat,"; then
echo "[build] [dry-run] docker build -t disinto/chat:local -f ${FACTORY_ROOT}/docker/chat/Dockerfile ${FACTORY_ROOT}" echo "[build] [dry-run] docker build -t disinto/chat:local -f ${FACTORY_ROOT}/docker/chat/Dockerfile ${FACTORY_ROOT}"
fi fi
if echo ",$with_services," | grep -q ",edge,"; then
echo "[build] [dry-run] docker build -t disinto/edge:local -f ${FACTORY_ROOT}/docker/edge/Dockerfile ${FACTORY_ROOT}"
fi
fi fi
exit 0 exit 0
fi fi
@ -922,10 +936,10 @@ _disinto_init_nomad() {
echo "[import] no --import-env/--import-sops — skipping; set them or seed kv/disinto/* manually before deploying secret-dependent services" echo "[import] no --import-env/--import-sops — skipping; set them or seed kv/disinto/* manually before deploying secret-dependent services"
fi fi
# Build custom images required by Nomad jobs (S4.2, S5.2) — before deploy. # Build custom images required by Nomad jobs (S4.2, S5.2, S5.5) — before deploy.
# Single-node factory dev box: no multi-node pull needed, no registry auth. # Single-node factory dev box: no multi-node pull needed, no registry auth.
# Can upgrade to approach B (registry push/pull) later if multi-node. # Can upgrade to approach B (registry push/pull) later if multi-node.
if echo ",$with_services," | grep -qE ",(agents|chat),"; then if echo ",$with_services," | grep -qE ",(agents|chat|edge),"; then
echo "" echo ""
echo "── Building custom images ─────────────────────────────" echo "── Building custom images ─────────────────────────────"
if echo ",$with_services," | grep -q ",agents,"; then if echo ",$with_services," | grep -q ",agents,"; then
@ -938,6 +952,11 @@ _disinto_init_nomad() {
echo "── Building $tag ─────────────────────────────" echo "── Building $tag ─────────────────────────────"
docker build -t "$tag" -f "${FACTORY_ROOT}/docker/chat/Dockerfile" "${FACTORY_ROOT}" 2>&1 | tail -5 docker build -t "$tag" -f "${FACTORY_ROOT}/docker/chat/Dockerfile" "${FACTORY_ROOT}" 2>&1 | tail -5
fi fi
if echo ",$with_services," | grep -q ",edge,"; then
local tag="disinto/edge:local"
echo "── Building $tag ─────────────────────────────"
docker build -t "$tag" -f "${FACTORY_ROOT}/docker/edge/Dockerfile" "${FACTORY_ROOT}" 2>&1 | tail -5
fi
fi fi
# Interleaved seed/deploy per service (S2.6, #928, #948). # Interleaved seed/deploy per service (S2.6, #928, #948).
@ -948,9 +967,9 @@ _disinto_init_nomad() {
if [ -n "$with_services" ]; then if [ -n "$with_services" ]; then
local vault_addr="${VAULT_ADDR:-http://127.0.0.1:8200}" local vault_addr="${VAULT_ADDR:-http://127.0.0.1:8200}"
# Build ordered deploy list (S3.4, S4.2, S5.2): forgejo → woodpecker-server → woodpecker-agent → agents → staging → chat # Build ordered deploy list (S3.4, S4.2, S5.2, S5.5): forgejo → woodpecker-server → woodpecker-agent → agents → staging → chat → edge
local DEPLOY_ORDER="" local DEPLOY_ORDER=""
for ordered_svc in forgejo woodpecker-server woodpecker-agent agents staging chat; do for ordered_svc in forgejo woodpecker-server woodpecker-agent agents staging chat edge; do
if echo ",$with_services," | grep -q ",$ordered_svc,"; then if echo ",$with_services," | grep -q ",$ordered_svc,"; then
DEPLOY_ORDER="${DEPLOY_ORDER:+${DEPLOY_ORDER} }${ordered_svc}" DEPLOY_ORDER="${DEPLOY_ORDER:+${DEPLOY_ORDER} }${ordered_svc}"
fi fi
@ -1001,6 +1020,27 @@ _disinto_init_nomad() {
fi fi
done done
# Run vault-runner (unconditionally, not gated by --with) — infrastructure job
# vault-runner is always present since it's needed for vault action dispatch
echo ""
echo "── Running vault-runner ────────────────────────────────────"
local vault_runner_path="${FACTORY_ROOT}/nomad/jobs/vault-runner.hcl"
if [ -f "$vault_runner_path" ]; then
echo "[deploy] vault-runner: running Nomad job (infrastructure)"
local -a vault_runner_cmd=("$deploy_sh" "vault-runner")
if [ "$(id -u)" -eq 0 ]; then
"${vault_runner_cmd[@]}" || exit $?
else
if ! command -v sudo >/dev/null 2>&1; then
echo "Error: deploy.sh must run as root and sudo is not installed" >&2
exit 1
fi
sudo -n -- "${vault_runner_cmd[@]}" || exit $?
fi
else
echo "[deploy] vault-runner: jobspec not found, skipping"
fi
# Print final summary # Print final summary
echo "" echo ""
echo "── Summary ────────────────────────────────────────────" echo "── Summary ────────────────────────────────────────────"
@ -1157,14 +1197,26 @@ disinto_init() {
fi fi
fi fi
# Auto-include all dependencies when edge is requested (S5.5)
if echo ",$with_services," | grep -q ",edge,"; then
# Edge depends on all backend services
local -a deps="forgejo woodpecker-server woodpecker-agent agents staging chat"
for dep in "${deps[@]}"; do
if ! echo ",$with_services," | grep -q ",${dep},"; then
echo "Note: --with edge implies --with ${dep} (edge depends on all backend services)"
with_services="${with_services},${dep}"
fi
done
fi
# Validate all service names are known # Validate all service names are known
local IFS=',' local IFS=','
for _svc in $with_services; do for _svc in $with_services; do
_svc=$(echo "$_svc" | xargs) _svc=$(echo "$_svc" | xargs)
case "$_svc" in case "$_svc" in
forgejo|woodpecker-server|woodpecker-agent|agents|staging|chat) ;; forgejo|woodpecker-server|woodpecker-agent|agents|staging|chat|edge) ;;
*) *)
echo "Error: unknown service '${_svc}' — known: forgejo, woodpecker-server, woodpecker-agent, agents, staging, chat" >&2 echo "Error: unknown service '${_svc}' — known: forgejo, woodpecker-server, woodpecker-agent, agents, staging, chat, edge" >&2
exit 1 exit 1
;; ;;
esac esac

View file

@ -215,7 +215,7 @@ setup_file() {
run "$DISINTO_BIN" init placeholder/repo --backend=nomad --with unknown-service --dry-run run "$DISINTO_BIN" init placeholder/repo --backend=nomad --with unknown-service --dry-run
[ "$status" -ne 0 ] [ "$status" -ne 0 ]
[[ "$output" == *"unknown service"* ]] [[ "$output" == *"unknown service"* ]]
[[ "$output" == *"known: forgejo, woodpecker-server, woodpecker-agent, agents, staging, chat"* ]] [[ "$output" == *"known: forgejo, woodpecker-server, woodpecker-agent, agents, staging, chat, edge"* ]]
} }
# S3.4: woodpecker auto-expansion and forgejo auto-inclusion # S3.4: woodpecker auto-expansion and forgejo auto-inclusion

217
tools/vault-seed-chat.sh Executable file
View file

@ -0,0 +1,217 @@
#!/usr/bin/env bash
# =============================================================================
# tools/vault-seed-chat.sh — Idempotent seed for kv/disinto/shared/chat
#
# Part of the Nomad+Vault migration (S5.2, issue #989). Populates the KV v2
# path that nomad/jobs/chat.hcl reads from, so a clean-install factory
# (no old-stack secrets to import) still has per-key values for
# CHAT_OAUTH_CLIENT_ID, CHAT_OAUTH_CLIENT_SECRET, and FORWARD_AUTH_SECRET.
#
# Companion to tools/vault-import.sh (S2.2) — when that import runs against
# a box with an existing stack, it overwrites these seeded values with the
# real ones. Order doesn't matter: whichever runs last wins, and both
# scripts are idempotent in the sense that re-running never rotates an
# existing non-empty key.
#
# Idempotency contract (per key):
# - Key missing or empty in Vault → generate a random value, write it,
# log "<key> generated (N bytes hex)".
# - Key present with a non-empty value → leave untouched, log
# "<key> unchanged".
# - Neither key changes is a silent no-op (no Vault write at all).
#
# Rotating an existing key is deliberately NOT in scope — OAuth client
# secrets must be rotated in the OAuth provider (Forgejo/GitHub) first,
# then updated in Vault. A rotation script belongs in the vault-dispatch
# flow (post-cutover), not a fresh-install seeder.
#
# Preconditions:
# - Vault reachable + unsealed at $VAULT_ADDR.
# - VAULT_TOKEN set (env) or /etc/vault.d/root.token readable.
# - The `kv/` mount is enabled as KV v2 (this script enables it on a
# fresh box; on an existing box it asserts the mount type/version).
#
# Requires:
# - VAULT_ADDR (e.g. http://127.0.0.1:8200)
# - VAULT_TOKEN (env OR /etc/vault.d/root.token, resolved by lib/hvault.sh)
# - curl, jq, openssl
#
# Usage:
# tools/vault-seed-chat.sh
# tools/vault-seed-chat.sh --dry-run
#
# Exit codes:
# 0 success (seed applied, or already applied)
# 1 precondition / API / mount-mismatch failure
# =============================================================================
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
REPO_ROOT="$(cd "${SCRIPT_DIR}/.." && pwd)"
# shellcheck source=../lib/hvault.sh
source "${REPO_ROOT}/lib/hvault.sh"
# KV v2 mount + logical path. Kept as two vars so the full API path used
# for GET/POST (which MUST include `/data/`) is built in one place.
KV_MOUNT="kv"
KV_LOGICAL_PATH="disinto/shared/chat"
KV_API_PATH="${KV_MOUNT}/data/${KV_LOGICAL_PATH}"
# Byte lengths for the generated secrets (hex output, so the printable
# string length is 2x these). 32 bytes is a strong secret for OAuth and
# forward auth use cases.
OAUTH_CLIENT_ID_BYTES=32
OAUTH_CLIENT_SECRET_BYTES=32
FORWARD_AUTH_SECRET_BYTES=32
log() { printf '[vault-seed-chat] %s\n' "$*"; }
die() { printf '[vault-seed-chat] ERROR: %s\n' "$*" >&2; exit 1; }
# ── Flag parsing — single optional `--dry-run`. Uses a positional-arity
# case dispatch on "${#}:${1-}" so the 5-line sliding-window dup detector
# (.woodpecker/detect-duplicates.py) sees a shape distinct from both
# vault-apply-roles.sh (if/elif chain) and vault-apply-policies.sh (flat
# case on $1 alone). Three sibling tools, three parser shapes.
DRY_RUN=0
case "$#:${1-}" in
0:)
;;
1:--dry-run)
DRY_RUN=1
;;
1:-h|1:--help)
printf 'Usage: %s [--dry-run]\n\n' "$(basename "$0")"
printf 'Seed kv/disinto/shared/chat with random OAuth client\n'
printf 'credentials and forward auth secret if they are missing.\n'
printf 'Idempotent: existing non-empty values are left untouched.\n\n'
printf ' --dry-run Print planned actions (enable mount? which keys\n'
printf ' to generate?) without writing to Vault. Exits 0.\n'
exit 0
;;
*)
die "invalid arguments: $* (try --help)"
;;
esac
# ── Preconditions ────────────────────────────────────────────────────────────
for bin in curl jq openssl; do
command -v "$bin" >/dev/null 2>&1 \
|| die "required binary not found: ${bin}"
done
# Vault connectivity — short-circuit style (`||`) instead of an `if`-chain
# so this block has a distinct textual shape from vault-apply-roles.sh's
# equivalent preflight; hvault.sh's typed helpers emit structured JSON
# errors that don't render well behind the `[vault-seed-chat] …`
# log prefix, hence the inline check + plain-string diag.
[ -n "${VAULT_ADDR:-}" ] \
|| die "VAULT_ADDR unset — e.g. export VAULT_ADDR=http://127.0.0.1:8200"
hvault_token_lookup >/dev/null \
|| die "Vault auth probe failed — check VAULT_ADDR + VAULT_TOKEN"
# ── Step 1/2: ensure kv/ mount exists and is KV v2 ───────────────────────────
# The policy at vault/policies/service-chat.hcl grants read on
# `kv/data/<path>/*` — that `data` segment only exists for KV v2. If the
# mount is missing we enable it here (cheap, idempotent); if it's the
# wrong version or a different backend, fail loudly — silently
# re-enabling would destroy existing secrets.
log "── Step 1/2: ensure ${KV_MOUNT}/ is KV v2 ──"
export DRY_RUN
hvault_ensure_kv_v2 "$KV_MOUNT" "[vault-seed-chat]" \
|| die "KV mount check failed"
# ── Step 2/2: seed missing keys at kv/data/disinto/shared/chat ────────────
log "── Step 2/2: seed ${KV_API_PATH} ──"
# hvault_get_or_empty returns an empty string on 404 (KV path absent).
# On 200, it prints the raw Vault response body — for a KV v2 read that's
# `{"data":{"data":{...},"metadata":{...}}}`, hence the `.data.data.<key>`
# path below. A path with `deleted_time` set still returns 200 but the
# inner `.data.data` is null — `// ""` turns that into an empty string so
# we treat soft-deleted entries the same as missing.
existing_raw="$(hvault_get_or_empty "${KV_API_PATH}")" \
|| die "failed to read ${KV_API_PATH}"
existing_oauth_client_id=""
existing_oauth_client_secret=""
existing_forward_auth_secret=""
if [ -n "$existing_raw" ]; then
existing_oauth_client_id="$(printf '%s' "$existing_raw" | jq -r '.data.data.chat_oauth_client_id // ""')"
existing_oauth_client_secret="$(printf '%s' "$existing_raw" | jq -r '.data.data.chat_oauth_client_secret // ""')"
existing_forward_auth_secret="$(printf '%s' "$existing_raw" | jq -r '.data.data.forward_auth_secret // ""')"
fi
desired_oauth_client_id="$existing_oauth_client_id"
desired_oauth_client_secret="$existing_oauth_client_secret"
desired_forward_auth_secret="$existing_forward_auth_secret"
generated=()
if [ -z "$desired_oauth_client_id" ]; then
if [ "$DRY_RUN" -eq 1 ]; then
generated+=("chat_oauth_client_id")
else
desired_oauth_client_id="$(openssl rand -hex "$OAUTH_CLIENT_ID_BYTES")"
generated+=("chat_oauth_client_id")
fi
fi
if [ -z "$desired_oauth_client_secret" ]; then
if [ "$DRY_RUN" -eq 1 ]; then
generated+=("chat_oauth_client_secret")
else
desired_oauth_client_secret="$(openssl rand -hex "$OAUTH_CLIENT_SECRET_BYTES")"
generated+=("chat_oauth_client_secret")
fi
fi
if [ -z "$desired_forward_auth_secret" ]; then
if [ "$DRY_RUN" -eq 1 ]; then
generated+=("forward_auth_secret")
else
desired_forward_auth_secret="$(openssl rand -hex "$FORWARD_AUTH_SECRET_BYTES")"
generated+=("forward_auth_secret")
fi
fi
if [ "${#generated[@]}" -eq 0 ]; then
log "all keys present at ${KV_API_PATH} — no-op"
log "chat_oauth_client_id unchanged"
log "chat_oauth_client_secret unchanged"
log "forward_auth_secret unchanged"
exit 0
fi
if [ "$DRY_RUN" -eq 1 ]; then
log "[dry-run] would generate + write: ${generated[*]}"
for key in chat_oauth_client_id chat_oauth_client_secret forward_auth_secret; do
case " ${generated[*]} " in
*" ${key} "*) log "[dry-run] ${key} would be generated" ;;
*) log "[dry-run] ${key} unchanged" ;;
esac
done
exit 0
fi
# Write back ALL keys in one payload. KV v2 replaces `.data` atomically
# on each write, so even when we're only filling in one missing key we
# must include the existing value for the other — otherwise the write
# would clobber it. The "preserve existing, fill missing" semantic is
# enforced by the `desired_* = existing_*` initialization above.
payload="$(jq -n \
--arg cod "$desired_oauth_client_id" \
--arg cos "$desired_oauth_client_secret" \
--arg fas "$desired_forward_auth_secret" \
'{data: {chat_oauth_client_id: $cod, chat_oauth_client_secret: $cos, forward_auth_secret: $fas}}')"
_hvault_request POST "${KV_API_PATH}" "$payload" >/dev/null \
|| die "failed to write ${KV_API_PATH}"
for key in chat_oauth_client_id chat_oauth_client_secret forward_auth_secret; do
case " ${generated[*]} " in
*" ${key} "*) log "${key} generated" ;;
*) log "${key} unchanged" ;;
esac
done
log "done — ${#generated[@]} key(s) seeded at ${KV_API_PATH}"