#!/usr/bin/env bash set -euo pipefail cd "$(dirname "$0")/.." tmp=$(mktemp -d) trap 'rm -rf "$tmp"' EXIT HUP INT TERM render_profile() { local profile=$1 local env_file=$2 local compose_file=$3 local rendered="$tmp/$profile.json" docker compose --env-file "$env_file" -f compose.yaml -f "$compose_file" \ config --format json >"$rendered" node - "$rendered" "$profile" <<'NODE' const fs = require("fs"); const [configPath, profile] = process.argv.slice(2); const config = JSON.parse(fs.readFileSync(configPath, "utf8")); const services = Object.keys(config.services).sort(); if (services.join(",") !== "core,frontend") throw new Error("mandatory stack must be core,frontend"); if (/omics_portal|chirone|localllm_default|\/home\/chirone/i.test(JSON.stringify(config))) { throw new Error("forbidden application coupling"); } if (!config.networks || !config.networks.thothii) throw new Error("base stack must define the thothii network"); if (!Object.hasOwn(config.services.core.environment || {}, "THT_LLM_URL")) { throw new Error("core must expose a generic THT_LLM_URL endpoint contract"); } if (/docker\.sock|\/var\/run\/docker|docker[-_]?daemon/i.test(JSON.stringify(config.services))) { throw new Error("Compose must not mount the Docker socket or daemon"); } const piAuthMounts = (config.services.core.volumes || []).filter( (mount) => mount.target === "/home/thoth/.pi/agent/auth.json", ); if (piAuthMounts.length !== 1 || piAuthMounts[0].type !== "bind" || !piAuthMounts[0].read_only) { throw new Error("Pi auth must be one read-only file bind"); } const ports = Object.fromEntries( Object.entries(config.services).map(([name, service]) => [name, service.ports || []]), ); if (profile === "local") { if (!ports.frontend.some((port) => port.host_ip === "127.0.0.1")) { throw new Error("local frontend must publish a loopback port"); } if (ports.core.length !== 0 && !ports.core.every((port) => port.host_ip === "127.0.0.1")) { throw new Error("local core may publish only loopback ports"); } } else { if (ports.core.length !== 0) throw new Error("server core must not publish a host port"); if (ports.frontend.length === 0) throw new Error("server frontend must publish a host port"); } NODE } assert_remote_required() { local env_file=$1 local compose_file=$2 local without_remote="$tmp/without-remote.env" grep -v '^THT_WORKSPACE_GIT_REMOTE=' "$env_file" >"$without_remote" if env -u THT_WORKSPACE_GIT_REMOTE docker compose --env-file "$without_remote" -f compose.yaml -f "$compose_file" \ config --format json >"$tmp/missing-remote.out" 2>"$tmp/missing-remote.err"; then echo "Compose must require THT_WORKSPACE_GIT_REMOTE" >&2 exit 1 fi grep -q 'THT_WORKSPACE_GIT_REMOTE' "$tmp/missing-remote.err" } THT_WORKSPACE_GIT_REMOTE=https://git.example.invalid/platform/thoth-workspaces.git \ PI_AUTH_FILE=/dev/null \ docker compose -f compose.yaml config --format json >"$tmp/base.json" node - "$tmp/base.json" <<'NODE' const fs = require("fs"); const config = JSON.parse(fs.readFileSync(process.argv[2], "utf8")); const services = Object.keys(config.services).sort(); if (services.join(",") !== "core,frontend") throw new Error("mandatory stack must be core,frontend"); if (/omics_portal|chirone|localllm_default|\/home\/chirone/i.test(JSON.stringify(config))) { throw new Error("forbidden application coupling"); } if (!config.networks || !config.networks.thothii) throw new Error("base stack must define the thothii network"); for (const volume of ["settings", "pi-state", "workspace-registry", "sessions"]) { if (!config.volumes || !config.volumes[volume]) throw new Error(`missing required volume: ${volume}`); } if (!Object.hasOwn(config.services.core.environment || {}, "THT_LLM_URL")) { throw new Error("core must expose a generic THT_LLM_URL endpoint contract"); } if (/docker\.sock|\/var\/run\/docker|docker[-_]?daemon/i.test(JSON.stringify(config.services))) { throw new Error("Compose must not mount the Docker socket or daemon"); } const piAuthMounts = (config.services.core.volumes || []).filter( (mount) => mount.target === "/home/thoth/.pi/agent/auth.json", ); if (piAuthMounts.length !== 1 || piAuthMounts[0].type !== "bind" || !piAuthMounts[0].read_only) { throw new Error("Pi auth must be one read-only file bind"); } NODE render_profile local deploy/env/local.env.example deploy/compose.local.yaml render_profile server deploy/env/server.env.example deploy/compose.server.yaml assert_remote_required deploy/env/local.env.example deploy/compose.local.yaml assert_remote_required deploy/env/server.env.example deploy/compose.server.yaml echo "unified Compose contract passed."