Files
moments/script/deploy.sh
rob thijssen bb2f5b1f9b
Some checks failed
deploy / Build prerendered web (push) Successful in 7m14s
deploy / Deploy web to oolon (push) Successful in 18s
deploy / Build api + worker (static musl) (push) Successful in 5m41s
deploy / Deploy moments-worker to frootmig (push) Successful in 19s
deploy / Deploy moments-api to nikola (push) Successful in 24s
refresh / Rebuild prerendered web (push) Successful in 7m15s
refresh / Deploy refreshed web to oolon (push) Failing after 26s
fix(web): stop binding 443 behind the edge SNI router; verify the reload
oolon's TCP 443 belongs to the stream SNI router, which ssl_prereads the
handshake and forwards to the local https tier on 127.0.0.1:14443 with
PROXY protocol. site.conf.tmpl predates that and still bound 443 itself,
so every deploy and every daily refresh rsynced a vhost that collides
with the router.

Nothing in the pipeline caught it. `nginx -t` only detects duplicate
listeners within a context, not across http{} and stream{}, and
`systemctl reload` merely sends SIGHUP, so it exits 0 while nginx logs
"bind() to 0.0.0.0:443 failed (98: Address already in use) ... still
could not bind()", aborts the reconfiguration and keeps its old cycle.
The deploy went green while oolon's running config was frozen. It stayed
frozen for a day, stranding every cert the step@ timers renewed on disk
until eleven internal vhosts were serving expired certs. A cold start
would have failed outright, taking the whole public edge down.

Template the listen line from WEB_LISTEN (manifest web.config.listen for
script/deploy.sh, which renders the same template), and assert that the
reload landed by requiring a fresh worker generation, dumping the nginx
error log when it did not.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0182wzZE8DguMPWhxD21gfP2
2026-07-21 11:42:39 +03:00

560 lines
21 KiB
Bash
Executable File

