fix(staging): make compose guard host-portable

This commit is contained in:
-Puter
2026-07-13 05:12:20 +05:30
parent d2e3f49519
commit 9cb1027f4c
4 changed files with 75 additions and 86 deletions
+4 -4
View File
@@ -23,10 +23,10 @@
"db:generate": "drizzle-kit generate",
"db:migrate": "tsx src/db/migrate.ts",
"db:studio": "drizzle-kit studio",
"compose:up": "node scripts/staging-compose.mjs up",
"compose:down": "node scripts/staging-compose.mjs down",
"staging:preflight": "node scripts/staging-compose.mjs preflight",
"staging:verify": "node scripts/staging-compose.mjs verify",
"compose:up": "scripts/staging-compose.sh up",
"compose:down": "scripts/staging-compose.sh down",
"staging:preflight": "scripts/staging-compose.sh preflight",
"staging:verify": "scripts/staging-compose.sh verify",
"docker:opencode:build": "docker build --build-arg GROWQR_IMAGE_VERSION=${OPENCODE_IMAGE_VERSION:-dev} --build-arg GROWQR_PROMPT_VERSION=${PROMPT_VERSION:-4} --build-arg GROWQR_MIGRATION_VERSION=${MIGRATION_VERSION:-1} -f docker/opencode/Dockerfile -t growqr/opencode:dev ."
},
"dependencies": {
+7 -7
View File
@@ -73,17 +73,17 @@ for (const obsoleteName of [
}
const stagingCompose = readFileSync(fileURLToPath(new URL("./staging-compose.mjs", import.meta.url)), "utf8");
assert.match(stagingCompose, /const PROJECT = "growqr-backend-staging"/, "staging wrapper must pin the compose project name");
const stagingCompose = readFileSync(fileURLToPath(new URL("./staging-compose.sh", import.meta.url)), "utf8");
assert.match(stagingCompose, /PROJECT=growqr-backend-staging/, "staging wrapper must pin the compose project name");
for (const [, name] of canonicalServiceNetworks) {
assert.match(stagingCompose, new RegExp(`"${name.replace(/[.*+?^${}()|[\\]\\]/g, "\\$&")}"`), `staging wrapper must verify ${name}`);
assert.match(stagingCompose, new RegExp(name.replace(/[.*+?^${}()|[\\]\\]/g, "\\$&")), `staging wrapper must verify ${name}`);
}
const packageJson = JSON.parse(readFileSync(fileURLToPath(new URL("../package.json", import.meta.url)), "utf8")) as {
scripts?: Record<string, string>;
};
assert.equal(packageJson.scripts?.["compose:up"], "node scripts/staging-compose.mjs up");
assert.equal(packageJson.scripts?.["compose:down"], "node scripts/staging-compose.mjs down");
assert.equal(packageJson.scripts?.["staging:preflight"], "node scripts/staging-compose.mjs preflight");
assert.equal(packageJson.scripts?.["staging:verify"], "node scripts/staging-compose.mjs verify");
assert.equal(packageJson.scripts?.["compose:up"], "scripts/staging-compose.sh up");
assert.equal(packageJson.scripts?.["compose:down"], "scripts/staging-compose.sh down");
assert.equal(packageJson.scripts?.["staging:preflight"], "scripts/staging-compose.sh preflight");
assert.equal(packageJson.scripts?.["staging:verify"], "scripts/staging-compose.sh verify");
console.log("onboarding-rev10-read: fixture serialization and staging network assertions passed");
-75
View File
@@ -1,75 +0,0 @@
#!/usr/bin/env node
import { spawnSync } from "node:child_process";
const PROJECT = "growqr-backend-staging";
const FILES = ["-f", "docker-compose.yml", "-f", "docker-compose.override.yml"];
const REQUIRED_NETWORKS = [
"growqr-backend-staging_default",
"interview-service_default",
"roleplay-service_default",
"resume-builder-staging_default",
"course-service_default",
"qscore-service-staging_default",
"matchmaking-v2_default",
"user-service-staging_default",
];
const SERVICE_CHECKS = [
["user-service", "http://growqr_user_service:8003/health", [200]],
["qscore", "http://qscore-service-staging-api-1:8000/health", [200]],
["course", "http://growqr-course-service:8010/health", [200]],
["resume", "http://growqr_resume_api:8000/health", [200]],
["interview", "http://interview-service-api-1:8000/health", [200]],
["roleplay", "http://roleplay-service-api-1:8000/health", [200]],
];
function run(command, args, options = {}) {
const result = spawnSync(command, args, { encoding: "utf8", stdio: options.capture ? "pipe" : "inherit" });
if (result.status !== 0) {
const detail = options.capture ? `${result.stdout ?? ""}${result.stderr ?? ""}`.trim() : "";
throw new Error(`${command} ${args.join(" ")} failed${detail ? `: ${detail}` : ""}`);
}
return options.capture ? (result.stdout ?? "").trim() : "";
}
function compose(args) {
return run("docker", ["compose", "-p", PROJECT, ...FILES, ...args]);
}
function preflight() {
for (const network of REQUIRED_NETWORKS.slice(1)) {
run("docker", ["network", "inspect", network], { capture: true });
}
compose(["config", "--quiet"]);
}
function verify() {
const networks = JSON.parse(run("docker", ["inspect", "growqr-backend", "--format", "{{json .NetworkSettings.Networks}}"], { capture: true }));
const actual = new Set(Object.keys(networks));
const missing = REQUIRED_NETWORKS.filter((network) => !actual.has(network));
const unexpected = [...actual].filter((network) => !REQUIRED_NETWORKS.includes(network));
if (missing.length || unexpected.length) {
throw new Error(`backend network mismatch; missing=[${missing.join(", ")}], unexpected=[${unexpected.join(", ")}]`);
}
const script = `const checks=${JSON.stringify(SERVICE_CHECKS)};let failed=false;for(const [name,url,ok] of checks){try{const response=await fetch(url,{signal:AbortSignal.timeout(3000)});console.log(name,response.status);if(!ok.includes(response.status))failed=true}catch(error){console.error(name,"ERROR",error.message);failed=true}}if(failed)process.exit(1);`;
run("docker", ["exec", "growqr-backend", "node", "--input-type=module", "-e", script]);
run("docker", ["exec", "growqr-backend", "node", "--input-type=module", "-e", "const r=await fetch('http://127.0.0.1:4000/healthz',{signal:AbortSignal.timeout(3000)});console.log('backend',r.status);if(r.status!==200)process.exit(1)"]);
}
const [action, ...args] = process.argv.slice(2);
try {
if (action === "preflight") preflight();
else if (action === "verify") verify();
else if (action === "up") {
preflight();
compose(["up", "-d", ...args]);
verify();
} else if (action === "down") compose(["down", ...args]);
else {
console.error("Usage: node scripts/staging-compose.mjs <preflight|verify|up|down> [compose args]");
process.exit(2);
}
} catch (error) {
console.error(error instanceof Error ? error.message : String(error));
process.exit(1);
}
+64
View File
@@ -0,0 +1,64 @@
#!/bin/sh
set -eu
PROJECT=growqr-backend-staging
COMPOSE="docker compose -p $PROJECT -f docker-compose.yml -f docker-compose.override.yml"
REQUIRED_NETWORKS="growqr-backend-staging_default interview-service_default roleplay-service_default resume-builder-staging_default course-service_default qscore-service-staging_default matchmaking-v2_default user-service-staging_default"
EXTERNAL_NETWORKS="interview-service_default roleplay-service_default resume-builder-staging_default course-service_default qscore-service-staging_default matchmaking-v2_default user-service-staging_default"
preflight() {
for network in $EXTERNAL_NETWORKS; do
docker network inspect "$network" >/dev/null
done
$COMPOSE config --quiet
}
verify() {
actual=$(docker inspect growqr-backend --format '{{range $name, $_ := .NetworkSettings.Networks}}{{$name}} {{end}}')
for network in $REQUIRED_NETWORKS; do
case " $actual " in
*" $network "*) ;;
*) echo "missing backend network: $network" >&2; exit 1 ;;
esac
done
for network in $actual; do
case " $REQUIRED_NETWORKS " in
*" $network "*) ;;
*) echo "unexpected backend network: $network" >&2; exit 1 ;;
esac
done
docker exec growqr-backend node --input-type=module -e '
const checks = [
["user-service", "http://growqr_user_service:8003/health"],
["qscore", "http://qscore-service-staging-api-1:8000/health"],
["course", "http://growqr-course-service:8010/health"],
["resume", "http://growqr_resume_api:8000/health"],
["interview", "http://interview-service-api-1:8000/health"],
["roleplay", "http://roleplay-service-api-1:8000/health"],
];
let failed = false;
for (const [name, url] of checks) {
try {
const response = await fetch(url, { signal: AbortSignal.timeout(3000) });
console.log(name, response.status);
if (response.status !== 200) failed = true;
} catch (error) {
console.error(name, "ERROR", error.message);
failed = true;
}
}
if (failed) process.exit(1);
'
docker exec growqr-backend node --input-type=module -e "const response=await fetch('http://127.0.0.1:4000/healthz',{signal:AbortSignal.timeout(3000)});console.log('backend',response.status);if(response.status!==200)process.exit(1)"
}
action=${1:-}
[ "$#" -gt 0 ] && shift
case "$action" in
preflight) preflight ;;
verify) verify ;;
up) preflight; $COMPOSE up -d "$@"; verify ;;
down) $COMPOSE down "$@" ;;
*) echo "Usage: scripts/staging-compose.sh <preflight|verify|up|down> [compose args]" >&2; exit 2 ;;
esac