176 lines
6.8 KiB
Bash
176 lines
6.8 KiB
Bash
#!/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"
|
|
# Go's HTTPS client uses the OS trust store, not Node's bundled roots.
|
|
# The slim Node runtime has no system CA bundle of its own.
|
|
ca_bundle=/etc/ssl/certs/ca-certificates.crt
|
|
test -s "$ca_bundle"
|
|
docker network inspect "$network" >/dev/null
|
|
python3 "$(dirname "$0")/migrate.py"
|
|
|
|
release=$(mktemp -d "$foundry/release.XXXXXXXX")
|
|
install -m 755 "$1" "$release/foundry"
|
|
export HOST=0.0.0.0 PORT=8080 PUBLIC_ORIGIN="$origin"
|
|
mail_env=()
|
|
if [[ -s "$foundry/mail.env" ]]; then
|
|
mail_env=(--env-file "$foundry/mail.env")
|
|
fi
|
|
research_env=()
|
|
if [[ -s "$foundry/research.env" ]]; then
|
|
research_env=(--env-file "$foundry/research.env")
|
|
fi
|
|
slack_env=()
|
|
if [[ -n "${SLACK_CLIENT_ID:-}" || -n "${SLACK_CLIENT_SECRET:-}" ]]; then
|
|
for name in SLACK_CLIENT_ID SLACK_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
|
|
export SLACK_CLIENT_ID SLACK_CLIENT_SECRET
|
|
slack_env=(-e SLACK_CLIENT_ID -e SLACK_CLIENT_SECRET)
|
|
elif [[ -s "$foundry/slack.env" ]]; then
|
|
slack_env=(--env-file "$foundry/slack.env")
|
|
fi
|
|
codex_mounts=()
|
|
codex_dir=$foundry/codex
|
|
if [[ -d "$codex_dir" ]]; then
|
|
# Core and its private worker must speak the same Canvas artifact protocol.
|
|
# Keep the previous container definition for exact startup rollback.
|
|
worker_backup=
|
|
if docker inspect foundry-codex-worker >/dev/null 2>&1; then
|
|
worker_backup=foundry-codex-worker-previous-$(date +%s)-$$
|
|
docker stop -t 5 foundry-codex-worker >/dev/null
|
|
docker rename foundry-codex-worker "$worker_backup"
|
|
fi
|
|
worker_ready=false
|
|
if bash "$(dirname "$0")/codex-worker.sh" "$release/foundry" >/dev/null; then
|
|
for attempt in {1..20}; do
|
|
if docker exec foundry-codex-worker node -e '
|
|
const http = require("node:http");
|
|
const req = http.get({socketPath:"/run/foundry-codex/worker.sock",path:"/canvas/models"}, res => {
|
|
let body = "";
|
|
res.on("data", chunk => { body += chunk; if(body.length > 262144) req.destroy(); });
|
|
res.on("end", () => { try { const value=JSON.parse(body); process.exit(res.statusCode===200 && Array.isArray(value.models) && value.models.length>0 ? 0 : 1); } catch { process.exit(1); } });
|
|
});
|
|
req.setTimeout(2000, () => req.destroy());
|
|
req.on("error", () => process.exit(1));
|
|
' >/dev/null 2>&1; then worker_ready=true; break; fi
|
|
sleep 0.25
|
|
done
|
|
fi
|
|
if [[ "$worker_ready" != true ]]; then
|
|
docker rm -f foundry-codex-worker >/dev/null 2>&1 || true
|
|
if [[ -n "$worker_backup" ]]; then
|
|
docker rename "$worker_backup" foundry-codex-worker
|
|
docker start foundry-codex-worker >/dev/null
|
|
fi
|
|
printf 'Canvas worker startup/catalog verification failed; previous worker restored.\n' >&2
|
|
exit 1
|
|
fi
|
|
if [[ -n "$worker_backup" ]]; then docker rm "$worker_backup" >/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[@]}" "${mail_env[@]}" "${slack_env[@]}" "${research_env[@]}" --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 -e FOUNDRY_ONBOARDING \
|
|
--user 1000:1000 --cap-drop ALL --security-opt no-new-privileges \
|
|
--read-only --memory 1g \
|
|
--mount "type=bind,source=$ca_bundle,target=/etc/ssl/certs/ca-certificates.crt,readonly" \
|
|
-e SSL_CERT_FILE=/etc/ssl/certs/ca-certificates.crt \
|
|
--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");
|
|
const fs = require("node:fs");
|
|
async function verify() {
|
|
assert.ok(fs.readFileSync(process.env.SSL_CERT_FILE, "utf8").includes("-----BEGIN CERTIFICATE-----"));
|
|
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'
|