singularity-forge/scripts/run-vega-source-server.mjs
Mikael Hugo 8c945550fa
Some checks are pending
sf self-deploy / build, test, and publish server image (push) Waiting to run
sf self-deploy / deploy test and probe (push) Blocked by required conditions
sf self-deploy / promote prod (push) Blocked by required conditions
feat: operational glue for upgrade-safety chain
Bundles the working-tree state into one coherent commit covering the
upgrade-safety glue that complements today's earlier landings
(orphan-recovery, sf-db single-connection, drain-timer-not-unref'd,
forceShutdown drain, shutdown-state.ts, instrumentation.ts,
shutdown-signal.js, gate-deadlock-classifier).

Modified:
  docker/Dockerfile.source-server — image build tweaks for the source-
    server variant used by the in-container upgrader.
  docker/docker-compose.vega.yaml — env passthroughs for host-side dirs
    (SF_SOURCE_HOST_ROOT, SF_WORKSPACE_HOST_DIR, SF_WORKSPACES_HOST_DIR,
    SF_HOME_HOST_DIR), docker socket mount, group_add for docker GID,
    and SF_RPC_SHUTDOWN_GRACE_MS=600000 matching the 10-min drain.
  scripts/run-vega-source-server.mjs — substantial rework supporting
    the in-container upgrade flow.
  scripts/upgrade-vega-source-server.mjs — buildEnv() + dockerBuildEnv()
    helpers, probeBind via SF_VEGA_PROBE_HOST, containerExists()
    pre-check before drainContainer, stop timeout now matches the
    10-min RPC grace via SF_VEGA_DRAIN_STOP_TIME (default 610s).
  src/web/project-discovery-service.ts — calls
    recoverProjectRuntimeQueues() on each of the 3 discovery paths
    (root monorepo, per-entry, nested SF projects). Closes the
    cloud-volume mtime-lag window codex flagged.
  web/app/api/ready/route.ts — calls recoverProjectRuntimeQueues() on
    every readiness probe, and now also reads shutdown-state so the
    probe returns 503 while draining.
  web/components/sf/projects-view.tsx — UI wiring for the upgrade
    trigger.
  web/pages/api/projects.ts — backend API addition for the project
    enumeration that feeds projects-view.
  docs/specs/sf-self-deploy.md — docs update for the new flow.
  package.json — script alias.

Added:
  scripts/build-web-host.mjs — new build helper for the standalone web
    host artifact consumed by the upgrade flow.
  src/resources/extensions/sf/tests/auto-shutdown-signal.test.mjs —
    unit test for the cooperative-shutdown signal module (registers /
    requests / snapshot).
  src/web/project-runtime-recovery.ts — thin wrapper around
    recoverOrphanedFeedbackDrains for per-project use from web routes.
  web/app/api/drain/route.ts — explicit drain endpoint for operator-
    triggered queue flush.
  web/app/api/server-upgrade/route.ts — auth-gated endpoint that
    spawns the in-container upgrader via docker socket; passes through
    host-dir env so the upgrader knows real bind-mount paths.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-17 22:57:26 +02:00

268 lines
6.8 KiB
JavaScript

