diff --git a/docker/Dockerfile.source-server b/docker/Dockerfile.source-server deleted file mode 100644 index 213c7314d..000000000 --- a/docker/Dockerfile.source-server +++ /dev/null @@ -1,28 +0,0 @@ -# syntax=docker/dockerfile:1.7 -# -# Local source-mounted SF server image. -# -# Purpose: run the vega development/production server inside a container while -# keeping /home/mhugo/code/singularity-forge as the source of truth. -# -# Consumer: docker/docker-compose.vega.yaml. - -FROM node:26.1-slim - -ENV NODE_ENV=development -ENV HOME=/home/node -ENV SF_WEB_PREFER_SOURCE=0 - -RUN apt-get update && apt-get install -y --no-install-recommends \ - ca-certificates \ - docker-buildx \ - docker-cli \ - git \ - libsecret-1-0 \ - tini \ - && rm -rf /var/lib/apt/lists/* - -WORKDIR /opt/sf -EXPOSE 4000 -ENTRYPOINT ["tini", "--"] -CMD ["npm", "run", "sf:server", "--", "--host", "0.0.0.0", "--port", "4000"] diff --git a/docker/docker-compose.vega.yaml b/docker/docker-compose.vega.yaml deleted file mode 100644 index fc1692497..000000000 --- a/docker/docker-compose.vega.yaml +++ /dev/null @@ -1,51 +0,0 @@ -services: - sf-server: - build: - context: .. - dockerfile: docker/Dockerfile.source-server - container_name: ${SF_VEGA_CONTAINER:-sf-server-vega} - working_dir: /opt/sf - user: "${PUID:-1000}:${PGID:-1000}" - group_add: - - "${DOCKER_GID:-999}" - ports: - - "${SF_VEGA_BIND:-127.0.0.1}:4000:4000" - volumes: - - ../:/opt/sf - - ${SF_WORKSPACE_DIR:-..}:/workspace - - ${SF_WORKSPACES_DIR:-..}:/workspaces - - ${SF_WORKSPACES_DIR:-/home/mhugo/code}:${SF_WORKSPACES_DIR:-/home/mhugo/code} - - ${HOME}/.sf:/home/node/.sf - - ${HOME}/.gitconfig:/home/node/.gitconfig:ro - - /var/run/docker.sock:/var/run/docker.sock - environment: - HOME: /home/node - NODE_ENV: development - SF_SOURCE_ROOT: /opt/sf - SF_RUNTIME_SOURCE_ROOT: /opt/sf - SF_RELEASE_MANIFEST: /opt/sf/dist/sf-release-manifest.json - SF_WEB_PROJECT_CWD: ${SF_WORKSPACE_DIR:-/home/mhugo/code/singularity-forge} - SF_WORKSPACES_DIR: ${SF_WORKSPACES_DIR:-/home/mhugo/code} - SF_SOURCE_HOST_ROOT: ${SF_SOURCE_HOST_ROOT:-/home/mhugo/code/singularity-forge} - SF_WORKSPACE_HOST_DIR: ${SF_WORKSPACE_HOST_DIR:-/home/mhugo/code/singularity-forge} - SF_WORKSPACES_HOST_DIR: ${SF_WORKSPACES_HOST_DIR:-/home/mhugo/code} - SF_HOME_HOST_DIR: ${SF_HOME_HOST_DIR:-/home/mhugo/.sf} - SF_WEB_HOST: 0.0.0.0 - SF_WEB_PORT: "4000" - HOSTNAME: 0.0.0.0 - PORT: "4000" - SF_WEB_ALLOWED_ORIGINS: ${SF_WEB_ALLOWED_ORIGINS:-http://127.0.0.1:4000,http://localhost:4000} - SF_DEV_SERVER_WATCH: "1" - SF_RPC_SHUTDOWN_GRACE_MS: "600000" - command: - - node - - /opt/sf/dist/web/standalone/server.js - restart: unless-stopped - healthcheck: - test: - - CMD-SHELL - - node -e "fetch('http://127.0.0.1:4000/api/healthz').then(r=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))" - interval: 15s - timeout: 5s - retries: 12 - start_period: 60s diff --git a/scripts/run-vega-source-server.mjs b/scripts/run-vega-source-server.mjs deleted file mode 100644 index 3fa6a82a6..000000000 --- a/scripts/run-vega-source-server.mjs +++ /dev/null @@ -1,268 +0,0 @@ -#!/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", "-t", 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; -} diff --git a/scripts/upgrade-vega-source-server.mjs b/scripts/upgrade-vega-source-server.mjs deleted file mode 100644 index 715c7f760..000000000 --- a/scripts/upgrade-vega-source-server.mjs +++ /dev/null @@ -1,230 +0,0 @@ -#!/usr/bin/env node -/** - * upgrade-vega-source-server.mjs — blue/green upgrade the shared vega SF - * webserver. - * - * Purpose: prove a candidate source-mounted server on a side port before - * replacing the shared production container on port 4000. - * - * Consumer: `npm run docker:vega:upgrade` locally and Forgejo/host-side deploy - * automation when vega is the target. - */ -import { spawnSync } from "node:child_process"; -import { dirname, resolve } from "node:path"; -import { fileURLToPath } from "node:url"; - -const root = resolve(fileURLToPath(new URL("..", import.meta.url))); -const bind = process.env.SF_VEGA_BIND || "127.0.0.1"; -const prodName = process.env.SF_VEGA_CONTAINER || "sf-server-vega"; -const candidateName = - process.env.SF_VEGA_CANDIDATE_CONTAINER || "sf-server-vega-candidate"; -const prodPort = process.env.SF_VEGA_PORT || "4000"; -const candidatePort = process.env.SF_VEGA_CANDIDATE_PORT || "4001"; -const workspacesRoot = process.env.SF_WORKSPACES_DIR || dirname(root); -const skipBuild = process.env.SF_VEGA_UPGRADE_SKIP_BUILD === "1"; -const probeBind = process.env.SF_VEGA_PROBE_HOST || bind; - -if (!skipBuild) { - run("npm", ["run", "build:web-host"], { env: buildEnv() }); - run(process.execPath, [ - "scripts/generate-release-manifest.mjs", - "--out", - "dist/sf-release-manifest.json", - ]); -} -run( - "docker", - [ - "build", - "-f", - "docker/Dockerfile.source-server", - "-t", - process.env.SF_VEGA_IMAGE || "sf-source-server:vega", - ".", - ], - { env: dockerBuildEnv() }, -); - -startServer(candidateName, candidatePort); -await probeServer(candidatePort, "candidate"); - -await requestDrain(prodPort, "prod"); -drainContainer(prodName); -startServer(prodName, prodPort); -await probeServer(prodPort, "prod"); - -await requestDrain(candidatePort, "candidate"); -drainContainer(candidateName); -process.stdout.write( - `sf server upgraded: ${prodName} is healthy on ${bind}:${prodPort}\n`, -); - -function startServer(name, port) { - run("node", ["scripts/run-vega-source-server.mjs", "up"], { - env: { - ...process.env, - SF_VEGA_CONTAINER: name, - SF_VEGA_PORT: port, - SF_VEGA_SKIP_IMAGE_BUILD: "1", - }, - }); -} - -async function probeServer(port, label) { - const baseUrl = `http://${probeBind}:${port}`; - const checks = [ - ["healthz", `${baseUrl}/api/healthz`], - ["ready", `${baseUrl}/api/ready`], - ["version", `${baseUrl}/api/version`], - [ - "projects", - `${baseUrl}/api/projects?root=${encodeURIComponent(workspacesRoot)}&detail=true`, - ], - ]; - const deadline = Date.now() + 60_000; - let lastError = ""; - while (Date.now() < deadline) { - try { - for (const [name, url] of checks) { - const response = await fetch(url, { cache: "no-store" }); - if (!response.ok) { - throw new Error(`${name} returned ${response.status}`); - } - if (name === "projects") { - const projects = await response.json(); - if (!Array.isArray(projects) || projects.length === 0) { - throw new Error("projects returned no configured repos"); - } - } else { - await response.arrayBuffer(); - } - } - process.stdout.write(`${label} probes passed on ${baseUrl}\n`); - return; - } catch (error) { - lastError = error instanceof Error ? error.message : String(error); - await delay(1000); - } - } - showLogs(label === "candidate" ? candidateName : prodName); - throw new Error(`${label} probes failed: ${lastError}`); -} - -async function requestDrain(port, label) { - const baseUrl = `http://${probeBind}:${port}`; - 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(port, label); - } - } catch (error) { - process.stdout.write( - `${label} drain preflight skipped: ${ - error instanceof Error ? error.message : String(error) - }\n`, - ); - } -} - -async function waitForDrainHealthz(port, label) { - const baseUrl = `http://${probeBind}:${port}`; - const deadline = Date.now() + 10_000; - let lastStatus = "unknown"; - while (Date.now() < deadline) { - try { - const response = await fetch(`${baseUrl}/api/healthz`, { - cache: "no-store", - headers: authHeaders(), - }); - lastStatus = String(response.status); - if (response.status === 503) { - process.stdout.write(`${label} drain acknowledged on ${baseUrl}\n`); - return; - } - } catch (error) { - lastStatus = error instanceof Error ? error.message : String(error); - } - await delay(250); - } - process.stdout.write( - `${label} drain did not surface on healthz before stop (last=${lastStatus})\n`, - ); -} - -function authHeaders() { - const token = process.env.SF_WEB_AUTH_TOKEN; - return token ? { Authorization: `Bearer ${token}` } : {}; -} - -function showLogs(name) { - spawnSync("docker", ["logs", "--tail=120", name], { - cwd: root, - stdio: "inherit", - env: process.env, - }); -} - -function buildEnv() { - const nodeOptions = [process.env.NODE_OPTIONS, "--disable-warning=DEP0205"] - .filter(Boolean) - .join(" "); - return { - ...process.env, - NODE_ENV: "production", - NEXT_TELEMETRY_DISABLED: "1", - NODE_OPTIONS: nodeOptions, - NPM_CONFIG_UPDATE_NOTIFIER: "false", - npm_config_update_notifier: "false", - }; -} - -function dockerBuildEnv() { - return { - ...process.env, - DOCKER_BUILDKIT: "1", - BUILDKIT_PROGRESS: process.env.BUILDKIT_PROGRESS || "plain", - DEBIAN_FRONTEND: "noninteractive", - }; -} - -function drainContainer(name) { - if (!containerExists(name)) return; - // 610s: matches SF_RPC_SHUTDOWN_GRACE_MS=600000 in rpc-mode's - // graceful-shutdown handler with a 10s safety margin for Node exit. - // Normal drains finish in <1s; the long ceiling is for pathological - // lock contention so queued self-feedback writes are never lost - // across an upgrade. Override per-deployment via env if needed. - const stopTime = process.env.SF_VEGA_DRAIN_STOP_TIME || "610"; - run("docker", ["stop", "-t", stopTime, name], { allowFailure: true }); - run("docker", ["rm", "-f", name], { allowFailure: true }); -} - -function containerExists(name) { - const result = spawnSync("docker", ["container", "inspect", name], { - cwd: root, - stdio: "ignore", - env: process.env, - }); - return result.status === 0; -} - -function delay(ms) { - return new Promise((resolveDelay) => setTimeout(resolveDelay, ms)); -} - -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); - } -} diff --git a/web/app/api/server-upgrade/route.ts b/web/app/api/server-upgrade/route.ts deleted file mode 100644 index 02ee60e26..000000000 --- a/web/app/api/server-upgrade/route.ts +++ /dev/null @@ -1,107 +0,0 @@ -import { spawnSync } from "node:child_process"; -import { randomUUID } from "node:crypto"; -import { statSync } from "node:fs"; -import { getgid, getuid } from "node:process"; - -import { verifyAuthToken } from "../../../lib/auth-guard"; - -export const runtime = "nodejs"; -export const dynamic = "force-dynamic"; - -export async function POST(request: Request): Promise { - const authError = verifyAuthToken(request); - if (authError) return authError; - - const sourceHostRoot = - process.env.SF_SOURCE_HOST_ROOT ?? "/home/mhugo/code/singularity-forge"; - const workspaceHost = - process.env.SF_WORKSPACE_HOST_DIR ?? - process.env.SF_WEB_PROJECT_CWD ?? - sourceHostRoot; - const workspacesHost = - process.env.SF_WORKSPACES_HOST_DIR ?? "/home/mhugo/code"; - const sfHomeHost = process.env.SF_HOME_HOST_DIR ?? "/home/mhugo/.sf"; - const image = process.env.SF_VEGA_IMAGE ?? "sf-source-server:vega"; - const name = `sf-server-vega-upgrader-${randomUUID().slice(0, 8)}`; - const uid = process.env.PUID ?? String(getuid?.() ?? 1000); - const gid = process.env.PGID ?? String(getgid?.() ?? 1000); - const dockerSocketGid = socketGroupId("/var/run/docker.sock"); - - const args = [ - "run", - "-d", - "--rm", - "--name", - name, - "--network", - "host", - "--user", - `${uid}:${gid}`, - ...(dockerSocketGid ? ["--group-add", dockerSocketGid] : []), - "-v", - `${sourceHostRoot}:/opt/sf`, - "-v", - `${workspaceHost}:/workspace`, - "-v", - `${workspacesHost}:/workspaces`, - "-v", - `${workspacesHost}:${workspacesHost}`, - "-v", - `${sfHomeHost}:/home/node/.sf`, - "-v", - "/var/run/docker.sock:/var/run/docker.sock", - "-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", - `SF_WORKSPACE_DIR=${workspaceHost}`, - "-e", - `SF_WORKSPACES_DIR=${workspacesHost}`, - "-e", - "SF_VEGA_PORT=4000", - "-e", - "SF_VEGA_CANDIDATE_PORT=4001", - "-e", - "SF_VEGA_PROBE_HOST=127.0.0.1", - "-e", - "DOCKER_BUILDKIT=1", - "-e", - "BUILDKIT_PROGRESS=plain", - image, - "node", - "/opt/sf/scripts/upgrade-vega-source-server.mjs", - ]; - - try { - const result = spawnSync("docker", args, { - cwd: "/opt/sf", - encoding: "utf8", - stdio: ["ignore", "pipe", "pipe"], - }); - if (result.status !== 0) { - throw new Error(result.stderr || result.stdout || "docker run failed"); - } - return Response.json( - { triggered: true, upgrader: name, containerId: result.stdout.trim() }, - { status: 202, headers: { "Cache-Control": "no-store" } }, - ); - } catch (error) { - return Response.json( - { error: error instanceof Error ? error.message : String(error) }, - { status: 500, headers: { "Cache-Control": "no-store" } }, - ); - } -} - -function socketGroupId(path: string): string | null { - try { - return String(statSync(path).gid); - } catch { - return null; - } -}