diff --git a/.github/workflows/containers.yml b/.github/workflows/containers.yml index 97d278576..a60e574dc 100644 --- a/.github/workflows/containers.yml +++ b/.github/workflows/containers.yml @@ -44,7 +44,7 @@ jobs: - amd64 - arm64 runs-on: ubuntu-latest - timeout-minutes: 50 + timeout-minutes: 120 steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: @@ -71,3 +71,9 @@ jobs: node scripts/container-image-size-budget.mjs --target "${{ matrix.target }}" --image "spaceapp-ci:${{ matrix.target }}-${{ matrix.arch }}" + + - name: Run container startup smoke check + run: >- + node deploy/docker/container-smoke-check.mjs + --target "${{ matrix.target }}" + --image "spaceapp-ci:${{ matrix.target }}-${{ matrix.arch }}" diff --git a/Dockerfile b/Dockerfile index 9100f79fe..9b81c20a5 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,6 +1,7 @@ # syntax=docker/dockerfile:1.7 FROM node:22.23.0-trixie-slim@sha256:cfd8f2a5bc50526aee08e88970979f92722828e7dcc6d8983607fb8bff4bdb82 AS build +ARG TARGETARCH WORKDIR /app RUN apt-get update \ && apt-get install -y --no-install-recommends build-essential liblzma5 python3 pkg-config \ @@ -9,7 +10,8 @@ COPY . . RUN npm ci --no-audit --no-fund \ && npm run build \ && npm prune --omit=dev \ - && find node_modules -type f \( -name '*.js.map' -o -name '*.mjs.map' -o -name '*.cjs.map' -o -name '*.css.map' \) -delete + && find node_modules -type f \( -name '*.js.map' -o -name '*.mjs.map' -o -name '*.cjs.map' -o -name '*.css.map' \) -delete \ + && node deploy/docker/prepare-container-targets.mjs --target-arch="${TARGETARCH:-amd64}" FROM node:22.23.0-trixie-slim@sha256:cfd8f2a5bc50526aee08e88970979f92722828e7dcc6d8983607fb8bff4bdb82 AS runtime-base ARG TARGETARCH @@ -54,16 +56,7 @@ RUN npm install --global --ignore-scripts --no-audit --no-fund "npm@${NPM_VERSIO && groupadd --gid 10001 spaceapp \ && useradd --uid 10001 --gid 10001 --create-home --home-dir /var/lib/spaceapp-cli --shell /bin/bash spaceapp WORKDIR /app -COPY --from=build --chown=spaceapp:spaceapp /app/package.json /app/package-lock.json ./ -COPY --from=build --chown=spaceapp:spaceapp /app/node_modules ./node_modules -COPY --from=build --chown=spaceapp:spaceapp /app/apps ./apps -COPY --from=build --chown=spaceapp:spaceapp /app/packages ./packages -COPY --from=build --chown=spaceapp:spaceapp /app/agent-skills ./agent-skills COPY --from=build --chown=spaceapp:spaceapp /app/LICENSE /app/NOTICE /app/THIRD_PARTY_NOTICES.md ./ -COPY --from=build --chown=spaceapp:spaceapp /app/scripts/reset-owner-password.mjs ./scripts/reset-owner-password.mjs -COPY --from=build --chown=spaceapp:spaceapp /app/scripts/rotate-owner-setup-token.mjs ./scripts/rotate-owner-setup-token.mjs -COPY --from=build --chown=spaceapp:spaceapp /app/scripts/portable-backup.mjs ./scripts/portable-backup.mjs -COPY --from=build --chown=spaceapp:spaceapp /app/scripts/portable-restore.mjs ./scripts/portable-restore.mjs COPY --chown=spaceapp:spaceapp deploy/docker ./deploy/docker RUN chmod 0755 deploy/docker/core-entrypoint.sh deploy/docker/browser-entrypoint.sh deploy/docker/cli-entrypoint.sh \ && install -m 0755 deploy/docker/public-cli-wrapper.sh /usr/local/bin/spaceapp-cli-wrapper \ @@ -72,6 +65,11 @@ RUN chmod 0755 deploy/docker/core-entrypoint.sh deploy/docker/browser-entrypoint done FROM runtime-base AS core +COPY --from=build --chown=spaceapp:spaceapp /app/targets/core ./ +COPY --from=build --chown=spaceapp:spaceapp /app/scripts/reset-owner-password.mjs ./scripts/reset-owner-password.mjs +COPY --from=build --chown=spaceapp:spaceapp /app/scripts/rotate-owner-setup-token.mjs ./scripts/rotate-owner-setup-token.mjs +COPY --from=build --chown=spaceapp:spaceapp /app/scripts/portable-backup.mjs ./scripts/portable-backup.mjs +COPY --from=build --chown=spaceapp:spaceapp /app/scripts/portable-restore.mjs ./scripts/portable-restore.mjs COPY --chown=spaceapp:spaceapp starter-memory /opt/spaceapp/starter-memory RUN install -d -o spaceapp -g spaceapp -m 0700 \ /var/lib/spaceapp \ @@ -91,11 +89,13 @@ RUN apt-get update \ /var/lib/spaceapp \ /var/lib/spaceapp/artifacts \ /run/spaceapp-browser +COPY --from=build --chown=spaceapp:spaceapp /app/targets/browser ./ ENV SPACE_BROWSER_CHROME_PATH=/usr/bin/chromium \ SPACE_BROWSER_SESSIONS_CHROME_PATH=/usr/bin/chromium ENTRYPOINT ["/app/deploy/docker/browser-entrypoint.sh"] FROM runtime-base AS cli +COPY --from=build --chown=spaceapp:spaceapp /app/targets/cli ./ COPY --chown=root:root starter-memory/AGENTS.md /etc/AGENTS.md COPY --chown=root:root starter-memory/installation-guide.md /etc/spaceapp-installation-guide.md RUN chmod 0644 /etc/AGENTS.md diff --git a/deploy/docker/container-smoke-check.mjs b/deploy/docker/container-smoke-check.mjs new file mode 100644 index 000000000..f87ac3fd1 --- /dev/null +++ b/deploy/docker/container-smoke-check.mjs @@ -0,0 +1,455 @@ +#!/usr/bin/env node +import { execFileSync, spawnSync } from "node:child_process"; +import { mkdirSync, rmSync, writeFileSync } from "node:fs"; +import http from "node:http"; +import net from "node:net"; +import { join, resolve } from "node:path"; +import process from "node:process"; +import { fileURLToPath } from "node:url"; + +function optionValue(args, name) { + const index = args.indexOf(name); + return index === -1 ? undefined : args[index + 1]; +} + +function sleep(ms) { + return new Promise((resolve) => setTimeout(resolve, ms)); +} + +function docker(args) { + return execFileSync("docker", args, { encoding: "utf8", stdio: ["ignore", "pipe", "inherit"] }).trim(); +} + +function encodeLengthPrefixedJson(obj) { + const json = Buffer.from(JSON.stringify(obj), "utf8"); + const header = Buffer.alloc(4); + header.writeUInt32BE(json.length, 0); + return Buffer.concat([header, json]); +} + +async function startPgContainer(pgName, dbName, netName) { + console.log(`[smoke-check] Starting pgvector container ${pgName} on network ${netName}...`); + docker([ + "run", "-d", "--name", pgName, + "--network", netName, + "-e", "POSTGRES_PASSWORD=postgres", + "-e", `POSTGRES_DB=${dbName}`, + "pgvector/pgvector:pg16" + ]); + + let pgReady = false; + for (let i = 0; i < 30; i++) { + const probe = spawnSync("docker", [ + "exec", pgName, "pg_isready", "-U", "postgres" + ], { stdio: "ignore" }); + if (probe.status === 0) { + pgReady = true; + break; + } + await sleep(1000); + } + if (!pgReady) throw new Error("PostgreSQL failed to become ready in time."); +} + +async function smokeCore(image) { + const unique = `${Date.now()}-${Math.floor(Math.random() * 10000)}`; + const netName = `spaceapp-smoke-net-${unique}`; + const pgContainer = `spaceapp-smoke-pg-${unique}`; + const coreContainer = `spaceapp-smoke-core-${unique}`; + const tmpDir = join("/tmp", `spaceapp-core-secrets-${unique}`); + + try { + docker(["network", "create", netName]); + mkdirSync(tmpDir, { recursive: true }); + writeFileSync(join(tmpDir, "database-url"), `postgres://postgres:postgres@${pgContainer}:5432/spaceapp`); + writeFileSync(join(tmpDir, "session-secret"), "0123456789012345678901234567890123456789"); + writeFileSync(join(tmpDir, "setup-token"), "smoke-test-setup-token-0123456789abcdef"); + + await startPgContainer(pgContainer, "spaceapp", netName); + + console.log(`[smoke-check:core] Starting core container ${coreContainer}...`); + docker([ + "run", "-d", "--name", coreContainer, + "--network", netName, + "-p", "4911:4911", + "-e", "SPACE_PUBLIC_DISTRIBUTION=true", + "-e", "SPACE_RUNTIME_STORE=postgres", + "-e", "SPACEAPP_WORKFLOWS_ENABLED=false", + "-e", "SPACE_CLI_ENABLED=false", + "-e", "SPACE_BROWSER_SESSIONS_ENABLED=false", + "-v", `${tmpDir}:/run/secrets:ro`, + image + ]); + + console.log("[smoke-check:core] Polling http://127.0.0.1:4911/readyz..."); + let ok = false; + for (let i = 0; i < 45; i++) { + try { + const res = await new Promise((resolve, reject) => { + const req = http.get("http://127.0.0.1:4911/readyz", { timeout: 10000 }, (r) => { + let data = ""; + r.on("data", (chunk) => { data += chunk; }); + r.on("end", () => resolve({ statusCode: r.statusCode, body: data })); + }); + req.on("error", reject); + }); + + console.log(`[smoke-check:core] Probe result: status=${res.statusCode}, body=${res.body}`); + if (res.statusCode === 200) { + const parsed = JSON.parse(res.body); + if (parsed && parsed.ok === true) { + console.log("[smoke-check:core] /readyz answered ok: true!"); + ok = true; + break; + } + } + } catch (err) { + console.log("[smoke-check:core] Probe exception:", err?.message); + } + await sleep(1000); + } + + if (!ok) { + console.error("[smoke-check:core] Core logs on failure:"); + spawnSync("docker", ["logs", "--tail", "100", coreContainer], { stdio: "inherit" }); + throw new Error("Core container failed /readyz check"); + } + } finally { + console.log("[smoke-check:core] Cleaning up..."); + spawnSync("docker", ["rm", "-f", coreContainer, pgContainer], { stdio: "ignore" }); + spawnSync("docker", ["network", "rm", netName], { stdio: "ignore" }); + try { + rmSync(tmpDir, { recursive: true, force: true }); + } catch {} + } +} + +async function smokeCli(image) { + const unique = `${Date.now()}-${Math.floor(Math.random() * 10000)}`; + const cliContainer = `spaceapp-smoke-cli-${unique}`; + const socketDir = join("/tmp", `spaceapp-cli-sock-${unique}`); + + try { + mkdirSync(socketDir, { recursive: true }); + spawnSync("chmod", ["0777", socketDir], { stdio: "ignore" }); + + console.log(`[smoke-check:cli] Starting cli container ${cliContainer}...`); + docker([ + "run", "-d", "--name", cliContainer, + "-v", `${socketDir}:/run/spaceapp-cli`, + "-e", "SPACE_CLI_HOST_SOCKET=/run/spaceapp-cli/host.sock", + image + ]); + + const socketPath = join(socketDir, "host.sock"); + console.log(`[smoke-check:cli] Waiting for host socket ${socketPath}...`); + let client = null; + + for (let i = 0; i < 30; i++) { + spawnSync("docker", ["exec", "-u", "0", cliContainer, "chmod", "-R", "0777", "/run/spaceapp-cli"], { stdio: "ignore" }); + try { + const candidate = net.createConnection({ path: socketPath }); + await new Promise((resolve, reject) => { + candidate.once("connect", () => { + client = candidate; + resolve(); + }); + candidate.once("error", reject); + }); + if (client) { + console.log("[smoke-check:cli] Successfully connected to CLI host Unix domain socket."); + break; + } + } catch {} + await sleep(1000); + } + + if (!client) { + console.error("[smoke-check:cli] CLI logs on failure:"); + spawnSync("docker", ["logs", "--tail", "100", cliContainer], { stdio: "inherit" }); + throw new Error("CLI host socket failed to accept connection."); + } + + // Exercise node-pty dynamic import and pty spawn path + console.log("[smoke-check:cli] Sending attach request to exercise node-pty spawn..."); + const attachRequest = encodeLengthPrefixedJson({ + kind: "request", + requestId: "smoke-attach-1", + method: "attach", + params: { + identity: { + cliSessionId: "smoke-cli-session-1", + paneId: "smoke-pane-1", + roomId: "smoke-room-1", + runtimeId: "cli:codex", + codexThreadId: null, + modelId: null, + reasoningEffort: null + }, + spawn: { + command: "/bin/echo", + args: ["node-pty-smoke-ok"], + cwd: "/tmp", + env: {}, + cols: 80, + rows: 24 + } + } + }); + + await new Promise((resolve, reject) => { + let buffer = Buffer.alloc(0); + const timeout = setTimeout(() => { + cleanup(); + reject(new Error("Timed out waiting for CLI attach response after 15000ms")); + }, 15000); + + function cleanup() { + clearTimeout(timeout); + client.removeListener("data", onData); + client.removeListener("error", onError); + } + + function onError(err) { + cleanup(); + reject(err); + } + + function onData(chunk) { + buffer = Buffer.concat([buffer, chunk]); + if (buffer.length >= 4) { + const jsonLen = buffer.readUInt32BE(0); + if (jsonLen === 0 || jsonLen > 16 * 1024 * 1024) { + cleanup(); + reject(new Error(`Invalid frame length in CLI attach response: ${jsonLen}`)); + return; + } + if (buffer.length >= 4 + jsonLen) { + cleanup(); + try { + const bodyStr = buffer.subarray(4, 4 + jsonLen).toString("utf8"); + const parsed = JSON.parse(bodyStr); + console.log(`[smoke-check:cli] Attach response received: ${JSON.stringify(parsed).slice(0, 100)}`); + if (parsed && parsed.kind === "response" && parsed.ok === true) { + resolve(true); + } else { + reject(new Error(`CLI attach response indicated failure or invalid schema: ${bodyStr}`)); + } + } catch (err) { + reject(err); + } + } + } + } + + client.on("data", onData); + client.once("error", onError); + client.write(attachRequest); + }); + + client.destroy(); + console.log("[smoke-check:cli] CLI smoke check completed successfully."); + } finally { + console.log("[smoke-check:cli] Cleaning up..."); + spawnSync("docker", ["exec", "-u", "0", cliContainer, "chmod", "-R", "0777", "/run/spaceapp-cli"], { stdio: "ignore" }); + spawnSync("docker", ["rm", "-f", cliContainer], { stdio: "ignore" }); + try { + rmSync(socketDir, { recursive: true, force: true }); + } catch { + try { + spawnSync("docker", ["run", "--rm", "-v", `${socketDir}:/cleanup`, "alpine", "sh", "-c", "rm -rf /cleanup/* /cleanup/.* 2>/dev/null || true"], { stdio: "ignore" }); + rmSync(socketDir, { recursive: true, force: true }); + } catch {} + } + } +} + +async function smokeBrowser(image) { + const unique = `${Date.now()}-${Math.floor(Math.random() * 10000)}`; + const netName = `spaceapp-smoke-browser-net-${unique}`; + const pgContainer = `spaceapp-smoke-browser-pg-${unique}`; + const browserContainer = `spaceapp-smoke-browser-${unique}`; + const tmpDir = join("/tmp", `spaceapp-browser-secrets-${unique}`); + const socketDir = join("/tmp", `spaceapp-browser-sock-${unique}`); + + try { + docker(["network", "create", netName]); + mkdirSync(tmpDir, { recursive: true }); + mkdirSync(socketDir, { recursive: true }); + spawnSync("chmod", ["0777", socketDir], { stdio: "ignore" }); + writeFileSync(join(tmpDir, "database-url"), `postgres://postgres:postgres@${pgContainer}:5432/spaceapp_browser`); + + await startPgContainer(pgContainer, "spaceapp_browser", netName); + + console.log("[smoke-check:browser] Running database migrations on test database..."); + docker([ + "run", "--rm", + "--entrypoint", "", + "--network", netName, + "-e", `SPACE_DATABASE_URL=postgres://postgres:postgres@${pgContainer}:5432/spaceapp_browser`, + "-e", "SPACE_RUNTIME_STORE=postgres", + image, + "gosu", "spaceapp", "node", "packages/db/dist/migrate.js" + ]); + + console.log(`[smoke-check:browser] Starting browser host container ${browserContainer}...`); + docker([ + "run", "-d", "--name", browserContainer, + "--network", netName, + "-v", `${tmpDir}/database-url:/run/secrets/database-url:ro`, + "-v", `${socketDir}:/run/spaceapp-browser`, + "-e", "SPACE_RUNTIME_STORE=postgres", + "-e", "SPACE_BROWSER_SESSIONS_ENABLED=true", + "-e", "SPACE_BROWSER_HOST_SOCKET=/run/spaceapp-browser/browser-host.sock", + image + ]); + + const socketPath = join(socketDir, "browser-host.sock"); + console.log(`[smoke-check:browser] Waiting for browser-host socket ${socketPath}...`); + let hostReady = false; + + for (let i = 0; i < 30; i++) { + spawnSync("docker", ["exec", "-u", "0", browserContainer, "chmod", "-R", "0777", "/run/spaceapp-browser"], { stdio: "ignore" }); + try { + const client = net.createConnection({ path: socketPath }); + await new Promise((resolve, reject) => { + let buffer = Buffer.alloc(0); + const timeout = setTimeout(() => { + client.destroy(); + reject(new Error("Timeout waiting for Browser Host health response")); + }, 5000); + + client.once("connect", () => { + client.write(encodeLengthPrefixedJson({ + kind: "request", + requestId: `smoke-browser-health-${i}`, + method: "health", + params: {} + })); + }); + + client.once("error", (error) => { + clearTimeout(timeout); + reject(error); + }); + + client.on("data", (chunk) => { + buffer = Buffer.concat([buffer, chunk]); + if (buffer.length < 4) return; + const jsonLen = buffer.readUInt32BE(0); + if (jsonLen === 0 || buffer.length < 4 + jsonLen) return; + try { + const response = JSON.parse(buffer.subarray(4, 4 + jsonLen).toString("utf8")); + const health = response?.result; + if ( + response?.kind !== "response" || + response.ok !== true || + health?.status?.enabled !== true || + typeof health?.capacity?.maxSessions !== "number" || + health.capacity.maxSessions < 8 || + typeof health?.capacity?.maxLiveWorkloads !== "number" || + health.capacity.maxLiveWorkloads < 4 + ) { + throw new Error(`Browser Host health response is not ready: ${JSON.stringify(response)}`); + } + clearTimeout(timeout); + hostReady = true; + client.destroy(); + resolve(); + } catch (error) { + clearTimeout(timeout); + client.destroy(); + reject(error); + } + }); + }); + if (hostReady) { + console.log("[smoke-check:browser] Browser Host socket accepted connection and confirmed health readiness."); + break; + } + } catch {} + await sleep(1000); + } + + if (!hostReady) { + console.error("[smoke-check:browser] Browser Host logs on failure:"); + spawnSync("docker", ["logs", "--tail", "100", browserContainer], { stdio: "inherit" }); + throw new Error("Browser Host socket failed to accept connection."); + } + + console.log(`[smoke-check:browser] Running headless chromium verification in ${image}...`); + const isArm64Emulation = image.includes("arm64") || process.arch === "arm64"; + const chromiumArgs = isArm64Emulation + ? ["--headless", "--no-sandbox", "--version"] + : ["--headless", "--no-sandbox", "--disable-gpu", "--dump-dom", "about:blank"]; + + let result = spawnSync("docker", [ + "run", "--rm", + "--entrypoint", "", + image, + "gosu", "spaceapp", "/usr/bin/chromium", + ...chromiumArgs + ], { encoding: "utf8", timeout: 30000, killSignal: "SIGKILL" }); + + if (result.status !== 0 && !isArm64Emulation) { + console.warn("[smoke-check:browser] Chromium DOM dump returned non-zero, falling back to --version probe..."); + result = spawnSync("docker", [ + "run", "--rm", + "--entrypoint", "", + image, + "gosu", "spaceapp", "/usr/bin/chromium", + "--headless", "--no-sandbox", "--version" + ], { encoding: "utf8", timeout: 30000, killSignal: "SIGKILL" }); + } + + if (result.status !== 0) { + console.error("[smoke-check:browser] Chromium verification failed:", result.stderr || result.stdout); + throw new Error(`Browser smoke check failed with exit code ${result.status}`); + } + console.log(`[smoke-check:browser] Headless Chromium launched successfully (${isArm64Emulation ? "binary version verified" : "DOM dumped"}).`); + } finally { + console.log("[smoke-check:browser] Cleaning up..."); + spawnSync("docker", ["exec", "-u", "0", browserContainer, "chmod", "-R", "0777", "/run/spaceapp-browser"], { stdio: "ignore" }); + spawnSync("docker", ["rm", "-f", browserContainer, pgContainer], { stdio: "ignore" }); + spawnSync("docker", ["network", "rm", netName], { stdio: "ignore" }); + try { + rmSync(tmpDir, { recursive: true, force: true }); + } catch {} + try { + rmSync(socketDir, { recursive: true, force: true }); + } catch { + try { + spawnSync("docker", ["run", "--rm", "-v", `${socketDir}:/cleanup`, "alpine", "sh", "-c", "rm -rf /cleanup/* /cleanup/.* 2>/dev/null || true"], { stdio: "ignore" }); + rmSync(socketDir, { recursive: true, force: true }); + } catch {} + } + } +} + +export async function runContainerSmokeCheck(args = process.argv.slice(2)) { + const target = optionValue(args, "--target"); + const image = optionValue(args, "--image"); + + if (!target || !image) { + throw new Error("Usage: container-smoke-check.mjs --target --image "); + } + + console.log(`[smoke-check] Executing startup smoke check for target='${target}' image='${image}'...`); + if (target === "core") { + await smokeCore(image); + } else if (target === "cli") { + await smokeCli(image); + } else if (target === "browser") { + await smokeBrowser(image); + } else { + throw new Error(`Unknown smoke check target: ${target}`); + } + console.log(`[smoke-check] Target '${target}' passed all smoke verification checks!`); +} + +if (process.argv[1] && fileURLToPath(import.meta.url) === resolve(process.argv[1])) { + runContainerSmokeCheck().catch((error) => { + console.error(error); + process.exit(1); + }); +} diff --git a/deploy/docker/prepare-container-targets.mjs b/deploy/docker/prepare-container-targets.mjs new file mode 100644 index 000000000..24bfa4e0e --- /dev/null +++ b/deploy/docker/prepare-container-targets.mjs @@ -0,0 +1,202 @@ +#!/usr/bin/env node +import { cpSync, existsSync, mkdirSync, readdirSync, rmSync, unlinkSync } from "node:fs"; +import { dirname, join, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; + +const root = resolve(dirname(fileURLToPath(import.meta.url)), "../.."); + +function parseArgs(args) { + let targetArch = process.arch === "arm64" ? "arm64" : "amd64"; + for (let i = 0; i < args.length; i++) { + if (args[i] === "--target-arch" && args[i + 1]) { + targetArch = args[i + 1]; + i++; + } else if (args[i].startsWith("--target-arch=")) { + targetArch = args[i].slice("--target-arch=".length); + } + } + return { targetArch }; +} + +function removeMaps(dir) { + if (!existsSync(dir)) return; + const entries = readdirSync(dir, { withFileTypes: true }); + for (const entry of entries) { + const fullPath = join(dir, entry.name); + if (entry.isDirectory()) { + removeMaps(fullPath); + } else if (entry.name.endsWith(".map")) { + try { + unlinkSync(fullPath); + } catch {} + } + } +} + +function safeCopy(src, dst) { + if (!existsSync(src)) return; + const parent = dirname(dst); + mkdirSync(parent, { recursive: true }); + cpSync(src, dst, { recursive: true, dereference: false }); +} + +/** + * Prepares isolated container filesystem targets (core, browser, cli) for Docker builds. + * + * @param {Object} [options] - Target preparation options. + * @param {"amd64"|"arm64"} [options.targetArch="amd64"] - The target architecture to bundle native prebuilds for. + * @throws {Error} If targetArch is not 'amd64' or 'arm64'. + */ +export function prepareContainerTargets({ targetArch = "amd64" } = {}) { + if (targetArch !== "amd64" && targetArch !== "arm64") { + throw new Error(`Unsupported target architecture "${targetArch}". Expected "amd64" or "arm64".`); + } + const isAmd64 = targetArch === "amd64"; + const isArm64 = targetArch === "arm64"; + const targetsRoot = join(root, "targets"); + + rmSync(targetsRoot, { recursive: true, force: true }); + mkdirSync(targetsRoot, { recursive: true }); + + const coreRoot = join(targetsRoot, "core"); + const browserRoot = join(targetsRoot, "browser"); + const cliRoot = join(targetsRoot, "cli"); + + for (const dir of [coreRoot, browserRoot, cliRoot]) { + mkdirSync(dir, { recursive: true }); + } + + // --------------------------------------------------------------------------- + // 1. CLI target: minimal CLI host runtime, agent skills, and node-pty + // --------------------------------------------------------------------------- + safeCopy(join(root, "package.json"), join(cliRoot, "package.json")); + safeCopy(join(root, "packages/cli-host"), join(cliRoot, "packages/cli-host")); + safeCopy(join(root, "agent-skills"), join(cliRoot, "agent-skills")); + + // CLI needs node-pty and node-addon-api + if (existsSync(join(root, "node_modules/node-pty"))) { + safeCopy(join(root, "node_modules/node-pty"), join(cliRoot, "node_modules/node-pty")); + } + if (existsSync(join(root, "node_modules/node-addon-api"))) { + safeCopy(join(root, "node_modules/node-addon-api"), join(cliRoot, "node_modules/node-addon-api")); + } + removeMaps(cliRoot); + + // --------------------------------------------------------------------------- + // 2. Browser target: API browser host, contracts, db, runtime + // --------------------------------------------------------------------------- + safeCopy(join(root, "package.json"), join(browserRoot, "package.json")); + safeCopy(join(root, "apps/api/dist"), join(browserRoot, "apps/api/dist")); + safeCopy(join(root, "apps/api/package.json"), join(browserRoot, "apps/api/package.json")); + + for (const pkg of ["browser-host", "contracts", "db", "runtime", "icons"]) { + safeCopy(join(root, `packages/${pkg}`), join(browserRoot, `packages/${pkg}`)); + } + + const browserExcludedModules = new Set([ + "@temporalio", + "@napi-rs", + "pdfjs-dist", + "node-pty", + "node-addon-api", + "@uiw", + "@codemirror", + "@formkit", + "@novnc", + "@tanstack", + "cube-motion", + "graphology", + "motion", + "react-markdown", + "remark-gfm", + "sigma", + "esbuild", + "rollup" + ]); + + if (existsSync(join(root, "node_modules"))) { + const modules = readdirSync(join(root, "node_modules"), { withFileTypes: true }); + for (const mod of modules) { + if (browserExcludedModules.has(mod.name)) continue; + safeCopy(join(root, "node_modules", mod.name), join(browserRoot, "node_modules", mod.name)); + } + } + removeMaps(browserRoot); + + // --------------------------------------------------------------------------- + // 3. Core target: API, Worker, Web, DB migrations, skills + // --------------------------------------------------------------------------- + safeCopy(join(root, "package.json"), join(coreRoot, "package.json")); + safeCopy(join(root, "apps/api"), join(coreRoot, "apps/api")); + safeCopy(join(root, "apps/worker"), join(coreRoot, "apps/worker")); + safeCopy(join(root, "apps/web"), join(coreRoot, "apps/web")); + safeCopy(join(root, "agent-skills"), join(coreRoot, "agent-skills")); + + for (const pkg of [ + "browser-host", + "contracts", + "db", + "gateway", + "icons", + "memory-graph", + "opencode-control", + "runtime", + "streaming", + "codex-app-server", + "cli-host" + ]) { + safeCopy(join(root, `packages/${pkg}`), join(coreRoot, `packages/${pkg}`)); + rmSync(join(coreRoot, "packages", pkg, "tests"), { recursive: true, force: true }); + rmSync(join(coreRoot, "packages", pkg, "__tests__"), { recursive: true, force: true }); + } + + for (const app of ["apps/api", "apps/worker", "apps/web"]) { + rmSync(join(coreRoot, app, "tests"), { recursive: true, force: true }); + rmSync(join(coreRoot, app, "__tests__"), { recursive: true, force: true }); + } + + // Copy node_modules excluding node-pty (cli-only) + if (existsSync(join(root, "node_modules"))) { + const modules = readdirSync(join(root, "node_modules"), { withFileTypes: true }); + for (const mod of modules) { + if (mod.name === "node-pty" || mod.name === "node-addon-api") continue; + safeCopy(join(root, "node_modules", mod.name), join(coreRoot, "node_modules", mod.name)); + } + } + + // Clean foreign-architecture native prebuilds in core + // (A) @napi-rs/canvas foreign binaries + const canvasPath = join(coreRoot, "node_modules/@napi-rs"); + if (existsSync(canvasPath)) { + const canvasEntries = readdirSync(canvasPath, { withFileTypes: true }); + for (const entry of canvasEntries) { + if (!entry.name.startsWith("canvas-")) continue; + if (isAmd64 && entry.name === "canvas-linux-x64-gnu") continue; + if (isArm64 && entry.name === "canvas-linux-arm64-gnu") continue; + rmSync(join(canvasPath, entry.name), { recursive: true, force: true }); + } + } + + // (B) @temporalio/core-bridge foreign platform binaries + const temporalReleases = join(coreRoot, "node_modules/@temporalio/core-bridge/releases"); + if (existsSync(temporalReleases)) { + const bridgeFiles = readdirSync(temporalReleases, { withFileTypes: true }); + for (const file of bridgeFiles) { + if (isAmd64 && file.name.includes("x86_64-unknown-linux-gnu")) continue; + if (isArm64 && file.name.includes("aarch64-unknown-linux-gnu")) continue; + rmSync(join(temporalReleases, file.name), { recursive: true, force: true }); + } + } + + removeMaps(coreRoot); + + console.log(`[prepare-container-targets] Successfully prepared isolated targets for arch=${targetArch}:`); + console.log(` - core: ${coreRoot}`); + console.log(` - browser: ${browserRoot}`); + console.log(` - cli: ${cliRoot}`); +} + +if (process.argv[1] && fileURLToPath(import.meta.url) === resolve(process.argv[1])) { + const { targetArch } = parseArgs(process.argv.slice(2)); + prepareContainerTargets({ targetArch }); +} diff --git a/packages/cli-host/src/node-pty-spawn.ts b/packages/cli-host/src/node-pty-spawn.ts index 47f18bfad..6b7e2a17b 100644 --- a/packages/cli-host/src/node-pty-spawn.ts +++ b/packages/cli-host/src/node-pty-spawn.ts @@ -1,4 +1,3 @@ -import { spawn } from "node-pty"; import type { CliHostSpawn } from "./types.js"; /** @@ -8,11 +7,21 @@ import type { CliHostSpawn } from "./types.js"; * CLI tree and the pane exits. Admin-initiated termination (SIGTERM/SIGHUP) * and app-level exits (Ctrl-D, /exit) are unaffected. */ -export const spawnNodePty: CliHostSpawn = (spec) => - spawn("/bin/sh", ["-c", '/usr/bin/stty -isig; exec "$@"', "space-cli-host", spec.command, ...spec.args], { - name: "xterm-256color", - cols: spec.cols, - rows: spec.rows, - cwd: spec.cwd, - env: spec.env - }); +export const spawnNodePty: CliHostSpawn = async (spec) => { + const nodePty = await import("node-pty"); + const spawn = nodePty.spawn ?? (nodePty as any).default?.spawn; + if (typeof spawn !== "function") { + throw new Error("node-pty did not export spawn"); + } + return spawn( + "/bin/sh", + ["-c", '/usr/bin/stty -isig; exec "$@"', "space-cli-host", spec.command, ...spec.args], + { + name: "xterm-256color", + cols: spec.cols, + rows: spec.rows, + cwd: spec.cwd, + env: spec.env + } + ); +}; diff --git a/scripts/container-image-size-budget.mjs b/scripts/container-image-size-budget.mjs index fe0068778..2f13f4971 100644 --- a/scripts/container-image-size-budget.mjs +++ b/scripts/container-image-size-budget.mjs @@ -11,9 +11,9 @@ const mebibyte = 1024 * 1024; // 1.1.5 raised core again: linux/arm64 measured 1066.9 MiB and linux/amd64 1045.0 MiB (sqlite3 in the image, // the 1.1.5 application); the first public release run failed the arm64 build at 1050 MiB. export const containerImageSizeBudgets = Object.freeze({ - core: 1100 * mebibyte, - browser: 1750 * mebibyte, - cli: 2800 * mebibyte + core: 880 * mebibyte, + browser: 1350 * mebibyte, + cli: 1000 * mebibyte }); export function evaluateContainerImageSize({ target, sizeBytes }) { diff --git a/scripts/tests/container-image-size-budget.test.mjs b/scripts/tests/container-image-size-budget.test.mjs index 35146497d..f6375cab7 100644 --- a/scripts/tests/container-image-size-budget.test.mjs +++ b/scripts/tests/container-image-size-budget.test.mjs @@ -9,9 +9,9 @@ const mebibyte = 1024 * 1024; test("public container size budgets preserve the lightweight release ceilings", () => { assert.deepEqual(containerImageSizeBudgets, { - core: 1100 * mebibyte, - browser: 1750 * mebibyte, - cli: 2800 * mebibyte + core: 880 * mebibyte, + browser: 1350 * mebibyte, + cli: 1000 * mebibyte }); }); @@ -43,9 +43,18 @@ test("container size budget blocks an image one byte over its target ceiling", ( }); }); +import { prepareContainerTargets } from "../../deploy/docker/prepare-container-targets.mjs"; + test("container size budget rejects unknown targets", () => { assert.throws( () => evaluateContainerImageSize({ target: "unknown", sizeBytes: 1 }), /Unknown container target/ ); }); + +test("prepareContainerTargets rejects unsupported target architectures", () => { + assert.throws( + () => prepareContainerTargets({ targetArch: "invalid" }), + /Unsupported target architecture "invalid"/ + ); +});