#!/usr/bin/env node
/**
* run-vega-source-server.mjs — start the source-mounted SF server container on
* vega without requiring Docker Compose.
*
* Purpose: keep the local production server containerized while using the live
* checkout at /home/mhugo/code/singularity-forge as the source of truth.
*
* Consumer: `npm run docker:vega:up` on vega.
*/
import { spawnSync } from "node:child_process";
import { statSync } from "node:fs";
import { homedir } from "node:os";
import { dirname, resolve } from "node:path";
import { fileURLToPath } from "node:url";
const root = resolve(fileURLToPath(new URL("..", import.meta.url)));
const image = process.env.SF_VEGA_IMAGE || "sf-source-server:vega";
const bind = process.env.SF_VEGA_BIND || "127.0.0.1";
const workspace = resolve(process.env.SF_WORKSPACE_DIR || root);
const workspacesRoot = resolve(process.env.SF_WORKSPACES_DIR || dirname(root));
const sourceHostRoot = resolve(process.env.SF_SOURCE_HOST_ROOT || root);
const workspaceHost = resolve(process.env.SF_WORKSPACE_HOST_DIR || workspace);
const workspacesHost = resolve(
process.env.SF_WORKSPACES_HOST_DIR || workspacesRoot,
);
const sfHomeHost = resolve(process.env.SF_HOME_HOST_DIR || `${homedir()}/.sf`);
const name = process.env.SF_VEGA_CONTAINER || "sf-server-vega";
const port = process.env.SF_VEGA_PORT || "4000";
const uid = process.env.PUID || String(process.getuid?.() ?? 1000);
const gid = process.env.PGID || String(process.getgid?.() ?? 1000);
const dockerSocketGid = socketGroupId("/var/run/docker.sock");
const command = process.argv[2] ?? "up";
const skipImageBuild = process.env.SF_VEGA_SKIP_IMAGE_BUILD === "1";
if (command === "--help" || command === "-h" || command === "help") {
process.stdout.write(`Usage:
npm run docker:vega:up
SF_WORKSPACE_DIR=/path/to/repo npm run docker:vega:up
node scripts/run-vega-source-server.mjs print
node scripts/run-vega-source-server.mjs logs
node scripts/run-vega-source-server.mjs down
Environment:
SF_WORKSPACE_DIR initial repo mounted at /workspace, defaults to this checkout
SF_WORKSPACES_DIR repo parent mounted at /workspaces, defaults to ../
SF_VEGA_BIND host bind address, defaults to 127.0.0.1
SF_VEGA_PORT shared webserver port, defaults to 4000
SF_VEGA_CONTAINER explicit container name
`);
process.exit(0);
}
if (command === "print") {
process.stdout.write(
JSON.stringify(
{
name,
image,
bind,
port,
workspace,
workspacesRoot,
sourceHostRoot,
workspaceHost,
workspacesHost,
sfHomeHost,
sfSource: root,
},
null,
2,
) + "\n",
);
process.exit(0);
}
if (command === "logs") {
run("docker", ["logs", "-f", "--tail=200", name]);
process.exit(0);
}
if (command === "down") {
await requestDrain(port);
drainContainer(name);
process.exit(0);
}
if (command !== "up") {
process.stderr.write(`Unknown command: ${command}\n`);
process.exit(2);
}
const allowedOrigins =
process.env.SF_WEB_ALLOWED_ORIGINS ||
`http://127.0.0.1:${port},http://localhost:${port}`;
if (!skipImageBuild) {
run(
"docker",
["build", "-f", "docker/Dockerfile.source-server", "-t", image, "."],
{ env: dockerBuildEnv() },
);
}
await requestDrain(port);
drainContainer(name);
run("docker", [
"run",
"-d",
"--name",
name,
"--restart",
"unless-stopped",
"--user",
`${uid}:${gid}`,
...(dockerSocketGid ? ["--group-add", dockerSocketGid] : []),
"-p",
`${bind}:${port}:4000`,
"-e",
"HOME=/home/node",
"-e",
"NODE_ENV=development",
"-e",
"SF_SOURCE_ROOT=/opt/sf",
"-e",
"SF_RUNTIME_SOURCE_ROOT=/opt/sf",
"-e",
"SF_RELEASE_MANIFEST=/opt/sf/dist/sf-release-manifest.json",
"-e",
`SF_WEB_PROJECT_CWD=${workspace}`,
"-e",
`SF_WORKSPACES_DIR=${workspacesRoot}`,
"-e",
`SF_SOURCE_HOST_ROOT=${sourceHostRoot}`,
"-e",
`SF_WORKSPACE_HOST_DIR=${workspaceHost}`,
"-e",
`SF_WORKSPACES_HOST_DIR=${workspacesHost}`,
"-e",
`SF_HOME_HOST_DIR=${sfHomeHost}`,
"-e",
"HOSTNAME=0.0.0.0",
"-e",
"PORT=4000",
"-e",
"SF_WEB_HOST=0.0.0.0",
"-e",
"SF_WEB_PORT=4000",
"-e",
`SF_WEB_ALLOWED_ORIGINS=${allowedOrigins}`,
"-e",
"SF_DEV_SERVER_WATCH=1",
"-e",
"SF_RPC_SHUTDOWN_GRACE_MS=600000",
"-v",
`${sourceHostRoot}:/opt/sf`,
"-v",
`${workspaceHost}:/workspace`,
"-v",
`${workspacesHost}:/workspaces`,
"-v",
`${workspacesHost}:${workspacesRoot}`,
"-v",
`${sfHomeHost}:/home/node/.sf`,
"-v",
`${homedir()}/.gitconfig:/home/node/.gitconfig:ro`,
"-v",
"/var/run/docker.sock:/var/run/docker.sock",
image,
"node",
"/opt/sf/dist/web/standalone/server.js",
]);
process.stdout.write(`${name} listening on ${bind}:${port}\n`);
process.stdout.write(`SF source: ${root}\n`);
process.stdout.write(`Initial workspace: ${workspace}\n`);
process.stdout.write(`Workspace parent: ${workspacesRoot}\n`);
function run(command, args, options = {}) {
const result = spawnSync(command, args, {
cwd: root,
stdio: "inherit",
env: options.env ?? process.env,
});
if (result.status !== 0 && !options.allowFailure) {
process.exit(result.status ?? 1);
}
}
function dockerBuildEnv() {
return {
...process.env,
DOCKER_BUILDKIT: "1",
BUILDKIT_PROGRESS: process.env.BUILDKIT_PROGRESS || "plain",
DEBIAN_FRONTEND: "noninteractive",
};
}
function socketGroupId(path) {
try {
return String(statSync(path).gid);
} catch {
return null;
}
}
function drainContainer(containerName) {
if (!containerExists(containerName)) return;
const stopTime = process.env.SF_VEGA_DRAIN_STOP_TIME || "610";
run("docker", ["stop", "--timeout", stopTime, containerName], {
allowFailure: true,
});
run("docker", ["rm", "-f", containerName], { allowFailure: true });
}
async function requestDrain(targetPort) {
if (!containerExists(name)) return;
const baseUrl = `http://${bind}:${targetPort}`;
try {
const response = await fetch(`${baseUrl}/api/drain`, {
method: "POST",
headers: authHeaders(),
});
if (!response.ok && response.status !== 404) {
throw new Error(`drain returned ${response.status}`);
}
if (response.ok) {
await waitForDrainHealthz(baseUrl);
}
} catch (error) {
process.stdout.write(
`drain preflight skipped: ${
error instanceof Error ? error.message : String(error)
}\n`,
);
}
}
async function waitForDrainHealthz(baseUrl) {
const deadline = Date.now() + 10_000;
while (Date.now() < deadline) {
try {
const response = await fetch(`${baseUrl}/api/healthz`, {
cache: "no-store",
headers: authHeaders(),
});
if (response.status === 503) return;
} catch {
return;
}
await new Promise((resolveDelay) => setTimeout(resolveDelay, 250));
}
}
function authHeaders() {
const token = process.env.SF_WEB_AUTH_TOKEN;
return token ? { Authorization: `Bearer ${token}` } : {};
}
function containerExists(containerName) {
const result = spawnSync("docker", ["container", "inspect", containerName], {
cwd: root,
stdio: "ignore",
env: process.env,
});
return result.status === 0;
}