diff --git a/.github/workflows/containers.yml b/.github/workflows/containers.yml index 97d278576..8b6141ef4 100644 --- a/.github/workflows/containers.yml +++ b/.github/workflows/containers.yml @@ -71,3 +71,9 @@ jobs: node scripts/container-image-size-budget.mjs --target "${{ matrix.target }}" --image "spaceapp-ci:${{ matrix.target }}-${{ matrix.arch }}" + + - name: Smoke check container + run: >- + node scripts/ci-container-smoke.mjs + --target "${{ matrix.target }}" + --image "spaceapp-ci:${{ matrix.target }}-${{ matrix.arch }}" diff --git a/Dockerfile b/Dockerfile index 422a66cbd..fc5c83e1b 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 scripts/prune-container-closures.mjs --targetarch "$TARGETARCH" --output-dir /app/closures FROM node:22.23.0-trixie-slim@sha256:cfd8f2a5bc50526aee08e88970979f92722828e7dcc6d8983607fb8bff4bdb82 AS runtime-base ARG TARGETARCH @@ -54,17 +56,8 @@ 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/THIRD_PARTY_LICENSES ./THIRD_PARTY_LICENSES -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 \ @@ -73,6 +66,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/closures/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 \ @@ -85,6 +83,7 @@ HEALTHCHECK --interval=15s --timeout=5s --start-period=45s --retries=5 \ ENTRYPOINT ["/app/deploy/docker/core-entrypoint.sh"] FROM runtime-base AS browser +COPY --from=build --chown=spaceapp:spaceapp /app/closures/browser ./ RUN apt-get update \ && apt-get install -y --no-install-recommends chromium fonts-liberation liblzma5 \ && rm -rf /var/lib/apt/lists/* \ @@ -97,6 +96,7 @@ ENV SPACE_BROWSER_CHROME_PATH=/usr/bin/chromium \ ENTRYPOINT ["/app/deploy/docker/browser-entrypoint.sh"] FROM runtime-base AS cli +COPY --from=build --chown=spaceapp:spaceapp /app/closures/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 @@ -117,8 +117,5 @@ RUN apt-get update \ /workspaces \ /run/spaceapp-cli \ && printf '%s\n' "$TARGETARCH" > /usr/local/share/spaceapp-cli-architecture -# The application's default room project is /opt/spaceapp. CLI sessions run in /etc, as -# /etc/AGENTS.md documents, so that path resolves to /etc in this image. Without it, the -# Getting Started OpenCode pane exits with "chdir(2) failed" before OpenCode starts. RUN ln -s /etc /opt/spaceapp ENTRYPOINT ["/app/deploy/docker/cli-entrypoint.sh"] diff --git a/scripts/ci-container-smoke.mjs b/scripts/ci-container-smoke.mjs new file mode 100644 index 000000000..56bf20c5f --- /dev/null +++ b/scripts/ci-container-smoke.mjs @@ -0,0 +1,285 @@ +#!/usr/bin/env node +import { execFileSync, spawnSync } from "node:child_process"; +import { chmodSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { fileURLToPath } from "node:url"; + +/** + * Runs a command synchronously and returns stdout. + * @param {string} cmd + * @param {string[]} args + * @param {object} [options] + * @returns {string} + */ +function run(cmd, args, options = {}) { + return execFileSync(cmd, args, { + encoding: "utf8", + stdio: ["ignore", "pipe", "pipe"], + ...options + }).trim(); +} + +/** + * Parses CLI options. + * @param {string[]} [args=process.argv.slice(2)] + * @returns {{ target: string, image: string }} + */ +export function parseSmokeArgs(args = process.argv.slice(2)) { + let target = ""; + let image = ""; + + for (let i = 0; i < args.length; i++) { + if (args[i] === "--target" && args[i + 1]) { + target = args[++i]; + } else if (args[i] === "--image" && args[i + 1]) { + image = args[++i]; + } + } + + if (!target || !image) { + throw new Error("Usage: ci-container-smoke.mjs --target --image "); + } + + return { target, image }; +} + +/** + * Performs smoke verification on the core container image. + * Starts against a temporary PostgreSQL pgvector container, verifies migrations, + * and asserts that /readyz returns ok: true. + * @param {string} image + */ +export async function smokeCore(image) { + const netName = `spaceapp-smoke-net-${Date.now()}`; + const pgContainer = `spaceapp-smoke-pg-${Date.now()}`; + const coreContainer = `spaceapp-smoke-core-${Date.now()}`; + const tempDir = mkdtempSync(join(tmpdir(), "spaceapp-core-smoke-")); + + try { + process.stdout.write(`Creating smoke network ${netName}...\n`); + run("docker", ["network", "create", netName]); + + process.stdout.write(`Starting pgvector service container ${pgContainer}...\n`); + run("docker", [ + "run", + "-d", + "--name", pgContainer, + "--network", netName, + "-e", "POSTGRES_PASSWORD=smokepassword", + "-e", "POSTGRES_USER=spaceapp", + "-e", "POSTGRES_DB=spaceapp", + "pgvector/pgvector:pg16" + ]); + + // Await PostgreSQL readiness + let pgReady = false; + for (let i = 0; i < 30; i++) { + const check = spawnSync("docker", [ + "exec", + pgContainer, + "pg_isready", + "-U", "spaceapp", + "-d", "spaceapp" + ], { encoding: "utf8" }); + if (check.status === 0) { + pgReady = true; + break; + } + await new Promise((r) => setTimeout(r, 1000)); + } + if (!pgReady) { + throw new Error("pgvector container failed to become ready within 30s"); + } + + // Prepare secrets + const dbUrlPath = join(tempDir, "database-url"); + const sessionSecretPath = join(tempDir, "session-secret"); + const setupTokenPath = join(tempDir, "setup-token"); + + writeFileSync(dbUrlPath, `postgres://spaceapp:smokepassword@${pgContainer}:5432/spaceapp\n`); + writeFileSync(sessionSecretPath, "012345678901234567890123456789012\n"); + writeFileSync(setupTokenPath, "smoke-setup-token-12345\n"); + + try { + chmodSync(dbUrlPath, 0o600); + chmodSync(sessionSecretPath, 0o600); + chmodSync(setupTokenPath, 0o600); + } catch {} + + process.stdout.write(`Starting core service container ${coreContainer}...\n`); + run("docker", [ + "run", + "-d", + "--name", coreContainer, + "--network", netName, + "-p", "127.0.0.1:4921:4911", + "-e", "SPACEAPP_WORKFLOWS_ENABLED=false", + "-e", "SPACE_DATABASE_URL_SOURCE_FILE=/run/secrets/database-url", + "-e", "SPACE_SESSION_SECRET_FILE=/run/secrets/session-secret", + "-e", "SPACE_SETUP_TOKEN_SOURCE_FILE=/run/secrets/setup-token", + "-v", `${dbUrlPath}:/run/secrets/database-url:ro`, + "-v", `${sessionSecretPath}:/run/secrets/session-secret:ro`, + "-v", `${setupTokenPath}:/run/secrets/setup-token:ro`, + image + ]); + + // Poll /readyz on port 4921 + let coreReady = false; + for (let i = 0; i < 45; i++) { + try { + const res = await fetch("http://127.0.0.1:4921/readyz"); + if (res.ok) { + const body = await res.json(); + if (body.ok === true) { + coreReady = true; + break; + } + } + } catch { + // Retry + } + await new Promise((r) => setTimeout(r, 1000)); + } + + if (!coreReady) { + const logs = run("docker", ["logs", "--tail", "100", coreContainer]); + throw new Error(`Core container failed /readyz check. Container logs:\n${logs}`); + } + + process.stdout.write("Core container smoke check passed: /readyz returned ok: true.\n"); + } finally { + try { run("docker", ["rm", "-f", coreContainer]); } catch {} + try { run("docker", ["rm", "-f", pgContainer]); } catch {} + try { run("docker", ["network", "rm", netName]); } catch {} + try { rmSync(tempDir, { recursive: true, force: true }); } catch {} + } +} + +/** + * Performs smoke verification on the CLI container image. + * Starts the CLI host and verifies that it accepts a connection on its socket. + * @param {string} image + */ +export async function smokeCli(image) { + const cliContainer = `spaceapp-smoke-cli-${Date.now()}`; + try { + process.stdout.write(`Starting CLI service container ${cliContainer}...\n`); + run("docker", [ + "run", + "-d", + "--name", cliContainer, + "-e", "SPACE_CLI_HOST_SOCKET=/run/spaceapp-cli/host.sock", + image + ]); + + // Wait for the CLI socket to be ready and connect to it + let socketAccepted = false; + for (let i = 0; i < 30; i++) { + const check = spawnSync("docker", [ + "exec", + cliContainer, + "node", + "-e", + ` + const net = require("node:net"); + const socket = net.createConnection("/run/spaceapp-cli/host.sock", () => { + socket.end(); + process.exit(0); + }); + socket.on("error", () => process.exit(1)); + ` + ], { encoding: "utf8" }); + + if (check.status === 0) { + socketAccepted = true; + break; + } + await new Promise((r) => setTimeout(r, 1000)); + } + + if (!socketAccepted) { + const logs = run("docker", ["logs", "--tail", "100", cliContainer]); + throw new Error(`CLI container failed socket connection check. Container logs:\n${logs}`); + } + + process.stdout.write("CLI container smoke check passed: accepted connection on host socket.\n"); + } finally { + try { run("docker", ["rm", "-f", cliContainer]); } catch {} + } +} + +/** + * Performs smoke verification on the browser container image. + * Launches headless Chromium once and verifies Browser Host module integrity. + * @param {string} image + */ +export async function smokeBrowser(image) { + process.stdout.write("Verifying Browser Host module closure...\n"); + run("docker", [ + "run", + "--rm", + "--entrypoint", + "node", + image, + "-e", + ` + const { createBrowserHostServer } = await import("@space/browser-host"); + const { PostgresSpaceStore } = await import("@space/db"); + const { SpaceConflictError } = await import("@space/runtime"); + if (typeof createBrowserHostServer !== "function") process.exit(1); + ` + ]); + + process.stdout.write("Launching headless Chromium...\n"); + const chromiumOutput = run("docker", [ + "run", + "--rm", + "--entrypoint", + "/usr/bin/chromium", + image, + "--headless", + "--no-sandbox", + "--disable-gpu", + "--dump-dom", + "about:blank" + ]); + + if (!chromiumOutput.includes("")) { + throw new Error(`Chromium failed to dump DOM for about:blank. Output:\n${chromiumOutput}`); + } + + process.stdout.write("Browser container smoke check passed: headless Chromium launched successfully.\n"); +} + +/** + * Main dispatcher. + * @param {object} [options] + * @param {string} [options.target] + * @param {string} [options.image] + */ +export async function runContainerSmoke(options = parseSmokeArgs()) { + const { target, image } = options; + process.stdout.write(`Running smoke check for target '${target}' using image '${image}'...\n`); + + switch (target) { + case "core": + await smokeCore(image); + break; + case "cli": + await smokeCli(image); + break; + case "browser": + await smokeBrowser(image); + break; + default: + throw new Error(`Unknown target for container smoke: ${target}`); + } +} + +if (process.argv[1] && fileURLToPath(import.meta.url) === process.argv[1]) { + runContainerSmoke().catch((error) => { + process.stderr.write(`Smoke check failed: ${error.message}\n`); + process.exit(1); + }); +} diff --git a/scripts/container-image-size-budget.mjs b/scripts/container-image-size-budget.mjs index fe0068778..f77afbe2a 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: 850 * mebibyte, + browser: 1350 * mebibyte, + cli: 1000 * mebibyte }); export function evaluateContainerImageSize({ target, sizeBytes }) { diff --git a/scripts/prune-container-closures.mjs b/scripts/prune-container-closures.mjs new file mode 100644 index 000000000..364bddc46 --- /dev/null +++ b/scripts/prune-container-closures.mjs @@ -0,0 +1,432 @@ +#!/usr/bin/env node +import { cpSync, existsSync, mkdirSync, readdirSync, readFileSync, rmSync, unlinkSync } from "node:fs"; +import { dirname, join, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; + +const scriptDir = dirname(fileURLToPath(import.meta.url)); +const repoRoot = resolve(scriptDir, ".."); + +/** + * Parses CLI arguments into an options map. + * @param {string[]} [args=process.argv.slice(2)] + * @returns {{ targetArch: string, outputDir: string }} + */ +export function parseArgs(args = process.argv.slice(2)) { + let targetArch = process.env.TARGETARCH || (process.arch === "arm64" ? "arm64" : "amd64"); + let outputDir = join(repoRoot, "closures"); + + for (let i = 0; i < args.length; i++) { + if (args[i] === "--targetarch" && args[i + 1]) { + targetArch = args[++i]; + } else if (args[i] === "--output-dir" && args[i + 1]) { + outputDir = resolve(args[++i]); + } + } + + return { targetArch, outputDir }; +} + +/** + * Recursively deletes any .map files from a directory. + * @param {string} dir + */ +export function removeSourceMaps(dir) { + if (!existsSync(dir)) return; + const entries = readdirSync(dir, { withFileTypes: true }); + for (const entry of entries) { + const full = join(dir, entry.name); + if (entry.isDirectory()) { + removeSourceMaps(full); + } else if (entry.isFile() && entry.name.endsWith(".map")) { + unlinkSync(full); + } + } +} + +/** + * Recursively resolves production dependencies for a set of root packages. + * @param {string} nodeModulesDir + * @param {string[]} rootPackages + * @returns {Set} + */ +export function resolveDependencyClosure(nodeModulesDir, rootPackages) { + const collected = new Set(); + const queue = [...rootPackages]; + + while (queue.length > 0) { + const pkg = queue.shift(); + if (!pkg || collected.has(pkg)) continue; + collected.add(pkg); + + const pkgJsonPath = join(nodeModulesDir, pkg, "package.json"); + if (existsSync(pkgJsonPath)) { + try { + const pkgJson = JSON.parse(readFileSync(pkgJsonPath, "utf8")); + const deps = Object.keys(pkgJson.dependencies || {}); + for (const dep of deps) { + if (!collected.has(dep)) { + queue.push(dep); + } + } + } catch { + // Skip unparseable package.json + } + } + } + + return collected; +} + +/** + * Builds the standalone CLI container closure. + * @param {string} root + * @param {string} targetDir + * @param {string} targetArch + */ +export function buildCliClosure(root, targetDir, targetArch) { + rmSync(targetDir, { recursive: true, force: true }); + mkdirSync(targetDir, { recursive: true }); + + cpSync(join(root, "package.json"), join(targetDir, "package.json")); + for (const file of ["LICENSE", "NOTICE", "THIRD_PARTY_NOTICES.md"]) { + const src = join(root, file); + if (existsSync(src)) cpSync(src, join(targetDir, file)); + } + if (existsSync(join(root, "THIRD_PARTY_LICENSES"))) { + cpSync(join(root, "THIRD_PARTY_LICENSES"), join(targetDir, "THIRD_PARTY_LICENSES"), { recursive: true }); + } + + const cliHostSrc = join(root, "packages", "cli-host"); + const cliHostDst = join(targetDir, "packages", "cli-host"); + mkdirSync(cliHostDst, { recursive: true }); + cpSync(join(cliHostSrc, "package.json"), join(cliHostDst, "package.json")); + cpSync(join(cliHostSrc, "dist"), join(cliHostDst, "dist"), { recursive: true }); + + if (existsSync(join(root, "agent-skills"))) { + cpSync(join(root, "agent-skills"), join(targetDir, "agent-skills"), { recursive: true }); + } + if (existsSync(join(root, "deploy", "docker"))) { + cpSync(join(root, "deploy", "docker"), join(targetDir, "deploy", "docker"), { recursive: true }); + } + + const nmDst = join(targetDir, "node_modules"); + mkdirSync(nmDst, { recursive: true }); + + const nodePtySrc = join(root, "node_modules", "node-pty"); + if (existsSync(nodePtySrc)) { + const nodePtyDst = join(nmDst, "node-pty"); + cpSync(nodePtySrc, nodePtyDst, { recursive: true }); + + const prebuildsDir = join(nodePtyDst, "prebuilds"); + if (existsSync(prebuildsDir)) { + const neededPrebuild = targetArch === "arm64" ? "linux-arm64" : "linux-x64"; + for (const entry of readdirSync(prebuildsDir)) { + if (entry !== neededPrebuild) { + rmSync(join(prebuildsDir, entry), { recursive: true, force: true }); + } + } + } + } + + const addonApiSrc = join(root, "node_modules", "node-addon-api"); + if (existsSync(addonApiSrc)) { + cpSync(addonApiSrc, join(nmDst, "node-addon-api"), { recursive: true }); + } + + const spaceDst = join(nmDst, "@space"); + mkdirSync(spaceDst, { recursive: true }); + cpSync(join(cliHostDst), join(spaceDst, "cli-host"), { recursive: true }); + + removeSourceMaps(targetDir); +} + +/** + * Builds the standalone Browser Host container closure. + * @param {string} root + * @param {string} targetDir + */ +export function buildBrowserClosure(root, targetDir) { + rmSync(targetDir, { recursive: true, force: true }); + mkdirSync(targetDir, { recursive: true }); + + cpSync(join(root, "package.json"), join(targetDir, "package.json")); + for (const file of ["LICENSE", "NOTICE", "THIRD_PARTY_NOTICES.md"]) { + const src = join(root, file); + if (existsSync(src)) cpSync(src, join(targetDir, file)); + } + if (existsSync(join(root, "THIRD_PARTY_LICENSES"))) { + cpSync(join(root, "THIRD_PARTY_LICENSES"), join(targetDir, "THIRD_PARTY_LICENSES"), { recursive: true }); + } + + const apiSrc = join(root, "apps", "api"); + const apiDst = join(targetDir, "apps", "api"); + mkdirSync(apiDst, { recursive: true }); + cpSync(join(apiSrc, "package.json"), join(apiDst, "package.json")); + cpSync(join(apiSrc, "dist"), join(apiDst, "dist"), { recursive: true }); + + const browserPackages = ["browser-host", "contracts", "db", "runtime", "memory-graph"]; + for (const pkg of browserPackages) { + const pkgSrc = join(root, "packages", pkg); + const pkgDst = join(targetDir, "packages", pkg); + mkdirSync(pkgDst, { recursive: true }); + cpSync(join(pkgSrc, "package.json"), join(pkgDst, "package.json")); + if (existsSync(join(pkgSrc, "dist"))) { + cpSync(join(pkgSrc, "dist"), join(pkgDst, "dist"), { recursive: true }); + } + if (pkg === "db" && existsSync(join(pkgSrc, "migrations"))) { + cpSync(join(pkgSrc, "migrations"), join(pkgDst, "migrations"), { recursive: true }); + } + } + + if (existsSync(join(root, "deploy", "docker"))) { + cpSync(join(root, "deploy", "docker"), join(targetDir, "deploy", "docker"), { recursive: true }); + } + + const nmDst = join(targetDir, "node_modules"); + mkdirSync(nmDst, { recursive: true }); + + const rootDeps = [ + "pg", + "nanoid", + "zod", + "graphology", + "graphology-layout-forceatlas2", + "mdast-util-from-markdown", + "@t3-oss/env-core" + ]; + const closureDeps = resolveDependencyClosure(join(root, "node_modules"), rootDeps); + + for (const dep of closureDeps) { + const src = join(root, "node_modules", dep); + if (existsSync(src)) { + const dst = join(nmDst, dep); + mkdirSync(dirname(dst), { recursive: true }); + cpSync(src, dst, { recursive: true }); + } + } + + const spaceDst = join(nmDst, "@space"); + mkdirSync(spaceDst, { recursive: true }); + for (const pkg of browserPackages) { + cpSync(join(targetDir, "packages", pkg), join(spaceDst, pkg), { recursive: true }); + } + + removeSourceMaps(targetDir); +} + +/** + * Builds the core service container closure. + * @param {string} root + * @param {string} targetDir + * @param {string} targetArch + */ +export function buildCoreClosure(root, targetDir, targetArch) { + rmSync(targetDir, { recursive: true, force: true }); + mkdirSync(targetDir, { recursive: true }); + + cpSync(join(root, "package.json"), join(targetDir, "package.json")); + for (const file of ["LICENSE", "NOTICE", "THIRD_PARTY_NOTICES.md"]) { + const src = join(root, file); + if (existsSync(src)) cpSync(src, join(targetDir, file)); + } + if (existsSync(join(root, "THIRD_PARTY_LICENSES"))) { + cpSync(join(root, "THIRD_PARTY_LICENSES"), join(targetDir, "THIRD_PARTY_LICENSES"), { recursive: true }); + } + + const scriptsDst = join(targetDir, "scripts"); + mkdirSync(scriptsDst, { recursive: true }); + for (const script of [ + "reset-owner-password.mjs", + "rotate-owner-setup-token.mjs", + "portable-backup.mjs", + "portable-restore.mjs" + ]) { + const src = join(root, "scripts", script); + if (existsSync(src)) cpSync(src, join(scriptsDst, script)); + } + + for (const app of ["api", "worker", "web"]) { + const appSrc = join(root, "apps", app); + if (!existsSync(appSrc)) continue; + const appDst = join(targetDir, "apps", app); + mkdirSync(appDst, { recursive: true }); + if (existsSync(join(appSrc, "package.json"))) { + cpSync(join(appSrc, "package.json"), join(appDst, "package.json")); + } + if (existsSync(join(appSrc, "dist"))) { + cpSync(join(appSrc, "dist"), join(appDst, "dist"), { recursive: true }); + } + if (app === "api") { + const pyWorker = join(appSrc, "dist", "databases", "sqlite-worker.py"); + if (!existsSync(pyWorker) && existsSync(join(appSrc, "src", "databases", "sqlite-worker.py"))) { + mkdirSync(dirname(pyWorker), { recursive: true }); + cpSync(join(appSrc, "src", "databases", "sqlite-worker.py"), pyWorker); + } + } + if (app === "web") { + if (existsSync(join(appSrc, "server.mjs"))) { + cpSync(join(appSrc, "server.mjs"), join(appDst, "server.mjs")); + } + if (existsSync(join(appSrc, "public"))) { + cpSync(join(appSrc, "public"), join(appDst, "public"), { recursive: true }); + } + } + } + + const corePackages = [ + "cli-host", + "codex-app-server", + "contracts", + "db", + "gateway", + "icons", + "memory-graph", + "opencode-control", + "runtime", + "streaming" + ]; + + for (const pkg of corePackages) { + const pkgSrc = join(root, "packages", pkg); + if (!existsSync(pkgSrc)) continue; + const pkgDst = join(targetDir, "packages", pkg); + mkdirSync(pkgDst, { recursive: true }); + if (existsSync(join(pkgSrc, "package.json"))) { + cpSync(join(pkgSrc, "package.json"), join(pkgDst, "package.json")); + } + if (existsSync(join(pkgSrc, "dist"))) { + cpSync(join(pkgSrc, "dist"), join(pkgDst, "dist"), { recursive: true }); + } + if (pkg === "db" && existsSync(join(pkgSrc, "migrations"))) { + cpSync(join(pkgSrc, "migrations"), join(pkgDst, "migrations"), { recursive: true }); + } + } + + if (existsSync(join(root, "deploy", "docker"))) { + cpSync(join(root, "deploy", "docker"), join(targetDir, "deploy", "docker"), { recursive: true }); + } + + const srcNm = join(root, "node_modules"); + const dstNm = join(targetDir, "node_modules"); + mkdirSync(dstNm, { recursive: true }); + + const coreRootDeps = [ + "@ai-sdk/gateway", + "@codemirror/lang-sql", + "@fastify/compress", + "@fastify/cookie", + "@fastify/helmet", + "@fastify/multipart", + "@fastify/rate-limit", + "@fastify/websocket", + "@formkit/auto-animate", + "@noble/hashes", + "@novnc/novnc", + "@t3-oss/env-core", + "@tanstack/react-table", + "@tanstack/react-virtual", + "@temporalio/activity", + "@temporalio/client", + "@temporalio/worker", + "@temporalio/workflow", + "@types/react", + "@types/react-dom", + "@uiw/react-codemirror", + "@xmldom/xmldom", + "@xterm/addon-fit", + "@xterm/headless", + "@xterm/xterm", + "cube-motion", + "fastify", + "graphology", + "imapflow", + "jszip", + "mammoth", + "motion", + "mysql2", + "nanoid", + "pdfjs-dist", + "pg", + "react", + "react-dom", + "react-markdown", + "remark-gfm", + "sanitize-html", + "sigma", + "undici", + "zod" + ]; + + const closure = resolveDependencyClosure(srcNm, coreRootDeps); + for (const dep of closure) { + if (dep === "node-pty") continue; + const s = join(srcNm, dep); + if (existsSync(s)) { + const d = join(dstNm, dep); + mkdirSync(dirname(d), { recursive: true }); + cpSync(s, d, { recursive: true }); + } + } + + // Preserve core workspace symlinks in node_modules/@space + const spaceDst = join(dstNm, "@space"); + mkdirSync(spaceDst, { recursive: true }); + for (const pkg of corePackages) { + cpSync(join(targetDir, "packages", pkg), join(spaceDst, pkg), { recursive: true }); + } + + // Prune foreign native prebuilds from core + const foreignArch = targetArch === "arm64" ? "x64" : "arm64"; + const coreBridgeDir = join(dstNm, "@temporalio", "core-bridge"); + if (existsSync(coreBridgeDir)) { + const releases = readdirSync(coreBridgeDir); + for (const f of releases) { + if (f.includes(foreignArch) || f.includes("darwin") || f.includes("win32")) { + rmSync(join(coreBridgeDir, f), { recursive: true, force: true }); + } + } + } + + const canvasDir = join(dstNm, "@napi-rs", "canvas"); + if (existsSync(canvasDir)) { + const canvasEntries = readdirSync(canvasDir); + for (const f of canvasEntries) { + if (f.includes(foreignArch) || f.includes("darwin") || f.includes("win32")) { + rmSync(join(canvasDir, f), { recursive: true, force: true }); + } + } + } + + removeSourceMaps(targetDir); +} + +/** + * Builds all service container closures. + * @param {object} [options] + * @param {string} [options.targetArch] + * @param {string} [options.outputDir] + */ +export function buildContainerClosures({ targetArch, outputDir } = parseArgs()) { + mkdirSync(outputDir, { recursive: true }); + process.stdout.write(`Pruning container closures for ${targetArch} into ${outputDir}...\n`); + + process.stdout.write(" -> Building CLI closure...\n"); + buildCliClosure(repoRoot, join(outputDir, "cli"), targetArch); + + process.stdout.write(" -> Building Browser closure...\n"); + buildBrowserClosure(repoRoot, join(outputDir, "browser")); + + process.stdout.write(" -> Building Core closure...\n"); + buildCoreClosure(repoRoot, join(outputDir, "core"), targetArch); + + process.stdout.write("Container closures built successfully.\n"); +} + +if (process.argv[1] && fileURLToPath(import.meta.url) === process.argv[1]) { + try { + buildContainerClosures(); + } catch (err) { + process.stderr.write(`Failed to build container closures: ${err.message}\n`); + process.exit(1); + } +} diff --git a/scripts/public-export.mjs b/scripts/public-export.mjs index c7dbddb50..14ff499d3 100644 --- a/scripts/public-export.mjs +++ b/scripts/public-export.mjs @@ -96,6 +96,8 @@ const publicScripts = new Set([ "scripts/embed-image-lock.mjs", "scripts/tests/embed-image-lock.test.mjs", "scripts/tests/opencode-standalone.test.mjs", + "scripts/ci-container-smoke.mjs", + "scripts/prune-container-closures.mjs", "scripts/container-image-size-budget.mjs", "scripts/fix-build-permissions.mjs", "scripts/portable-backup.mjs", @@ -109,6 +111,8 @@ const publicScripts = new Set([ "scripts/space-hygiene-check.mjs", "scripts/trivy-report-summary.mjs", "scripts/verify-published-containers.mjs", + "scripts/tests/ci-container-smoke.test.mjs", + "scripts/tests/prune-container-closures.test.mjs", "scripts/tests/container-image-size-budget.test.mjs", "scripts/tests/mcp-hono-compatibility.test.mjs", "scripts/tests/portable-backup-restore.test.mjs", diff --git a/scripts/tests/ci-container-smoke.test.mjs b/scripts/tests/ci-container-smoke.test.mjs new file mode 100644 index 000000000..e5baf8201 --- /dev/null +++ b/scripts/tests/ci-container-smoke.test.mjs @@ -0,0 +1,13 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { parseSmokeArgs } from "../ci-container-smoke.mjs"; + +test("parseSmokeArgs parses target and image correctly", () => { + const parsed = parseSmokeArgs(["--target", "core", "--image", "spaceapp:test"]); + assert.deepEqual(parsed, { target: "core", image: "spaceapp:test" }); +}); + +test("parseSmokeArgs throws if arguments are missing", () => { + assert.throws(() => parseSmokeArgs([]), /Usage: ci-container-smoke.mjs/); + assert.throws(() => parseSmokeArgs(["--target", "core"]), /Usage: ci-container-smoke.mjs/); +}); diff --git a/scripts/tests/container-image-size-budget.test.mjs b/scripts/tests/container-image-size-budget.test.mjs index 35146497d..e3fd9f93d 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: 850 * mebibyte, + browser: 1350 * mebibyte, + cli: 1000 * mebibyte }); }); diff --git a/scripts/tests/prune-container-closures.test.mjs b/scripts/tests/prune-container-closures.test.mjs new file mode 100644 index 000000000..6807443a9 --- /dev/null +++ b/scripts/tests/prune-container-closures.test.mjs @@ -0,0 +1,67 @@ +import assert from "node:assert/strict"; +import { existsSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import test from "node:test"; +import { + buildBrowserClosure, + buildCliClosure, + buildCoreClosure, + parseArgs, + removeSourceMaps, + resolveDependencyClosure +} from "../prune-container-closures.mjs"; + +test("parseArgs defaults to system architecture and default closures dir", () => { + const args = parseArgs([]); + assert.ok(args.targetArch === "amd64" || args.targetArch === "arm64"); + assert.ok(args.outputDir.endsWith("closures")); +}); + +test("parseArgs honors --targetarch and --output-dir flags", () => { + const args = parseArgs(["--targetarch", "arm64", "--output-dir", "custom-closures"]); + assert.equal(args.targetArch, "arm64"); + assert.ok(args.outputDir.endsWith("custom-closures")); +}); + +test("removeSourceMaps removes .map files recursively and leaves other files intact", () => { + const dir = mkdtempSync(join(tmpdir(), "spaceapp-test-sourcemaps-")); + try { + const sub = join(dir, "nested"); + mkdirSync(sub); + writeFileSync(join(dir, "bundle.js"), "console.log(1)"); + writeFileSync(join(dir, "bundle.js.map"), "{}"); + writeFileSync(join(sub, "sub.mjs"), "export const a = 1;"); + writeFileSync(join(sub, "sub.mjs.map"), "{}"); + + removeSourceMaps(dir); + + assert.equal(existsSync(join(dir, "bundle.js")), true); + assert.equal(existsSync(join(dir, "bundle.js.map")), false); + assert.equal(existsSync(join(sub, "sub.mjs")), true); + assert.equal(existsSync(join(sub, "sub.mjs.map")), false); + } finally { + rmSync(dir, { recursive: true, force: true }); + } +}); + +test("resolveDependencyClosure walks package.json dependency trees", () => { + const nm = mkdtempSync(join(tmpdir(), "spaceapp-test-closure-")); + try { + const pkgA = join(nm, "pkg-a"); + const pkgB = join(nm, "pkg-b"); + const pkgC = join(nm, "pkg-c"); + mkdirSync(pkgA); + mkdirSync(pkgB); + mkdirSync(pkgC); + + writeFileSync(join(pkgA, "package.json"), JSON.stringify({ dependencies: { "pkg-b": "^1.0.0" } })); + writeFileSync(join(pkgB, "package.json"), JSON.stringify({ dependencies: { "pkg-c": "^1.0.0" } })); + writeFileSync(join(pkgC, "package.json"), JSON.stringify({ dependencies: {} })); + + const resolved = resolveDependencyClosure(nm, ["pkg-a"]); + assert.deepEqual(Array.from(resolved).sort(), ["pkg-a", "pkg-b", "pkg-c"]); + } finally { + rmSync(nm, { recursive: true, force: true }); + } +});