#!/usr/bin/env bash # MealQuest automated deploy: deploy/RUNBOOK.md, run unattended. # # mealquest-auto-deploy deploy origin/main if it is new and CI passed # mealquest-auto-deploy --ref deploy now, bypassing the CI and no-op gates # # Installed as /usr/local/sbin/mealquest-auto-deploy and run as overseer by # mealquest-auto-deploy.service (deploy/systemd); install, semantics and the # manual override: deploy/RUNBOOK.md "Automated deploys". Run as root (sudo) # it re-executes itself as overseer. # # Without --ref: exit 0 with no output when origin/main is the last deployed # SHA in the deploy record or its CI is pending; exit 1, deploying nothing, # when its CI failed or reports no status, or when the last attempt to deploy # that SHA failed. Every deploy runs the runbook's blocks in order and stops # at the first failure; nothing before the restart touches production. A # verified deploy appends one line to the deploy record: # previous= deployed= restore_point= # a failed one appends: # FAILED target= status= set -euo pipefail CHECKOUT=/home/overseer/mealquest RECORD=/home/overseer/mealquest-auto-deploy.log LOCK=/home/overseer/mealquest-auto-deploy.lock STATUS_API=https://code.cubecraftlabs.com/api/v1/repos/Maniacs_Mansion/MealQuest/commits PUBLIC=https://mealquest.cubecraftlabs.com D=/var/lib/docker/volumes/mealquest-data/_data COMPOSE=(docker compose -f deploy/compose.yaml) fail() { echo "$*" >&2; exit 1; } if [ "$(id -u)" -eq 0 ]; then exec sudo -u overseer -H -- "$0" "$@" fi [ "$(id -un)" = overseer ] || fail "run as overseer (or root), not $(id -un)" ref= case "$#" in 0) ;; 2) [ "$1" = --ref ] && [ -n "$2" ] || fail "usage: $0 [--ref ]"; ref=$2 ;; *) fail "usage: $0 [--ref ]" ;; esac exec 9>> "$LOCK" flock -n 9 || fail "another mealquest-auto-deploy run holds $LOCK" cd "$CHECKOUT" git fetch --quiet --tags origin || fail "git fetch origin failed" if [ -n "$ref" ]; then target=$(git rev-parse --verify --quiet "$ref^{commit}") || fail "$ref is not a commit in $CHECKOUT" else # The full remote-tracking ref: a bare origin/main resolves a tag or local # branch of that name first, which could deploy a commit not on main. target=$(git rev-parse --verify --quiet "refs/remotes/origin/main^{commit}") \ || fail "refs/remotes/origin/main is not a commit in $CHECKOUT" [ -f "$RECORD" ] || fail "no deploy record at $RECORD: seed it with one manual deploy (deploy/RUNBOOK.md \"Automated deploys\")" last=$(grep -oE 'deployed=[0-9a-f]{40}' "$RECORD" | tail -n 1 | cut -d = -f 2) || true [ -n "$last" ] || fail "$RECORD records no deploy: seed it with one manual deploy (deploy/RUNBOOK.md \"Automated deploys\")" [ "$target" != "$last" ] || exit 0 if tail -n 1 "$RECORD" | grep -q " FAILED target=$target status="; then fail "auto-deploy of $target already failed (see $RECORD and the journal): fix it, then deploy with --ref" fi state=$(curl -fsS --max-time 30 "$STATUS_API/$target/status" \ | python3 -c 'import json, sys; d = json.load(sys.stdin); print(d["state"] if d["total_count"] else "absent")') \ || fail "could not read the CI status of $target from $STATUS_API" case "$state" in success) ;; pending) exit 0 ;; *) fail "CI for origin/main $target is $state, not success: not deploying" ;; esac fi echo "auto-deploy: deploying $target${ref:+ (--ref $ref)}" restarted= W= on_exit() { local st=$? [ -z "$W" ] || rm -rf "$W" || st=1 if [ "$st" -ne 0 ]; then echo "$(date -u +%Y-%m-%dT%H:%M:%SZ) FAILED target=$target status=$st" >> "$RECORD" if [ -n "$restarted" ]; then echo "auto-deploy FAILED (status $st) after the restart: production may be down or unverified; read journalctl -u mealquest, then fix or roll back (deploy/RUNBOOK.md)" >&2 else echo "auto-deploy FAILED (status $st): production untouched" >&2 fi fi exit "$st" } trap on_exit EXIT # deploy/RUNBOOK.md "Deploy" > "1. Build": record the rollback SHA from the # RUNNING release, check out the target, reject a dirty checkout, build. bind=$(grep -E '^MEALQUEST_BIND_ADDRESS=' deploy/.env | cut -d = -f 2-) || fail "deploy/.env sets no MEALQUEST_BIND_ADDRESS" [ -n "$bind" ] || fail "deploy/.env sets no MEALQUEST_BIND_ADDRESS" running=$(curl -fsS "http://$bind:8090/health" | python3 -c 'import json, sys; print(json.load(sys.stdin)["version"])') \ || fail "http://$bind:8090/health did not return a version: no running release to roll back to" case "$running" in dev|*-dirty) fail "the running release reports $running, which names no commit" ;; esac rollback=$(git rev-parse --verify --quiet "$running^{commit}") \ || fail "running version $running is not a commit in this repository" echo "rollback SHA: $rollback (running version $running)" git checkout --quiet --detach "$target" dirty=$(git status --porcelain --untracked-files=no -- . ':(exclude)internal/frontend/dist') [ -z "$dirty" ] || fail "the checkout has modified tracked files; clean it first: $dirty" v=$(git describe --tags --always)${dirty:+-dirty} # the Makefile's VERSION make docker docker image inspect "mealquest:$v" > /dev/null 2>&1 || fail "no image mealquest:$v: the build failed" echo "built: mealquest:$v" # deploy/RUNBOOK.md "Prerequisites": deploy/.env sets every variable of the # target's .env.example, and every secret is mode 0600. missing=$(comm -23 <(grep -oE '^MEALQUEST_[A-Z_]+' deploy/.env.example | sort -u) \ <(grep -oE '^MEALQUEST_[A-Z_]+' deploy/.env | sort -u)) [ -z "$missing" ] || fail "deploy/.env is missing: $missing" loose=$(sudo find deploy/secrets -type f ! -perm 600) [ -z "$loose" ] || fail "secrets not mode 0600: $loose" # CTXD RUNBOOKS.MD "Routine deploy and rollback" > "Deploy" step 2: a verified # pre-deploy restore point. The scripts are CTXD "Count check". They and the # check files live in a private mktemp directory (mode 0700), not at shared # /tmp paths, and python runs with -I: the snapshot runs as root, and a # shared directory on its import path would let any local user run code as # root. W=$(mktemp -d) cat > "$W/mqcount.py" <<'EOF' import sqlite3, sys, hashlib c = sqlite3.connect(sys.argv[1]) tabs = [r[0] for r in c.execute("select name from sqlite_master where type=\"table\" and name not like \"sqlite_%\" and name not like \"_litestream%\" order by name")] for t in ("dinners", "week_plans", "schema_migrations"): print(t, c.execute(f"select count(*) from {t}").fetchone()[0]) h = hashlib.sha256() for t in tabs: for row in c.execute(f"select * from \"{t}\" order by rowid"): h.update(repr(row).encode()) print("tables", len(tabs), "content-sha256", h.hexdigest()[:16]) print("integrity", c.execute("pragma integrity_check").fetchone()[0]) EOF cat > "$W/mqsnap.py" <<'EOF' import os, sqlite3, sys src, dst = sys.argv[1], sys.argv[2] if os.path.exists(dst): sys.exit(f"{dst} already exists") s = sqlite3.connect(f"file:{src}?mode=ro", uri=True) d = sqlite3.connect(dst) s.backup(d) s.close() result = d.execute("pragma integrity_check").fetchall() d.close() if result != [("ok",)]: sys.exit(f"snapshot integrity_check failed: {result}") print("snapshot", dst, "integrity ok") EOF point=$( set -eu point= trap 'st=$? rm -rf "$W" || st=1 sudo sh -c "rm -f $D/predeploy-check.db*" || st=1 if [ "$st" -eq 0 ] && [ -n "$point" ]; then echo "pre-deploy restore point: $point" >&2; echo "$point" else [ "$st" -ne 0 ] || st=1; echo "verification FAILED (status $st): do not restart" >&2; fi exit "$st"' EXIT exec 3>&1 1>&2 # block output to the journal; only the restore point to $point mkdir "$W/mqcheck" # a. Replication running, no log errors (grep: 0 match, 1 none, 2+ error), newest LTX listed id=$("${COMPOSE[@]}" ps --status running --quiet litestream) || fail "docker compose ps failed" [ -n "$id" ] || fail "litestream is not running" "${COMPOSE[@]}" logs --since 24h litestream > "$W/mqcheck/litestream.log" || fail "docker compose logs failed" gs=0 grep -i error "$W/mqcheck/litestream.log" > "$W/mqcheck/litestream.errors" || gs=$? case "$gs" in 0) cat "$W/mqcheck/litestream.errors"; fail "litestream logged errors in the last 24h";; 1) ;; *) fail "the litestream log scan failed (status $gs)";; esac "${COMPOSE[@]}" exec -T litestream \ litestream ltx -config /etc/litestream.yml -level all /data/mealquest.db # b. Restore replica to a scratch file (live files untouched), move off the volume, snapshot live "${COMPOSE[@]}" run --rm --no-deps litestream \ restore -config /etc/litestream.yml -o /data/predeploy-check.db /data/mealquest.db sudo sh -c "mv $D/predeploy-check.db* $W/mqcheck/" sudo python3 -I "$W/mqsnap.py" "$D/mealquest.db" "$W/mqcheck/live.db" sudo chown -R "$(id -un)" "$W/mqcheck" sudo ls -ln "$D" # mealquest.db-wal and -shm owned by 10001, never 0 (CTXD "Count check") root=$(sudo find "$D" -maxdepth 1 \( -name mealquest.db-wal -o -name mealquest.db-shm \) -uid 0) [ -z "$root" ] || fail "owned by root, not 10001 (CTXD RUNBOOKS.MD \"Count check\"): $root" # c. Restored replica: integrity ok and identical counts/content-sha256 to the live snapshot python3 -I "$W/mqcount.py" "$W/mqcheck/predeploy-check.db" > "$W/mqcheck/restored.txt" python3 -I "$W/mqcount.py" "$W/mqcheck/live.db" > "$W/mqcheck/live.txt" cat "$W/mqcheck/restored.txt" grep -qx 'integrity ok' "$W/mqcheck/restored.txt" || fail "the restored replica failed integrity_check" diff "$W/mqcheck/restored.txt" "$W/mqcheck/live.txt" || fail "the restored replica differs from the live snapshot" # d. Restore point: after every check, before the restart point=$(date -u +%Y-%m-%dT%H:%M:%SZ) exec 1>&3 ) # CTXD RUNBOOKS.MD "Routine deploy and rollback" > "Deploy" step 3: # boot-required configuration, secrets read the way the service reads them. [ -f deploy/.env ] || fail "missing deploy/.env" cid=$(grep -m 1 -E '^MEALQUEST_GOOGLE_CLIENT_ID=' deploy/.env | cut -d = -f 2- | tr -d '"' | tr -d "'") \ || fail "missing MEALQUEST_GOOGLE_CLIENT_ID in deploy/.env" [ -n "$cid" ] || fail "missing MEALQUEST_GOOGLE_CLIENT_ID in deploy/.env" [ -e deploy/secrets/google_client_secret ] || fail "missing deploy/secrets/google_client_secret" [ -s deploy/secrets/google_client_secret ] || fail "deploy/secrets/google_client_secret is empty" id=$("${COMPOSE[@]}" ps --status running --quiet mealquest) || fail "docker compose ps failed" [ -n "$id" ] || fail "the mealquest container is not running: start it (the check reads secrets the way the service does)" docker exec "$id" sh -c 'test -s /run/secrets/google_client_secret && test -r /run/secrets/google_client_secret' \ || fail "google_client_secret is not readable by the service user inside the container" echo "boot-required configuration present" # deploy/RUNBOOK.md "2. Ship and restart". "${COMPOSE[@]}" pull litestream searxng docker tag "mealquest:$v" mealquest-mealquest restarted=yes sudo systemctl restart mealquest for s in mealquest litestream searxng; do id=$("${COMPOSE[@]}" ps --status running --quiet "$s") [ -n "$id" ] || fail "$s is not running" done id=$("${COMPOSE[@]}" ps --quiet mealquest) health=$(docker inspect -f '{{.State.Health.Status}}' "$id") [ "$health" = healthy ] || fail "mealquest is $health, not healthy" echo "restarted on: mealquest:$v" # deploy/RUNBOOK.md "3. Verify the deployed version and a real screen". for base in "http://$bind:8090" "$PUBLIC"; do got=$(curl -fsS "$base/health" | python3 -c 'import json, sys; print(json.load(sys.stdin)["version"])') \ || fail "$base/health did not return a version" [ "$got" = "$v" ] || fail "$base/health reports version $got, the checkout is $v" for path in / /plan; do page=$(curl -fsS "$base$path") || fail "$base$path did not return 200" grep -q '
' <<< "$page" || fail "$base$path is not the PWA shell" js=$(grep -oE 'src="/assets/[^"]+\.js"' <<< "$page" | head -n 1 | cut -d '"' -f 2) || true [ -n "$js" ] || fail "$base$path references no built /assets/ script" type=$(curl -fsS -o /dev/null -w '%{content_type}' "$base$js") || fail "$base$js did not return 200" case "$type" in text/javascript*) ;; *) fail "$base$js is $type, not JavaScript" ;; esac done echo "$base: version $got, PWA shell and $js served" done deployed=$(git rev-parse HEAD) echo "$(date -u +%Y-%m-%dT%H:%M:%SZ) previous=$rollback deployed=$deployed restore_point=$point" >> "$RECORD" echo "deployed and verified: $v ($deployed), previous $rollback, restore point $point"