#!/usr/bin/env bash
#
# moments deployment script.
#
# ./script/deploy.sh <environment> [component...]
# ./script/deploy.sh prod api worker web
# ./script/deploy.sh prod all
#
# Builds artifacts locally, resolves secrets from `pass`, renders config
# templates, rsyncs everything to the target hosts, and reloads systemd /
# nginx / firewalld / SELinux state idempotently.
set -euo pipefail
shopt -s nullglob
repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
manifest="${repo_root}/asset/manifest.yml"
dry_run=0
usage() {
cat <<EOF >&2
usage: $(basename "$0") <environment> [component...] [--dry-run]
$(basename "$0") prod api worker web
$(basename "$0") prod all
$(basename "$0") prod default # api + web (worker isn't restarted unless asked)
EOF
exit 2
}
log() { printf '\033[1;34m[deploy]\033[0m %s\n' "$*" >&2; }
warn() { printf '\033[1;33m[deploy]\033[0m %s\n' "$*" >&2; }
die() { printf '\033[1;31m[deploy]\033[0m %s\n' "$*" >&2; exit 1; }
run() {
if (( dry_run )); then
printf '\033[2m[dry-run]\033[0m %s\n' "$*" >&2
else
"$@"
fi
}
ssh_run() {
local host="$1"; shift
if (( dry_run )); then
printf '\033[2m[dry-run]\033[0m ssh %s -- %s\n' "$host" "$*" >&2
else
ssh -o BatchMode=yes "$host" "$@"
fi
}
# Ensure /tmp on the remote is world-writable + sticky (mode 1777). Some
# hosts in this fleet have had /tmp reset to root-owned 0755 by an
# unrelated configuration step, which silently breaks the rsync of the
# deploy stage dir under our unprivileged user. Check the mode first so a
# correctly-configured host doesn't incur a needless sudo call.
ensure_tmp_writable() {
local host="$1"
if (( dry_run )); then
printf '\033[2m[dry-run]\033[0m ssh %s -- stat /tmp; chmod 1777 if needed\n' "$host" >&2
return 0
fi
local mode
mode="$(ssh -o BatchMode=yes "$host" 'stat -c %a /tmp')" || {
warn "could not stat /tmp on $host"
return 1
}
if [[ "$mode" != "1777" ]]; then
warn "/tmp on $host is mode $mode; fixing to 1777"
ssh -o BatchMode=yes "$host" 'sudo chmod 1777 /tmp' || {
warn "failed to chmod /tmp on $host"
return 1
}
fi
}
[[ $# -ge 1 ]] || usage
environment="$1"; shift
components=()
while [[ $# -gt 0 ]]; do
case "$1" in
--dry-run) dry_run=1 ;;
*) components+=("$1") ;;
esac
shift
done
[[ -f "$manifest" ]] || die "manifest not found: $manifest"
command -v yq >/dev/null 2>&1 || die "yq is required"
command -v pass >/dev/null 2>&1 || die "pass is required"
command -v rsync >/dev/null 2>&1 || die "rsync is required"
command -v cargo >/dev/null 2>&1 || die "cargo is required"
command -v podman >/dev/null 2>&1 || die "podman is required (used for the deploy build container)"
# Rust binaries are built inside a Debian container so the resulting ELF
# links against an older glibc than this workstation's. Building natively
# on f44 (glibc 2.43) produces binaries that won't load on f42 / f43
# servers — the dynamic loader refuses them outright. Debian bookworm's
# glibc 2.36 is older than every Fedora release we deploy to, so its
# binaries are forward-compatible.
#
# The artifacts land in target/deploy/release/ so a native `cargo build`
# in this checkout (for tests, clippy, dev runs) doesn't compete with
# the container for incremental state, and vice-versa.
rust_build_image="docker.io/library/rust:1-bookworm"
rust_target_dir="${repo_root}/target/deploy"
# Resolve component list ----------------------------------------------------
env_path=".environments.${environment}"
yq --exit-status "${env_path}" "$manifest" >/dev/null \
|| die "environment '$environment' not found in manifest"
mapfile -t all_components < <(yq --raw-output "${env_path}.components | keys | .[]" "$manifest")
if [[ ${#components[@]} -eq 0 ]]; then
usage
fi
case "${components[0]:-}" in
all) components=("${all_components[@]}") ;;
default) components=(api web) ;;
esac
# Build artifacts -----------------------------------------------------------
needs_rust=0
needs_web=0
for c in "${components[@]}"; do
case "$c" in
api|worker) needs_rust=1 ;;
web) needs_web=1 ;;
esac
done
if (( needs_rust )); then
log "cargo build --release in ${rust_build_image} (api, worker)"
install --directory "$rust_target_dir"
# Named volumes cache the cargo registry and git index across runs so
# subsequent builds don't re-fetch every crate. CARGO_TARGET_DIR
# redirects build output into the host-mounted target/deploy.
# :Z relabels the bind mount for SELinux on Fedora hosts.
run podman run --rm \
--volume "${repo_root}:/workspace:Z" \
--volume moments-deploy-cargo-registry:/usr/local/cargo/registry \
--volume moments-deploy-cargo-git:/usr/local/cargo/git \
--workdir /workspace \
--env CARGO_TARGET_DIR=/workspace/target/deploy \
"$rust_build_image" \
cargo build --release --bin moments-api --bin moments-worker
fi
if (( needs_web )); then
log "vite build (ui)"
run sh -c "cd '${repo_root}/ui' && pnpm install --frozen-lockfile && pnpm run build"
fi
# Per-component deploy ------------------------------------------------------
deploy_api() {
local host="$1"
log "api -> $host"
local bind
bind="$(yq --raw-output "${env_path}.components.api.config.bind" "$manifest")"
[[ -n "$bind" && "$bind" != "null" ]] || die "api.config.bind missing in manifest"
[[ "$bind" == *:* ]] \
|| die "api.config.bind must be host:port form: '$bind'"
local api_port
api_port="${bind##*:}"
[[ "$api_port" =~ ^[0-9]+$ ]] \
|| die "api.config.bind port is not numeric: '$api_port'"
if (( dry_run )); then
printf '\033[2m[dry-run]\033[0m render api.env (HOSTNAME=%s, BIND=%s) + firewalld svc (port=%s) + units, stage to %s:/tmp/, install via heredoc, run sysusers/restorecon/semanage/systemctl on %s\n' \
"$host" "$bind" "$api_port" "$host" "$host" >&2
return 0
fi
local fqdn="$host"
local stage
stage="$(mktemp --directory)"
trap "rm --recursive --force '$stage'" RETURN
install --directory \
"$stage/etc/moments" \
"$stage/etc/systemd/system" \
"$stage/etc/sysusers.d" \
"$stage/etc/firewalld/services" \
"$stage/usr/local/bin"
local rendered
rendered="$(<"${repo_root}/asset/config/api.env.tmpl")"
rendered=${rendered//'{{HOSTNAME}}'/$fqdn}
rendered=${rendered//'{{BIND}}'/$bind}
printf '%s\n' "$rendered" > "$stage/etc/moments/api.env"
rendered="$(<"${repo_root}/asset/systemd/moments-api-cert.path")"
rendered=${rendered//'{{HOSTNAME}}'/$fqdn}
printf '%s\n' "$rendered" > "$stage/etc/systemd/system/moments-api-cert.path"
rendered="$(<"${repo_root}/asset/firewalld/moments-api.xml.tmpl")"
rendered=${rendered//'{{API_PORT}}'/$api_port}
printf '%s\n' "$rendered" > "$stage/etc/firewalld/services/moments-api.xml"
chmod 0644 "$stage/etc/firewalld/services/moments-api.xml"
install --mode=0644 "${repo_root}/asset/systemd/moments-api.service" "$stage/etc/systemd/system/"
install --mode=0644 "${repo_root}/asset/systemd/moments-api-cert-reload.service" "$stage/etc/systemd/system/"
install --mode=0644 "${repo_root}/asset/systemd/moments.sysusers.conf" "$stage/etc/sysusers.d/moments.conf"
install --mode=0755 "${rust_target_dir}/release/moments-api" "$stage/usr/local/bin/moments-api"
chmod 0640 "$stage/etc/moments/api.env"
# Stage to a tmpdir on the remote, then `install` each file at its final
# path via the heredoc. Never rsync into /, since rsync of staged parent
# dirs (etc/, usr/, ...) can leak ownership, ACLs and xattrs onto the
# live system dirs.
local remote_stage="/tmp/moments-deploy.api.${$}.${RANDOM}"
ensure_tmp_writable "$host" || return 1
rsync \
--archive \
--hard-links \
--numeric-ids \
--rsh='ssh -o BatchMode=yes' \
"$stage/" \
"${host}:${remote_stage}/"
ssh_run "$host" "sudo bash -s -- ${remote_stage@Q} ${api_port@Q}" <<'REMOTE_EOF'
set -euo pipefail
remote_stage="$1"
api_port="$2"
trap 'rm --recursive --force "$remote_stage"' EXIT
fqdn="$(hostname --fqdn)"
install --owner=root --group=root --mode=0644 \
"$remote_stage/etc/sysusers.d/moments.conf" \
/etc/sysusers.d/moments.conf
systemd-sysusers /etc/sysusers.d/moments.conf
install --directory --owner=root --group=moments --mode=0750 /etc/moments
install --directory --owner=moments --group=moments --mode=0750 /var/lib/moments
install --owner=root --group=moments --mode=0640 \
"$remote_stage/etc/moments/api.env" \
/etc/moments/api.env
install --owner=root --group=root --mode=0644 \
"$remote_stage/etc/systemd/system/moments-api.service" \
/etc/systemd/system/moments-api.service
install --owner=root --group=root --mode=0644 \
"$remote_stage/etc/systemd/system/moments-api-cert.path" \
/etc/systemd/system/moments-api-cert.path
install --owner=root --group=root --mode=0644 \
"$remote_stage/etc/systemd/system/moments-api-cert-reload.service" \
/etc/systemd/system/moments-api-cert-reload.service
install --owner=root --group=root --mode=0644 \
"$remote_stage/etc/firewalld/services/moments-api.xml" \
/etc/firewalld/services/moments-api.xml
install --owner=root --group=root --mode=0755 \
"$remote_stage/usr/local/bin/moments-api" \
/usr/local/bin/moments-api
# Grant the moments user read access to the host private key for the
# postgres mTLS connection.
setfacl --modify=u:moments:r "/etc/pki/tls/private/${fqdn}.pem" || true
# Idempotent label: --add fails if the port is already labelled (we suppress
# that one stderr line); --modify is then a no-op or fixes a stale type.
semanage port --add --type=http_port_t --proto=tcp "$api_port" 2>/dev/null \
|| semanage port --modify --type=http_port_t --proto=tcp "$api_port"
firewall-cmd --reload
zone="$(firewall-cmd --get-default-zone)"
if ! firewall-cmd --zone="$zone" --query-service=moments-api >/dev/null 2>&1; then
firewall-cmd --permanent --zone="$zone" --add-service=moments-api
firewall-cmd --zone="$zone" --add-service=moments-api
fi
restorecon -Rv /usr/local/bin/moments-api /etc/moments /var/lib/moments
systemctl daemon-reload
systemctl enable --now moments-api-cert.path
systemctl enable --now moments-api.service
systemctl restart moments-api.service
# Quietly retry while the service binds; only show curl's diagnostics if
# every attempt fails. The journalctl tail on failure is the verbose source.
for i in 1 2 3 4 5 6 7 8 9 10; do
if curl --fail --silent "http://${fqdn}:${api_port}/v1/healthz" >/dev/null 2>&1; then
echo "moments-api healthy"
exit 0
fi
sleep 1
done
echo "moments-api did not become healthy" >&2
curl --fail --silent --show-error "http://${fqdn}:${api_port}/v1/healthz" >/dev/null || true
journalctl --unit=moments-api.service --lines=50 --no-pager >&2
exit 1
REMOTE_EOF
}
deploy_worker() {
local host="$1"
log "worker -> $host"
# Manifest entries under `worker.secrets` map env-var name -> pass store path.
# The script fetches each via `pass` and substitutes the matching {{NAME}}
# placeholder in worker.env.tmpl. Adding a new secret is then a manifest +
# template change; no script edit required.
local -a secret_lines secret_keys
mapfile -t secret_lines < <(yq --raw-output \
"${env_path}.components.worker.secrets // {} | to_entries | .[] | \"\(.key)=\(.value)\"" \
"$manifest")
local line
for line in "${secret_lines[@]}"; do
[[ -n "$line" ]] && secret_keys+=("${line%%=*}")
done
if (( dry_run )); then
printf '\033[2m[dry-run]\033[0m render worker.env (HOSTNAME=%s, secrets [%s] from pass) + units, stage to %s:/tmp/, install via heredoc, run sysusers/restorecon/systemctl on %s\n' \
"$host" "${secret_keys[*]:-none}" "$host" "$host" >&2
return 0
fi
local fqdn="$host"
local stage
stage="$(mktemp --directory)"
trap "rm --recursive --force '$stage'" RETURN
install --directory \
"$stage/etc/moments" \
"$stage/etc/systemd/system" \
"$stage/etc/sysusers.d" \
"$stage/usr/local/bin"
# Render templates in-memory so secrets never appear on a command line
# (sed would expose them to anything that can read /proc/<pid>/cmdline).
local rendered
rendered="$(<"${repo_root}/asset/config/worker.env.tmpl")"
rendered=${rendered//'{{HOSTNAME}}'/$fqdn}
local key pass_path value
for line in "${secret_lines[@]}"; do
[[ -z "$line" ]] && continue
key="${line%%=*}"
pass_path="${line#*=}"
if pass show "$pass_path" >/dev/null 2>&1; then
value="$(pass show "$pass_path")"
else
warn "no secret in pass at '${pass_path}' for ${key}; worker will run without ${key}"
value=""
fi
rendered=${rendered//"{{${key}}}"/$value}
done
printf '%s\n' "$rendered" > "$stage/etc/moments/worker.env"
rendered="$(<"${repo_root}/asset/systemd/moments-worker-cert.path")"
rendered=${rendered//'{{HOSTNAME}}'/$fqdn}
printf '%s\n' "$rendered" > "$stage/etc/systemd/system/moments-worker-cert.path"
install --mode=0644 "${repo_root}/asset/systemd/moments-worker.service" "$stage/etc/systemd/system/"
install --mode=0644 "${repo_root}/asset/systemd/moments-worker-cert-reload.service" "$stage/etc/systemd/system/"
install --mode=0644 "${repo_root}/asset/systemd/moments.sysusers.conf" "$stage/etc/sysusers.d/moments.conf"
install --mode=0755 "${rust_target_dir}/release/moments-worker" "$stage/usr/local/bin/moments-worker"
chmod 0640 "$stage/etc/moments/worker.env"
# Stage to a tmpdir on the remote, then `install` each file at its final
# path via the heredoc. Never rsync into /.
local remote_stage="/tmp/moments-deploy.worker.${$}.${RANDOM}"
ensure_tmp_writable "$host" || return 1
rsync \
--archive \
--hard-links \
--numeric-ids \
--rsh='ssh -o BatchMode=yes' \
"$stage/" \
"${host}:${remote_stage}/"
ssh_run "$host" "sudo bash -s -- ${remote_stage@Q}" <<'REMOTE_EOF'
set -euo pipefail
remote_stage="$1"
trap 'rm --recursive --force "$remote_stage"' EXIT
fqdn="$(hostname --fqdn)"
install --owner=root --group=root --mode=0644 \
"$remote_stage/etc/sysusers.d/moments.conf" \
/etc/sysusers.d/moments.conf
systemd-sysusers /etc/sysusers.d/moments.conf
install --directory --owner=root --group=moments --mode=0750 /etc/moments
install --directory --owner=moments --group=moments --mode=0750 /var/lib/moments
install --owner=root --group=moments --mode=0640 \
"$remote_stage/etc/moments/worker.env" \
/etc/moments/worker.env
install --owner=root --group=root --mode=0644 \
"$remote_stage/etc/systemd/system/moments-worker.service" \
/etc/systemd/system/moments-worker.service
install --owner=root --group=root --mode=0644 \
"$remote_stage/etc/systemd/system/moments-worker-cert.path" \
/etc/systemd/system/moments-worker-cert.path
install --owner=root --group=root --mode=0644 \
"$remote_stage/etc/systemd/system/moments-worker-cert-reload.service" \
/etc/systemd/system/moments-worker-cert-reload.service
install --owner=root --group=root --mode=0755 \
"$remote_stage/usr/local/bin/moments-worker" \
/usr/local/bin/moments-worker
setfacl --modify=u:moments:r "/etc/pki/tls/private/${fqdn}.pem" || true
restorecon -Rv /usr/local/bin/moments-worker /etc/moments /var/lib/moments
systemctl daemon-reload
systemctl enable --now moments-worker-cert.path
systemctl enable --now moments-worker.service
systemctl restart moments-worker.service
if ! systemctl is-active --quiet moments-worker.service; then
journalctl --unit=moments-worker.service --lines=50 --no-pager >&2
exit 1
fi
echo "moments-worker active"
REMOTE_EOF
}
deploy_web() {
local host="$1"
log "web -> $host"
local server_name web_root api_upstream web_listen
server_name="$(yq --raw-output "${env_path}.components.web.config.server_name" "$manifest")"
web_root="$(yq --raw-output "${env_path}.components.web.config.root" "$manifest")"
api_upstream="$(yq --raw-output "${env_path}.components.web.config.api_upstream" "$manifest")"
# The edge's stream SNI router owns TCP 443; vhosts sit behind it on 14443.
# Binding 443 here collides with the router — `nginx -t` passes, but every
# later reload aborts while systemctl still reports success. See the
# web.config.listen note in asset/manifest.yml.
web_listen="$(yq --raw-output "${env_path}.components.web.config.listen" "$manifest")"
[[ -n "$web_listen" && "$web_listen" != "null" ]] \
|| web_listen='127.0.0.1:14443 ssl proxy_protocol'
[[ -n "$server_name" && "$server_name" != "null" ]] || die "web.config.server_name missing in manifest"
[[ -n "$web_root" && "$web_root" != "null" ]] || die "web.config.root missing in manifest"
[[ -n "$api_upstream" && "$api_upstream" != "null" ]] || die "web.config.api_upstream missing in manifest"
[[ "$web_root" == /* ]] \
|| die "web.config.root must be an absolute path: '$web_root'"
[[ "$api_upstream" == http://* || "$api_upstream" == https://* ]] \
|| die "web.config.api_upstream must be a http(s) URL: '$api_upstream'"
local api_upstream_scheme api_upstream_addr api_upstream_port
api_upstream_scheme="${api_upstream%%://*}"
api_upstream_addr="${api_upstream#*://}"
[[ "$api_upstream_addr" == *:* ]] \
|| die "web.config.api_upstream must include an explicit port: '$api_upstream'"
api_upstream_port="${api_upstream_addr##*:}"
[[ "$api_upstream_port" =~ ^[0-9]+$ ]] \
|| die "extracted upstream port is not numeric: '$api_upstream_port'"
local site_conf_path="/etc/nginx/conf.d/${server_name}.conf"
if (( dry_run )); then
printf '\033[2m[dry-run]\033[0m render %s (server_name=%s, docroot=%s, upstream=%s://%s) + rsync ui/dist/ to %s:%s/, run nginx -t/reload on %s\n' \
"$site_conf_path" "$server_name" "$web_root" \
"$api_upstream_scheme" "$api_upstream_addr" \
"$host" "$web_root" "$host" >&2
return 0
fi
local stage
stage="$(mktemp --directory)"
trap "rm --recursive --force '$stage'" RETURN
install --directory "${stage}${web_root}" "$stage/etc/nginx/conf.d"
rsync --archive "${repo_root}/ui/dist/" "${stage}${web_root}/"
local rendered
rendered="$(<"${repo_root}/asset/nginx/site.conf.tmpl")"
rendered=${rendered//'{{SERVER_NAME}}'/$server_name}
rendered=${rendered//'{{WEB_LISTEN}}'/$web_listen}
rendered=${rendered//'{{DOCROOT}}'/$web_root}
rendered=${rendered//'{{API_UPSTREAM_SCHEME}}'/$api_upstream_scheme}
rendered=${rendered//'{{API_UPSTREAM_ADDR}}'/$api_upstream_addr}
printf '%s\n' "$rendered" > "${stage}${site_conf_path}"
chmod 0644 "${stage}${site_conf_path}"
# Both targets are leaf paths (the docroot itself, and a single named
# file) so rsync does not traverse /var or /etc parents — `--chown` is
# enough; -A/-X are intentionally absent.
rsync \
--archive \
--hard-links \
--numeric-ids \
--chown root:root \
--rsh='ssh -o BatchMode=yes' \
--rsync-path 'sudo rsync' \
--delete \
"${stage}${web_root}/" \
"${host}:${web_root}/"
rsync \
--archive \
--hard-links \
--numeric-ids \
--chown root:root \
--rsh='ssh -o BatchMode=yes' \
--rsync-path 'sudo rsync' \
"${stage}${site_conf_path}" \
"${host}:${site_conf_path}"
ssh_run "$host" "sudo bash -s -- ${web_root@Q} ${site_conf_path@Q} ${api_upstream_port@Q}" <<'REMOTE_EOF'
set -euo pipefail
web_root="$1"
site_conf_path="$2"
api_upstream_port="$3"
# Allow nginx to make outbound connections to the moments-api upstream
# across the WG mesh.
setsebool -P httpd_can_network_connect on
# Idempotent label: --add fails if the port is already labelled (we suppress
# that one stderr line); --modify is then a no-op or fixes a stale type.
semanage port --add --type=http_port_t --proto=tcp "$api_upstream_port" 2>/dev/null \
|| semanage port --modify --type=http_port_t --proto=tcp "$api_upstream_port"
restorecon -Rv "$web_root" "$site_conf_path"
if ! nginx -t; then
echo "nginx config check failed" >&2
exit 1
fi
systemctl reload nginx
echo "nginx reloaded"
REMOTE_EOF
}
# Dispatch ------------------------------------------------------------------
failed=()
for component in "${components[@]}"; do
mapfile -t hosts < <(yq --raw-output "${env_path}.components.${component}.hosts[]" "$manifest")
for host in "${hosts[@]}"; do
case "$component" in
api) deploy_api "$host" || failed+=("api@$host") ;;
worker) deploy_worker "$host" || failed+=("worker@$host") ;;
web) deploy_web "$host" || failed+=("web@$host") ;;
*) warn "unknown component: $component" ;;
esac
done
done
if [[ ${#failed[@]} -gt 0 ]]; then
die "failed: ${failed[*]}"
fi
log "deploy complete"