#!/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}" # The build joins the Gitea container's network namespace: localhost inside # the build = Gitea's loopback (port 3000), host loopback stays unreachable. # (A netavark bridge would be equivalent, but rootless bridges need the # ip_tables kernel module, absent on the machine — pasta needs nothing.) BUILD_NETNS="${BUILD_NETNS:-container:ctao-demo-gitea}" REPO_INTERNAL="${REPO_INTERNAL:-http://localhost:3000/$REPO.git}" KEEP="${KEEP:-3}" # released builds to retain mkdir -p "$BASE/repo" "$BASE/releases" "$BASE/state" "$BASE/npm-cache" # Host prerequisites (everything else runs inside containers). Fail loud — # a missing tool is permanent, unlike a Gitea hiccup below. for tool in curl jq podman; do command -v "$tool" >/dev/null || { echo "missing host tool: $tool"; exit 1; } done # --- 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 # Skip only if this sha is both recorded AND still present in releases/ # (a deleted release dir must trigger a rebuild, not an eternal skip). [[ "$sha" == "$(cat "$BASE/state/last-built" 2>/dev/null)" \ && -d "$BASE/releases/$sha" ]] && 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 Gitea's netns — it reaches Gitea on localhost:3000 and the # internet (for `npm ci` when the lockfile changed), but NOT the host's # loopback services (code-server). Never use --network=host here. # node_modules and .deps-hash are untracked, so they survive checkouts. podman run --rm --network="$BUILD_NETNS" --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" \ -v "$BASE/npm-cache:/root/.npm:z" \ -w /work "$BUILD_IMAGE" sh -ec ' git config --global safe.directory /work/repo [ -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" # --force: the working copy is disposable; a stray tracked-file edit must # not wedge every future build. git -C repo checkout --quiet --force "$SHA" cd repo lock=$(sha256sum package-lock.json | cut -d" " -f1) if [ ! -d node_modules ] || [ "$lock" != "$(cat .deps-hash 2>/dev/null)" ]; then # --ignore-scripts: (1) removes the install-time postinstall vector from # npm deps (build-time repo code still runs `npm run build` below — the # netns confinement is the control for that), (2) avoids the esbuild # ETXTBSY postinstall race in rootless containers. esbuild ships its # binary as an optional dep, so nothing here needs lifecycle scripts. npm ci --ignore-scripts --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. `current`'s target is excluded explicitly — # mtime ordering makes it newest today, but nothing should depend on that. # `|| 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" cur=$(readlink current || true) ls -1t | grep -vx current | grep -vx -- "$cur" | tail -n +"$((KEEP + 1))" | while read -r old; do rm -rf -- "$old" done || true echo "published $sha in $(( $(date +%s) - t0 ))s"