thermograph/infra/deploy/render-secrets.sh
Emi Griffith 40a3ce21d9
All checks were successful
secrets-guard / encrypted (pull_request) Successful in 9s
PR build (required check) / changes (pull_request) Successful in 17s
shell-lint / shellcheck (pull_request) Successful in 13s
PR build (required check) / validate-observability (pull_request) Has been skipped
PR build (required check) / build-frontend (pull_request) Successful in 1m27s
PR build (required check) / build-backend (pull_request) Successful in 1m36s
PR build (required check) / gate (pull_request) Successful in 3s
shell: add shellcheck CI guard and drive the tree to zero findings
No static analysis has ever run over the ~2k lines of shell that deploy,
provision secrets, and bootstrap hosts as root over SSH. Add shell-lint.yml
(pinned shellcheck v0.11.0 + sha256, -x, default severity, fail on any
finding) and fix everything it reports, plus two defects it structurally
cannot see.

Not path-filtered, matching secrets-guard's call: a backstop that only runs
when you expect it to isn't a backstop. Scripts are discovered with find, so
new ones are covered on landing. The version is pinned to a static release
rather than apt's, so a drifted shellcheck can't fail CI on an unrelated push.

render-secrets.sh: the mktemp holding DECRYPTED vault contents was only
removed on the success path and one failure branch, so a sops decrypt failure
left plaintext POSTGRES_PASSWORD in /tmp indefinitely on a live host. A RETURN
trap makes removal unconditional, and the two sops calls now `|| return 1`
explicitly instead of relying on the caller's set -e (a bare set -e abort
skips the trap). The function stays free of `set -e` itself -- it is sourced,
and shell options would leak into the caller.

autoscale.sh: ran `set -eu` without pipefail while piping docker stats into
awk, so a failed left side was swallowed and the loop scaled on empty input.
Promoted to pipefail with avg_cpu's failure treated as a missed sample, so a
daemon hiccup can't kill the autoscaler. Verified busybox ash in docker:27-cli
supports pipefail and the script still parses there.

capture-fixtures.sh: `jq . || cat` ran cat after jq had already consumed
stdin, silently writing a truncated fixture; now a real if/else that fails
loudly. deploy.sh/deploy-stack.sh: `# shellcheck source=` paths corrected for
the monorepo layout, and /etc/thermograph.env marked unfollowable (it is
rendered at deploy time and cannot exist at lint time).
2026-07-23 14:59:47 -07:00

84 lines
4.5 KiB
Bash
Executable file

#!/usr/bin/env bash
# Render /etc/thermograph.env from the SOPS-encrypted source of truth
# (deploy/secrets/<env>.yaml, committed encrypted) — sourced by deploy.sh and
# deploy-dev.sh, not run directly.
#
# The encrypted files are the single source of truth; /etc/thermograph.env becomes a
# rendered artifact that the existing seams (docker-compose `env_file:`, the systemd
# unit's EnvironmentFile, and entrypoint.sh's /run/secrets shim) keep consuming
# unchanged. Common secrets + the per-host overrides are concatenated with the host
# file LAST, so a host value wins (env_file and `source` both take the last
# occurrence of a duplicate key).
#
# Presence-detected: a host is "configured for SOPS" only when it has an age key at
# /etc/thermograph/age.key AND an environment name in /etc/thermograph/secrets-env
# (prod|beta|dev). A host without them keeps whatever /etc/thermograph.env it already
# has — so merging this is a safe no-op until a host is deliberately migrated. See
# deploy/secrets/README.md.
render_thermograph_secrets() {
local repo="${1:-.}" # repo root holding deploy/secrets
local key="${THERMOGRAPH_AGE_KEY:-/etc/thermograph/age.key}"
local marker="${THERMOGRAPH_SECRETS_ENV_FILE:-/etc/thermograph/secrets-env}"
local env_name
env_name=$(cat "$marker" 2>/dev/null || true)
# The per-host file is required; common.yaml is optional so the initial cutover can
# seed each host as an exact copy of its live env (byte-identical render, no value
# changes) and factor out shared secrets into common.yaml later.
if [ -z "$env_name" ] || [ ! -f "$key" ] \
|| [ ! -f "$repo/deploy/secrets/${env_name}.yaml" ]; then
echo "==> SOPS secrets not configured here; using existing /etc/thermograph.env"
return 0
fi
if ! command -v sops >/dev/null 2>&1; then
echo "!! sops not installed but this host is configured for SOPS secrets" >&2
return 1
fi
echo "==> Rendering /etc/thermograph.env from deploy/secrets (common + ${env_name})"
# The age private key is root-owned (0400). Read it directly if we can, else via
# sudo into SOPS_AGE_KEY — so the key never has to be readable by the deploy user.
# (The render needs sudo to write /etc/thermograph.env below anyway.)
local key_env=()
if [ -r "$key" ]; then
key_env=("SOPS_AGE_KEY_FILE=$key")
else
local keymat; keymat=$(sudo cat "$key" 2>/dev/null | grep '^AGE-SECRET-KEY-' || true)
[ -n "$keymat" ] || { echo "!! cannot read age key at $key (need sudo)" >&2; return 1; }
key_env=("SOPS_AGE_KEY=$keymat")
fi
local tmp; tmp=$(mktemp)
# The tmp file holds DECRYPTED secrets: a RETURN trap makes its removal
# unconditional (fires on every explicit return, success or failure, and is
# scoped to this function — it doesn't leak into or clobber the caller's traps).
trap 'rm -f "$tmp"' RETURN
: > "$tmp"
# Decrypt failures return 1 explicitly (no partial env, no reliance on the
# caller's shell options — a bare set -e abort would skip the RETURN trap and
# strand plaintext in /tmp). common first, host second, so a host value
# overrides a shared one (last-wins).
if [ -f "$repo/deploy/secrets/common.yaml" ]; then
env "${key_env[@]}" sops -d --input-type yaml --output-type dotenv \
"$repo/deploy/secrets/common.yaml" >> "$tmp" || return 1
fi
env "${key_env[@]}" sops -d --input-type yaml --output-type dotenv \
"$repo/deploy/secrets/${env_name}.yaml" >> "$tmp" || return 1
# Write /etc/thermograph.env. Prefer an in-place write when the existing file is
# writable by us (e.g. a group-writable 0660 root:<deploygroup> on a box whose CI
# deploy user isn't root and has no broad sudo — beta's `deploy`), since that needs
# only file write, not /etc dir write or sudo. Else install; else sudo install (a
# root/agent deploy) -- and on that sudo path CHOWN the result to the invoking
# deploy user (-o/-g $(id -un/-gn)), or the file lands root:root 0640 and the very
# next line of deploy.sh (`. /etc/thermograph.env` as that non-root user) can't read
# it, so POSTGRES_PASSWORD never enters the env and `docker compose` dies on
# interpolation. Fail loudly rather than deploy against stale secrets.
if [ -f /etc/thermograph.env ] && [ -w /etc/thermograph.env ]; then
cat "$tmp" > /etc/thermograph.env
elif install -m 0640 "$tmp" /etc/thermograph.env 2>/dev/null; then :
elif sudo install -m 0640 -o "$(id -un)" -g "$(id -gn)" "$tmp" /etc/thermograph.env 2>/dev/null; then :
else
echo "!! cannot write /etc/thermograph.env (need file write access or passwordless sudo)" >&2
return 1
fi
}