#!/usr/bin/env bash set -euo pipefail umask 077 base=/opt/endoleap-infra foundry=$base/foundry origin=https://api.foundry.entroia.com candidate=foundry-core-candidate network=endoleap-dev_default for name in GOOGLE_CLIENT_ID GOOGLE_CLIENT_SECRET; do value=${!name-} if [[ -z "$value" || "$value" == *$'\n'* || "$value" == *$'\r'* ]]; then printf 'Missing or invalid %s\n' "$name" >&2 exit 1 fi done # Accept formatted JSON from Actions Variables and never print its contents. FOUNDRY_PROJECTS=$(python3 - <<'PYCONFIG' import json import os import sys raw = os.environ.get("FOUNDRY_PROJECTS", "") if not raw.strip(): sys.exit("FOUNDRY_PROJECTS was not injected from Actions Variables") try: projects = json.loads(raw) except ValueError: sys.exit("FOUNDRY_PROJECTS must be valid JSON") if not isinstance(projects, dict) or not projects or any( not isinstance(callback, str) or not callback.strip() for callback in projects.values() ): sys.exit("FOUNDRY_PROJECTS must map project names to callback URLs") print(json.dumps(projects, separators=(",", ":"))) PYCONFIG ) export FOUNDRY_PROJECTS test -s "$foundry/database.env" docker network inspect "$network" >/dev/null release=$(mktemp -d "$foundry/release.XXXXXXXX") install -m 755 "$1" "$release/foundry" export HOST=0.0.0.0 PORT=8080 PUBLIC_ORIGIN="$origin" codex_mounts=() codex_dir=$foundry/codex if [[ -d "$codex_dir" ]]; then if ! docker inspect -f '{{.State.Running}}' foundry-codex-worker 2>/dev/null | grep -qx true; then bash "$(dirname "$0")/codex-worker.sh" "$release/foundry" >/dev/null fi for attempt in {1..20}; do [[ ! -S "$codex_dir/run/worker.sock" ]] || break sleep 0.25 done test -S "$codex_dir/run/worker.sock" test -s "$codex_dir/grants/projects.json" codex_mounts=( --mount "type=bind,source=$codex_dir/run,target=/run/foundry-codex,readonly" --mount "type=bind,source=$codex_dir/grants,target=/etc/foundry-codex,readonly" -e FOUNDRY_CODEX_SOCKET=/run/foundry-codex/worker.sock -e FOUNDRY_CODEX_GRANTS=/etc/foundry-codex/projects.json ) fi docker run -d --name "$candidate" "${codex_mounts[@]}" --network "$network" \ --env-file "$foundry/database.env" -e HOST -e PORT -e PUBLIC_ORIGIN \ -e GOOGLE_CLIENT_ID -e GOOGLE_CLIENT_SECRET -e FOUNDRY_PROJECTS \ --user 1000:1000 --cap-drop ALL --security-opt no-new-privileges \ --read-only --memory 128m \ --mount "type=bind,source=$release/foundry,target=/opt/foundry,readonly" \ node:24-bookworm-slim /opt/foundry >/dev/null cleanup_candidate() { docker rm -f "${live:-$candidate}" >/dev/null 2>&1 || true } trap cleanup_candidate EXIT docker exec "$candidate" node -e ' const assert = require("node:assert/strict"); async function verify() { const base = "http://127.0.0.1:8080"; for (let attempt = 0; ; attempt++) { try { const ready = await fetch(base + "/readyz", {signal: AbortSignal.timeout(3000)}); if (ready.ok) break; } catch (error) { if (attempt === 9) throw error; } assert.ok(attempt < 9); await new Promise(resolve => setTimeout(resolve, 1000)); } const project = Object.keys(JSON.parse(process.env.FOUNDRY_PROJECTS))[0]; const route = base + "/projects/" + encodeURIComponent(project) + "/auth"; assert.equal((await fetch(route + "/me")).status, 401); if (process.env.FOUNDRY_CODEX_SOCKET) { const exec = base + "/projects/" + encodeURIComponent(project) + "/codex/exec"; assert.equal((await fetch(exec, {method: "POST"})).status, 401); } } verify().catch(() => { console.error("Foundry readiness or anonymous access check failed"); process.exit(1); }); ' # Keep the previous running container until the new candidate has passed checks. # Traefik reads the route atomically from its watched config directory. id=$(docker inspect -f '{{.Id}}' "$candidate") live=foundry-core-${id:0:12} docker rename "$candidate" "$live" docker update --restart unless-stopped "$live" >/dev/null bash "$(dirname "$0")/route.sh" "$live" trap - EXIT printf 'Foundry readiness and unauthenticated rejection verified.\n'