deploy: machine deployment — quadlets (gitea, nginx, isolated build network), poll+build pipeline, runbook

Reviewed (Opus security review, all findings verified) and tested end-to-end
locally: cold build 23s, no-op poll 45ms, incremental rebuild 4s, prune,
gitea-down grace, real bridge network with container DNS.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012jWfn3RwPfFGTtBddm36Uy
This commit is contained in:
2026-07-28 13:44:46 +02:00
co-authored by Claude Fable 5
parent 4102974927
commit ed7a9c01e7
9 changed files with 336 additions and 0 deletions
+80
View File
@@ -0,0 +1,80 @@
#!/usr/bin/env bash
# CTAO portal demo — poll the content repo, rebuild, publish atomically.
# Triggered every 10 s by ctao-portal-build.timer; systemd oneshot semantics
# guarantee runs never overlap. The 99.9% case is one local curl and exit 0.
# No secrets anywhere: the portal repo is public-read on the local Gitea.
set -euo pipefail
# Defaults match the machine; every var is env-overridable so the whole
# pipeline can be tested locally against a sandbox dir + local Gitea.
BASE="${BASE:-$HOME/ctao-portal-demo}"
GITEA_URL="${GITEA_URL:-http://localhost:3000}" # published by ctao-demo-gitea
REPO="${REPO:-ctao/portal}" # owner/repo in Gitea
BRANCH="${BRANCH:-main}"
BUILD_IMAGE="${BUILD_IMAGE:-localhost/ctao-portal-build:1}"
BUILD_NET="${BUILD_NET:-ctao-demo}" # bridge from ctao-demo.network
# Clone URL as seen from INSIDE the build network (container-name DNS);
# the poll below uses $GITEA_URL because it runs on the host.
REPO_INTERNAL="${REPO_INTERNAL:-http://ctao-demo-gitea:3000/$REPO.git}"
KEEP="${KEEP:-3}" # released builds to retain
mkdir -p "$BASE/repo" "$BASE/releases" "$BASE/state"
# --- 1. Cheap poll: branch head via the local Gitea API (host curl + jq) ---
sha=$(curl -fsS --max-time 5 "$GITEA_URL/api/v1/repos/$REPO/branches/$BRANCH" \
| jq -r '.commit.id' || true)
# Gitea down/unreachable is a transient, not a unit failure — exit 0 quietly
# instead of painting the journal red every 10 s.
if [[ ! "$sha" =~ ^[0-9a-f]{40}$ ]]; then
echo "poll failed (gitea unreachable?) — skipping this tick"
exit 0
fi
[[ "$sha" == "$(cat "$BASE/state/last-built" 2>/dev/null)" ]] && exit 0
echo "building $sha"
t0=$(date +%s)
# --- 2. Build in the ephemeral container (git + pinned node live there).
# SECURITY: the container runs npm lifecycle scripts from the repo, so it is
# confined to the ctao-demo bridge — it reaches Gitea by container name and
# the internet (for `npm ci` when the lockfile changed), but NOT the host's
# loopback services (code-server, Strapi). Never use --network=host here.
# node_modules and .deps-hash are untracked, so they survive checkouts.
podman run --rm --network="$BUILD_NET" --memory=1g \
-e ASTRO_TELEMETRY_DISABLED=1 \
-e SHA="$sha" -e REPO_URL="$REPO_INTERNAL" -e BRANCH="$BRANCH" \
-v "$BASE/repo:/work/repo:z" \
-v "$BASE/releases:/work/releases:z" \
-w /work "$BUILD_IMAGE" sh -ec '
git config --global safe.directory "*"
[ -d repo/.git ] || git clone --branch "$BRANCH" "$REPO_URL" repo
git -C repo remote set-url origin "$REPO_URL" # self-heal if the URL changes
git -C repo fetch --quiet origin "$BRANCH"
git -C repo checkout --quiet "$SHA"
cd repo
lock=$(sha256sum package-lock.json | cut -d" " -f1)
if [ ! -d node_modules ] || [ "$lock" != "$(cat .deps-hash 2>/dev/null)" ]; then
npm ci --no-audit --no-fund
echo "$lock" > .deps-hash
fi
npm run build
rm -rf "../releases/$SHA"
cp -a dist "../releases/$SHA"
'
# --- 3. Atomic publish: symlink flip via rename(2) — no half-published moment.
# mv -T is GNU (the target host is Rocky); when testing on macOS put a
# coreutils `mv` (gmv) first in PATH.
rm -f "$BASE/releases/".current.* # stale temps from a crash mid-flip
ln -s "$sha" "$BASE/releases/.current.$$"
mv -Tf "$BASE/releases/.current.$$" "$BASE/releases/current"
echo "$sha" > "$BASE/state/last-built"
# --- 4. Prune old releases (never touches `current` — it is always newest).
# `|| true`: an empty match must not fail the unit after a successful publish
# (grep exits 1 under pipefail when there is nothing to prune).
cd "$BASE/releases"
ls -1t | grep -vx current | tail -n +"$((KEEP + 1))" | while read -r old; do
rm -rf -- "$old"
done || true
echo "published $sha in $(( $(date +%s) - t0 ))s"