All checks were successful
Build + push backend image (Forgejo registry) / build-push (push) Successful in 1m23s
Build + push frontend image (Forgejo registry) / build-push (push) Successful in 1m22s
Sync infra to hosts / sync-beta (push) Successful in 9s
Sync infra to hosts / sync-prod (push) Successful in 8s
secrets-guard / encrypted (push) Successful in 6s
shell-lint / shellcheck (push) Successful in 9s
Deploy backend to beta VPS / deploy (push) Successful in 2m4s
Deploy frontend to beta VPS / deploy (push) Successful in 1m8s
Adds .forgejo/workflows/shell-lint.yml (pinned shellcheck v0.11.0 + sha256, -x, default severity, fail on any finding, not path-filtered) and drives all 26 scripts to zero findings. Two defects shellcheck cannot see: render-secrets.sh left DECRYPTED vault contents in /tmp whenever a sops decrypt failed -- the caller's set -e aborted the function before either cleanup ran. Now removed on every exit path, with `|| return 1` on both sops calls so a decrypt failure can never write a partial /etc/thermograph.env regardless of the caller's shell options. Explicitly not a `trap ... RETURN`: such a trap set in a sourced function persists into the caller's shell and re-fires when the caller's next `.`/source completes, where the function-local tmp is unset -- fatal and silent under deploy.sh's set -u. The file now records that reasoning. autoscale.sh ran `set -eu` without pipefail while piping docker stats into awk, so a failed left side was swallowed and the loop autoscaled on empty input. Promoted to pipefail with a missed sample treated as a skip; verified busybox ash in docker:27-cli supports it. Also: capture-fixtures.sh's `jq . || cat` ran cat after jq had consumed stdin, silently writing truncated fixtures; deploy.sh/deploy-stack.sh `# shellcheck source=` paths corrected for the monorepo layout.
96 lines
5.3 KiB
Bash
Executable file
96 lines
5.3 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, so every exit path below removes it.
|
|
#
|
|
# Deliberately NOT a `trap ... RETURN`, which looks like the tidier way to make
|
|
# that unconditional: a RETURN trap set inside a SOURCED function persists in
|
|
# the CALLER's shell after the function returns, and a RETURN trap also fires
|
|
# when a `.`/source completes. deploy.sh sources /etc/thermograph.env a few
|
|
# lines after calling us, which would re-fire the trap at top level where `tmp`
|
|
# (function-local) is unset -- fatal under deploy.sh's `set -u`, and silent,
|
|
# because that line already redirects stderr to /dev/null. Explicit removal on
|
|
# each path is duller and correct.
|
|
: > "$tmp" || { rm -f "$tmp"; return 1; }
|
|
# Decrypt failures return 1 explicitly, so a partial env is never written and
|
|
# correctness doesn't depend on the caller's shell options. 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" || { rm -f "$tmp"; return 1; }
|
|
fi
|
|
env "${key_env[@]}" sops -d --input-type yaml --output-type dotenv \
|
|
"$repo/deploy/secrets/${env_name}.yaml" >> "$tmp" || { rm -f "$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.
|
|
# rc + a single cleanup point: the plaintext tmp must go whichever branch runs,
|
|
# but the old trailing `rm` was also the function's last command, so it masked a
|
|
# failed in-place `cat` write to status 0. Capture the status instead, so a
|
|
# half-written /etc/thermograph.env fails loudly rather than deploying stale.
|
|
local rc=0
|
|
if [ -f /etc/thermograph.env ] && [ -w /etc/thermograph.env ]; then
|
|
cat "$tmp" > /etc/thermograph.env || rc=1
|
|
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
|
|
rc=1
|
|
fi
|
|
rm -f "$tmp"
|
|
return "$rc"
|
|
}
|