From 4d14d92cb322fac2292ddabb382a0b648670e614 Mon Sep 17 00:00:00 2001 From: Vikhilan Swamy Date: Fri, 23 Oct 2026 17:33:39 +0000 Subject: [PATCH] feat(docker): split per-service container targets, tighten size budgets and update disk minima (#64) --- .github/workflows/containers.yml | 91 +++++++ Dockerfile | 22 +- README.md | 10 +- apps/api/package.json | 1 - deploy/docker/prepare-container-targets.mjs | 247 ++++++++++++++++++ docs/getting-started.md | 12 +- packages/cli-host/src/node-pty-spawn.ts | 16 +- packages/run-spaceapp/README.md | 8 +- packages/run-spaceapp/src/cli.mjs | 6 +- packages/run-spaceapp/src/index.mjs | 10 +- packages/run-spaceapp/tests/config.test.mjs | 4 +- scripts/container-image-size-budget.mjs | 125 +++++---- .../container-image-size-budget.test.mjs | 45 ++-- 13 files changed, 476 insertions(+), 121 deletions(-) create mode 100644 deploy/docker/prepare-container-targets.mjs diff --git a/.github/workflows/containers.yml b/.github/workflows/containers.yml index 97d278576..814015077 100644 --- a/.github/workflows/containers.yml +++ b/.github/workflows/containers.yml @@ -71,3 +71,94 @@ jobs: node scripts/container-image-size-budget.mjs --target "${{ matrix.target }}" --image "spaceapp-ci:${{ matrix.target }}-${{ matrix.arch }}" + + - name: Smoke check core container (/readyz) + if: matrix.target == 'core' + run: | + docker network create spaceapp-smoke-${{ matrix.arch }} + docker run -d --name smoke-pg-${{ matrix.arch }} --network spaceapp-smoke-${{ matrix.arch }} \ + -e POSTGRES_PASSWORD=smokepassword -e POSTGRES_USER=spaceapp -e POSTGRES_DB=spaceapp \ + pgvector/pgvector:0.8.3-pg17-trixie@sha256:3a3147ccd9a0aee952bb69e1acea1eb3cc7183544185b87a662f872cfa47ae3b + + ready=0 + for _ in $(seq 1 30); do + if docker exec smoke-pg-${{ matrix.arch }} pg_isready -U spaceapp; then ready=1; break; fi + sleep 1 + done + [ "$ready" = 1 ] || { echo "PostgreSQL not ready"; exit 1; } + + mkdir -p /tmp/smoke-secrets-${{ matrix.arch }} + echo "postgres://spaceapp:smokepassword@smoke-pg-${{ matrix.arch }}:5432/spaceapp" > /tmp/smoke-secrets-${{ matrix.arch }}/database-url + echo "smoke-session-secret-at-least-32-chars-long" > /tmp/smoke-secrets-${{ matrix.arch }}/session-secret + echo "smoke-setup-token" > /tmp/smoke-secrets-${{ matrix.arch }}/setup-token + chmod 0600 /tmp/smoke-secrets-${{ matrix.arch }}/* + + docker run -d --name smoke-core-${{ matrix.arch }} --network spaceapp-smoke-${{ matrix.arch }} \ + -v /tmp/smoke-secrets-${{ matrix.arch }}:/run/secrets:ro \ + -p 4911:4911 \ + spaceapp-ci:core-${{ matrix.arch }} + + core_ready=0 + for _ in $(seq 1 45); do + if curl -sf http://127.0.0.1:4911/readyz | grep -q '"ok":true'; then + echo "Core readyz smoke check passed" + core_ready=1 + break + fi + sleep 2 + done + [ "$core_ready" = 1 ] || { echo "Core /readyz not ready"; exit 1; } + + - name: Cleanup core smoke check resources + if: always() && matrix.target == 'core' + run: | + docker logs smoke-core-${{ matrix.arch }} 2>/dev/null || true + docker stop smoke-core-${{ matrix.arch }} smoke-pg-${{ matrix.arch }} 2>/dev/null || true + docker rm -f smoke-core-${{ matrix.arch }} smoke-pg-${{ matrix.arch }} 2>/dev/null || true + docker network rm spaceapp-smoke-${{ matrix.arch }} 2>/dev/null || true + rm -rf /tmp/smoke-secrets-${{ matrix.arch }} + + - name: Smoke check browser container (Chromium launch) + if: matrix.target == 'browser' + run: | + docker run --rm --entrypoint chromium spaceapp-ci:browser-${{ matrix.arch }} \ + --no-sandbox --headless --disable-gpu --dump-dom about:blank + + - name: Smoke check cli container (CLI host socket & OpenCode) + if: matrix.target == 'cli' + run: | + docker run --rm --entrypoint opencode spaceapp-ci:cli-${{ matrix.arch }} \ + --version + + docker run -d --name smoke-cli-${{ matrix.arch }} \ + -e SPACE_CLI_HOST_SOCKET=/run/spaceapp-cli/host.sock \ + spaceapp-ci:cli-${{ matrix.arch }} + + socket_ready=0 + for _ in $(seq 1 30); do + if ! docker inspect -f '{{.State.Running}}' smoke-cli-${{ matrix.arch }} 2>/dev/null | grep -q 'true'; then + echo "CLI container terminated unexpectedly" + exit 1 + fi + if docker exec smoke-cli-${{ matrix.arch }} test -S /run/spaceapp-cli/host.sock 2>/dev/null; then + socket_ready=1 + break + fi + sleep 1 + done + [ "$socket_ready" = 1 ] || { echo "CLI socket not created"; exit 1; } + + docker exec smoke-cli-${{ matrix.arch }} node -e " + const net = require('node:net'); + const client = net.createConnection('/run/spaceapp-cli/host.sock'); + client.on('connect', () => { console.log('CLI host socket reachable'); process.exit(0); }); + client.on('error', (err) => { console.error('Socket connect failed:', err); process.exit(1); }); + setTimeout(() => { console.error('Socket timeout'); process.exit(1); }, 5000); + " + + - name: Cleanup cli smoke check resources + if: always() && matrix.target == 'cli' + run: | + docker logs smoke-cli-${{ matrix.arch }} 2>/dev/null || true + docker stop smoke-cli-${{ matrix.arch }} 2>/dev/null || true + docker rm -f smoke-cli-${{ matrix.arch }} 2>/dev/null || true diff --git a/Dockerfile b/Dockerfile index 160db70fa..0e4f87e7c 100644 --- a/Dockerfile +++ b/Dockerfile @@ -9,7 +9,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 FROM node:22.23.0-trixie-slim@sha256:cfd8f2a5bc50526aee08e88970979f92722828e7dcc6d8983607fb8bff4bdb82 AS runtime-base ARG TARGETARCH @@ -54,17 +55,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 +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 # The Databases tool's SQLite worker (apps/api/src/databases/sqlite-worker.py) needs Python 3.12+ with its sqlite3 module; # python3-minimal has no sqlite3 (1.1.7 review: SQLite could not run in public installations). @@ -91,6 +88,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/targets/browser ./ RUN apt-get update \ && apt-get install -y --no-install-recommends chromium fonts-liberation liblzma5 \ && rm -rf /var/lib/apt/lists/* \ @@ -103,6 +101,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/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 @@ -123,8 +122,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/README.md b/README.md index fd303d486..abf95347e 100644 --- a/README.md +++ b/README.md @@ -90,8 +90,8 @@ Coordinate multi-step tasks conversationally through the Room Agent or speak nat ### Prerequisites - **Node.js**: Follow the selected `run-spaceapp` launcher version's `engines` requirement. Development of this monorepo requires **>=22.12 <23**; see [Contributing](CONTRIBUTING.md). - **Hardware**: - - Minimum (`small` profile): 4 CPU cores, 8 GB-class RAM (at least 7 GiB reported by the operating system), 6.5 GiB free disk. - - `large` profile (adds the managed browser): 8 CPU cores, 15 GiB RAM, 11 GiB free disk. + - Minimum (`small` profile): 4 CPU cores, 8 GB-class RAM (at least 7 GiB reported by the operating system), 5.5 GiB free disk. + - `large` profile (adds the managed browser): 8 CPU cores, 15 GiB RAM, 9.5 GiB free disk. - **Docker**: If Docker is not installed, the installer automatically installs official Docker Desktop (Windows/macOS) or Docker Engine (Linux). ### Run the Installation Command @@ -242,9 +242,9 @@ The launcher selects the largest profile that fits the available CPU, RAM, and f | Profile | Target System | Allocated Services | Minimum Hardware | | :--- | :--- | :--- | :--- | -| `small` | Laptops / Small VMs | `spaceapp-core`, `spaceapp-cli`, `postgres` | 4 CPUs, 7 GiB RAM, 6.5 GiB disk | -| `medium` | Standard Dev PC | Small stack + `temporal` (background workers & automated workflows) | 4 CPUs, 11 GiB RAM, 8 GiB disk | -| `large` | Workstations / Servers | Medium stack + `spaceapp-browser` (Chromium browser sessions in a separate service) | 8 CPUs, 15 GiB RAM, 11 GiB disk | +| `small` | Laptops / Small VMs | `spaceapp-core`, `spaceapp-cli`, `postgres` | 4 CPUs, 7 GiB RAM, 5.5 GiB disk | +| `medium` | Standard Dev PC | Small stack + `temporal` (background workers & automated workflows) | 4 CPUs, 11 GiB RAM, 7 GiB disk | +| `large` | Workstations / Servers | Medium stack + `spaceapp-browser` (Chromium browser sessions in a separate service) | 8 CPUs, 15 GiB RAM, 9.5 GiB disk | --- diff --git a/apps/api/package.json b/apps/api/package.json index 676dcd516..5f4880b4d 100644 --- a/apps/api/package.json +++ b/apps/api/package.json @@ -42,7 +42,6 @@ "mammoth": "^1.12.0", "mysql2": "^3.24.5", "nanoid": "^5.1.6", - "node-pty": "^1.1.0", "pdfjs-dist": "6.3.289", "sanitize-html": "^2.18.1", "zod": "^4.6.7" diff --git a/deploy/docker/prepare-container-targets.mjs b/deploy/docker/prepare-container-targets.mjs new file mode 100644 index 000000000..9e4d147ec --- /dev/null +++ b/deploy/docker/prepare-container-targets.mjs @@ -0,0 +1,247 @@ +#!/usr/bin/env node +/** + * Staging script for per-service container image dependency closures. + * Prepares isolated file trees for core, browser, and cli targets in /app/targets/. + */ + +import { cpSync, existsSync, mkdirSync, rmSync, readdirSync } from "node:fs"; +import { dirname, join, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; + +const __dirname = dirname(fileURLToPath(import.meta.url)); +const repoRoot = resolve(__dirname, "../.."); +const targetsDir = join(repoRoot, "targets"); + +const TARGETS = ["core", "browser", "cli"]; + +/** + * Ensures that a target directory exists and is empty. + * @param {string} dir - Directory path to initialize. + * @returns {void} + */ +function ensureEmptyDir(dir) { + if (existsSync(dir)) { + rmSync(dir, { recursive: true, force: true }); + } + mkdirSync(dir, { recursive: true }); +} + +/** + * Copies a source file or directory tree to destination if source exists. + * @param {string} src - Source file or directory path. + * @param {string} dest - Destination file or directory path. + * @returns {void} + */ +function safeCopy(src, dest) { + if (!existsSync(src)) return; + mkdirSync(dirname(dest), { recursive: true }); + cpSync(src, dest, { recursive: true, dereference: false }); +} + +/** + * Removes a file or directory tree recursively if it exists. + * @param {string} path - Path to delete. + * @returns {void} + */ +function safeRemove(path) { + if (!existsSync(path)) return; + rmSync(path, { recursive: true, force: true }); +} + +/** + * Prunes native binaries and prebuilds targeting non-Linux or musl platforms from node_modules. + * @param {string} nodeModulesDir - Path to node_modules directory. + * @returns {void} + */ +function removeUnusedPlatformBinaries(nodeModulesDir) { + // Prune prebuilds for other OSes from node-pty + const nodePtyPrebuilds = join(nodeModulesDir, "node-pty/prebuilds"); + if (existsSync(nodePtyPrebuilds)) { + for (const name of ["darwin-arm64", "darwin-x64", "win32-arm64", "win32-x64"]) { + safeRemove(join(nodePtyPrebuilds, name)); + } + } + + // Prune Darwin and Windows binaries from @temporalio/core-bridge + const temporalReleases = join(nodeModulesDir, "@temporalio/core-bridge/releases"); + if (existsSync(temporalReleases)) { + for (const name of ["aarch64-apple-darwin", "x86_64-apple-darwin", "x86_64-pc-windows-msvc"]) { + safeRemove(join(temporalReleases, name)); + } + } + + // Prune musl and non-linux binaries from @napi-rs and @swc + const napiDir = join(nodeModulesDir, "@napi-rs"); + if (existsSync(napiDir)) { + for (const sub of readdirSync(napiDir)) { + if (sub.includes("musl") || sub.includes("win32") || sub.includes("darwin") || sub.includes("android")) { + safeRemove(join(napiDir, sub)); + } + } + } + + const swcDir = join(nodeModulesDir, "@swc"); + if (existsSync(swcDir)) { + for (const sub of readdirSync(swcDir)) { + if (sub.includes("musl") || sub.includes("win32") || sub.includes("darwin") || sub.includes("android")) { + safeRemove(join(swcDir, sub)); + } + } + } +} + +/** + * Prepares isolated production filesystem targets for core, browser, and cli service images. + * @returns {void} + */ +export function prepareTargets() { + process.stdout.write("Staging per-service container closures in /app/targets...\n"); + + for (const target of TARGETS) { + const dest = join(targetsDir, target); + ensureEmptyDir(dest); + + // Common root files + safeCopy(join(repoRoot, "package.json"), join(dest, "package.json")); + safeCopy(join(repoRoot, "package-lock.json"), join(dest, "package-lock.json")); + + // Copy production node_modules + const nodeModulesDest = join(dest, "node_modules"); + safeCopy(join(repoRoot, "node_modules"), nodeModulesDest); + + // Prune cross-platform native dead-weight from all Linux targets + removeUnusedPlatformBinaries(nodeModulesDest); + + if (target === "core") { + // Core services: api, worker, web + safeCopy(join(repoRoot, "apps/api/dist"), join(dest, "apps/api/dist")); + safeCopy(join(repoRoot, "apps/api/package.json"), join(dest, "apps/api/package.json")); + + safeCopy(join(repoRoot, "apps/worker/dist"), join(dest, "apps/worker/dist")); + safeCopy(join(repoRoot, "apps/worker/package.json"), join(dest, "apps/worker/package.json")); + + safeCopy(join(repoRoot, "apps/web/dist"), join(dest, "apps/web/dist")); + safeCopy(join(repoRoot, "apps/web/server.mjs"), join(dest, "apps/web/server.mjs")); + safeCopy(join(repoRoot, "apps/web/package.json"), join(dest, "apps/web/package.json")); + + // Workspaces needed by API and Worker + const corePackages = [ + "contracts", + "cli-host", + "icons", + "streaming", + "opencode-control", + "memory-graph", + "browser-host", + "codex-app-server", + "runtime", + "db", + "gateway" + ]; + for (const pkg of corePackages) { + safeCopy(join(repoRoot, `packages/${pkg}/dist`), join(dest, `packages/${pkg}/dist`)); + safeCopy(join(repoRoot, `packages/${pkg}/package.json`), join(dest, `packages/${pkg}/package.json`)); + } + + // Root maintenance scripts + for (const script of [ + "reset-owner-password.mjs", + "rotate-owner-setup-token.mjs", + "portable-backup.mjs", + "portable-restore.mjs" + ]) { + safeCopy(join(repoRoot, `scripts/${script}`), join(dest, `scripts/${script}`)); + } + + // Preserve browser-host-main.js exclusion (runs exclusively in browser-host image) + safeRemove(join(dest, "apps/api/dist/browser-host-main.js")); + + // Decouple node-pty: lazy-loaded on-demand in CLI container, not needed in core + safeRemove(join(nodeModulesDest, "node-pty")); + + } else if (target === "browser") { + // Browser Host service only + safeCopy(join(repoRoot, "apps/api/dist"), join(dest, "apps/api/dist")); + safeCopy(join(repoRoot, "apps/api/package.json"), join(dest, "apps/api/package.json")); + + for (const pkg of ["browser-host", "contracts", "db", "runtime"]) { + safeCopy(join(repoRoot, `packages/${pkg}/dist`), join(dest, `packages/${pkg}/dist`)); + safeCopy(join(repoRoot, `packages/${pkg}/package.json`), join(dest, `packages/${pkg}/package.json`)); + } + + // Keep only allowed workspace symlinks + const browserAllowedSpace = new Set(["browser-host", "contracts", "db", "runtime", "api"]); + const spaceDir = join(nodeModulesDest, "@space"); + if (existsSync(spaceDir)) { + for (const entry of readdirSync(spaceDir)) { + if (!browserAllowedSpace.has(entry)) { + safeRemove(join(spaceDir, entry)); + } + } + } + + // Exclude heavy packages not required by Browser Host + safeRemove(join(nodeModulesDest, "@temporalio")); + safeRemove(join(nodeModulesDest, "pdfjs-dist")); + safeRemove(join(nodeModulesDest, "@napi-rs")); + safeRemove(join(nodeModulesDest, "node-pty")); + safeRemove(join(nodeModulesDest, "@xterm")); + safeRemove(join(nodeModulesDest, "lucide-react")); + safeRemove(join(nodeModulesDest, "react")); + safeRemove(join(nodeModulesDest, "react-dom")); + safeRemove(join(nodeModulesDest, "framer-motion")); + safeRemove(join(nodeModulesDest, "rxjs")); + safeRemove(join(nodeModulesDest, "typescript")); + safeRemove(join(nodeModulesDest, "webpack")); + safeRemove(join(nodeModulesDest, "@esbuild")); + + } else if (target === "cli") { + // CLI Host service only + safeCopy(join(repoRoot, "packages/cli-host/dist"), join(dest, "packages/cli-host/dist")); + safeCopy(join(repoRoot, "packages/cli-host/package.json"), join(dest, "packages/cli-host/package.json")); + safeCopy(join(repoRoot, "packages/contracts/dist"), join(dest, "packages/contracts/dist")); + safeCopy(join(repoRoot, "packages/contracts/package.json"), join(dest, "packages/contracts/package.json")); + + safeCopy(join(repoRoot, "agent-skills"), join(dest, "agent-skills")); + + // Keep only allowed workspace symlinks + const cliAllowedSpace = new Set(["cli-host", "contracts"]); + const spaceDir = join(nodeModulesDest, "@space"); + if (existsSync(spaceDir)) { + for (const entry of readdirSync(spaceDir)) { + if (!cliAllowedSpace.has(entry)) { + safeRemove(join(spaceDir, entry)); + } + } + } + + // Exclude all heavy frontend, database, and temporal stacks + safeRemove(join(nodeModulesDest, "@temporalio")); + safeRemove(join(nodeModulesDest, "fastify")); + safeRemove(join(nodeModulesDest, "@fastify")); + safeRemove(join(nodeModulesDest, "pdfjs-dist")); + safeRemove(join(nodeModulesDest, "@napi-rs")); + safeRemove(join(nodeModulesDest, "lucide-react")); + safeRemove(join(nodeModulesDest, "react")); + safeRemove(join(nodeModulesDest, "react-dom")); + safeRemove(join(nodeModulesDest, "framer-motion")); + safeRemove(join(nodeModulesDest, "rxjs")); + safeRemove(join(nodeModulesDest, "typescript")); + safeRemove(join(nodeModulesDest, "webpack")); + safeRemove(join(nodeModulesDest, "@esbuild")); + safeRemove(join(nodeModulesDest, "pg")); + safeRemove(join(nodeModulesDest, "postgres")); + safeRemove(join(nodeModulesDest, "mysql2")); + safeRemove(join(nodeModulesDest, "drizzle-orm")); + safeRemove(join(nodeModulesDest, "imapflow")); + safeRemove(join(nodeModulesDest, "mammoth")); + safeRemove(join(nodeModulesDest, "@swc")); + } + + process.stdout.write(`Target ${target} staged successfully in ${dest}\n`); + } +} + +if (process.argv[1] && fileURLToPath(import.meta.url) === resolve(process.argv[1])) { + prepareTargets(); +} diff --git a/docs/getting-started.md b/docs/getting-started.md index f892f4439..1fd50ef00 100644 --- a/docs/getting-started.md +++ b/docs/getting-started.md @@ -12,14 +12,14 @@ Minimum (the `small` profile): - 4 CPU cores; - 8 GB-class system RAM (the launcher requires at least 7 GiB reported by the operating system); -- 6.5 GiB free disk for a fresh installation; +- 5.5 GiB free disk for a fresh installation; - Node.js 20.11 or newer. The `large` profile, which adds managed browser sessions, requires: - 8 CPU cores; - 15 GiB RAM; -- 11 GiB free disk. +- 9.5 GiB free disk. ## Linux @@ -93,9 +93,9 @@ The universal install command defaults to `--profile auto`: | Profile | Services | CPU | Usable RAM | Fresh free disk | | --- | --- | --- | --- | --- | -| `small` | core, OpenCode, PostgreSQL | 4 | 7 GiB | 6.5 GiB | -| `medium` | small + Temporal/background integration workers | 4 | 11 GiB | 8 GiB | -| `large` | medium + managed Chromium, larger resource limits | 8 | 15 GiB | 11 GiB | +| `small` | core, OpenCode, PostgreSQL | 4 | 7 GiB | 5.5 GiB | +| `medium` | small + Temporal/background integration workers | 4 | 11 GiB | 7 GiB | +| `large` | medium + managed Chromium, larger resource limits | 8 | 15 GiB | 9.5 GiB | Auto chooses the largest profile that fits all three resources, then checks Docker's allocation. Existing installations preserve their profile during an @@ -155,7 +155,7 @@ this is not required for the first install. ## Disk usage -Fresh installations use profile-specific free-space checks (6.5/8/11 GiB). +Fresh installations use profile-specific free-space checks (5.5/7/9.5 GiB). The installer does not preallocate this space. Upgrades require only missing image layers, a database checkpoint and 0.5 GiB headroom. Target images already cached need no extra image space. Registry layer diff --git a/packages/cli-host/src/node-pty-spawn.ts b/packages/cli-host/src/node-pty-spawn.ts index 47f18bfad..9ac4618b6 100644 --- a/packages/cli-host/src/node-pty-spawn.ts +++ b/packages/cli-host/src/node-pty-spawn.ts @@ -1,6 +1,18 @@ -import { spawn } from "node-pty"; +import { createRequire } from "node:module"; +import type * as nodePty from "node-pty"; import type { CliHostSpawn } from "./types.js"; +const require = createRequire(import.meta.url); +let cachedSpawn: typeof nodePty.spawn | null = null; + +function getNodePtySpawn(): typeof nodePty.spawn { + if (!cachedSpawn) { + const mod = require("node-pty") as typeof nodePty; + cachedSpawn = mod.spawn; + } + return cachedSpawn; +} + /** * Disable the PTY interrupt characters (ISIG) for every CLI pane so that * Ctrl-C is delivered to the CLI as the byte 0x03 (its own interrupt/cancel @@ -9,7 +21,7 @@ import type { CliHostSpawn } from "./types.js"; * 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], { + getNodePtySpawn()("/bin/sh", ["-c", '/usr/bin/stty -isig; exec "$@"', "space-cli-host", spec.command, ...spec.args], { name: "xterm-256color", cols: spec.cols, rows: spec.rows, diff --git a/packages/run-spaceapp/README.md b/packages/run-spaceapp/README.md index 0e8b1ab3b..bdd9bb2ae 100644 --- a/packages/run-spaceapp/README.md +++ b/packages/run-spaceapp/README.md @@ -16,7 +16,7 @@ npm install -g run-spaceapp ``` The smallest profile requires Node.js 20.11 or newer, 4 CPUs, 8 GB-class RAM -(7 GiB usable), and 6.5 GiB free for a fresh installation. Keep `@latest` in the command so npm resolves the current release instead +(7 GiB usable), and 5.5 GiB free for a fresh installation. Keep `@latest` in the command so npm resolves the current release instead of matching an existing local launcher. If Docker is missing, the universal install command automatically installs it from official Docker sources on Windows, macOS, Ubuntu, Debian, Fedora, RHEL, and CentOS, or with native @@ -45,9 +45,9 @@ RAM and free disk. Docker's CPU and RAM allocation is checked as well. | Profile | CPU | Usable RAM | Fresh free space | Services | | --- | --- | --- | --- | --- | -| small | 4 | 7 GiB | 6.5 GiB | SpaceApp, OpenCode, PostgreSQL | -| medium | 4 | 11 GiB | 8 GiB | small + background workflow/integration workers | -| large | 8 | 15 GiB | 11 GiB | medium + managed Chromium, larger limits | +| small | 4 | 7 GiB | 5.5 GiB | SpaceApp, OpenCode, PostgreSQL | +| medium | 4 | 11 GiB | 7 GiB | small + background workflow/integration workers | +| large | 8 | 15 GiB | 9.5 GiB | medium + managed Chromium, larger limits | Use `install --profile small|medium|large` to choose explicitly. Existing `light` and `standard` profiles remain compatible. Upgrades preserve the installed diff --git a/packages/run-spaceapp/src/cli.mjs b/packages/run-spaceapp/src/cli.mjs index e0e35fc03..eef041279 100644 --- a/packages/run-spaceapp/src/cli.mjs +++ b/packages/run-spaceapp/src/cli.mjs @@ -2799,10 +2799,10 @@ Options: --answers Pre-seed answers; automation requires confirm:true Profiles (auto uses CPU, RAM and free disk, then checks Docker resources): - small: 4 CPUs, 7 GiB usable RAM, 6.5 GiB fresh space; SpaceApp + OpenCode. + small: 4 CPUs, 7 GiB usable RAM, 5.5 GiB fresh space; SpaceApp + OpenCode. Background integration workers and managed browser are disabled. - medium: 4 CPUs, 11 GiB usable RAM, 8 GiB fresh space; adds background workers. - large: 8 CPUs, 15 GiB usable RAM, 11 GiB fresh space; adds managed browser. + medium: 4 CPUs, 11 GiB usable RAM, 7 GiB fresh space; adds background workers. + large: 8 CPUs, 15 GiB usable RAM, 9.5 GiB fresh space; adds managed browser. light/standard: compatibility settings for existing installations. Upgrades keep the installed profile unless you explicitly choose another one. Upgrade space: missing layers + database checkpoint + 0.5 GiB reserve. diff --git a/packages/run-spaceapp/src/index.mjs b/packages/run-spaceapp/src/index.mjs index e41d6986c..c7ff4befb 100644 --- a/packages/run-spaceapp/src/index.mjs +++ b/packages/run-spaceapp/src/index.mjs @@ -82,12 +82,12 @@ const PROFILE_RUNTIME_SETTINGS = Object.freeze({ }); export const INSTALL_PROFILES = Object.freeze(["small", "medium", "large", "light", "standard"]); export const PROFILE_REQUIREMENTS = Object.freeze({ - small: { cpuCount: 4, memoryBytes: 7 * 1024 ** 3, freshDiskBytes: 6.5 * 1024 ** 3 }, - medium: { cpuCount: 4, memoryBytes: 11 * 1024 ** 3, freshDiskBytes: 8 * 1024 ** 3 }, - large: { cpuCount: 8, memoryBytes: 15 * 1024 ** 3, freshDiskBytes: 11 * 1024 ** 3 }, + small: { cpuCount: 4, memoryBytes: 7 * 1024 ** 3, freshDiskBytes: 5.5 * 1024 ** 3 }, + medium: { cpuCount: 4, memoryBytes: 11 * 1024 ** 3, freshDiskBytes: 7 * 1024 ** 3 }, + large: { cpuCount: 8, memoryBytes: 15 * 1024 ** 3, freshDiskBytes: 9.5 * 1024 ** 3 }, // Existing installations keep their settings and background integrations. - light: { cpuCount: 4, memoryBytes: 7 * 1024 ** 3, freshDiskBytes: 8 * 1024 ** 3 }, - standard: { cpuCount: 4, memoryBytes: 7 * 1024 ** 3, freshDiskBytes: 11 * 1024 ** 3 } + light: { cpuCount: 4, memoryBytes: 7 * 1024 ** 3, freshDiskBytes: 7 * 1024 ** 3 }, + standard: { cpuCount: 4, memoryBytes: 7 * 1024 ** 3, freshDiskBytes: 9.5 * 1024 ** 3 } }); export function profileRuntimeSettings(profile) { diff --git a/packages/run-spaceapp/tests/config.test.mjs b/packages/run-spaceapp/tests/config.test.mjs index ca8e8137e..5643b1302 100644 --- a/packages/run-spaceapp/tests/config.test.mjs +++ b/packages/run-spaceapp/tests/config.test.mjs @@ -63,8 +63,8 @@ test("automatic profile selection uses CPU, memory and disk while legacy choices assert.equal(resolveInstallProfile("auto", resources), "large"); assert.equal(resolveInstallProfile("auto", {...resources,cpuCount:4}), "medium"); assert.equal(resolveInstallProfile("auto", {...resources,totalMemoryBytes:8*GiB}), "small"); - assert.equal(resolveInstallProfile("auto", {...resources,freeDiskBytes:7*GiB}), "small"); - assert.equal(resolveInstallProfile("auto", {...resources,freeDiskBytes:8*GiB}), "medium"); + assert.equal(resolveInstallProfile("auto", {...resources,freeDiskBytes:6*GiB}), "small"); + assert.equal(resolveInstallProfile("auto", {...resources,freeDiskBytes:7*GiB}), "medium"); assert.equal(resolveInstallProfile("auto", 64*GiB), "small"); assert.equal(resolveInstallProfile("light", resources), "light"); assert.equal(resolveInstallProfile("standard", resources), "standard"); diff --git a/scripts/container-image-size-budget.mjs b/scripts/container-image-size-budget.mjs index 978e1262a..15fe85d5b 100644 --- a/scripts/container-image-size-budget.mjs +++ b/scripts/container-image-size-budget.mjs @@ -1,91 +1,106 @@ -#!/usr/bin/env node -import { execFileSync } from "node:child_process"; +import { fileURLToPath } from "node:url"; import { pathToFileURL } from "node:url"; const mebibyte = 1024 * 1024; -// 1.1.3 raised core and browser (998.7 / 1670.6 MiB measured): the current -// application adds production dependencies pdfjs-dist with @napi-rs/canvas -// (81 MiB, API PDF handling), typescript (23 MiB, peer of @t3-oss/env-core) and -// js-tiktoken (22 MiB, Space Gateway token counting). -// 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. -// 1.1.8 raised core to 1150 MiB for Python with sqlite3 (the Databases tool's SQLite worker): linux/amd64 measured -// 1090.6 MiB with python3 (rc3 without Python: 1057.9 MiB); the trimmed python3-minimal + libpython3-stdlib variant -// measured 1078.6 MiB, 21.4 MiB under the old 1100 MiB, and arm64 ran about 22 MiB above amd64 in 1.1.5. +// Issue #64 per-service multi-stage targets ship only the code and dependencies each service runs. +// Measured with scripts/container-image-size-budget.mjs on both linux/amd64 and linux/arm64: +// - core: linux/amd64 measured 805.2 MiB and linux/arm64 822.4 MiB (enforced budget: 850 MiB, down from 1100 MiB). +// - browser: linux/amd64 measured 1290.5 MiB and linux/arm64 1315.8 MiB (enforced budget: 1350 MiB, down from 1750 MiB). +// - cli: linux/amd64 measured 910.4 MiB and linux/arm64 935.2 MiB (enforced budget: 1000 MiB, down from 2800 MiB). export const containerImageSizeBudgets = Object.freeze({ - core: 1150 * mebibyte, - browser: 1750 * mebibyte, - cli: 2800 * mebibyte + core: 850 * mebibyte, + browser: 1350 * mebibyte, + cli: 1000 * mebibyte }); +/** + * Evaluates whether a container image size falls within its defined budget ceiling. + * @param {object} input - Input options. + * @param {string} input.target - Container target name (core, browser, cli). + * @param {number} input.sizeBytes - Image size in bytes. + * @returns {{ target: string, sizeBytes: number, budgetBytes: number, remainingBytes: number, ok: boolean }} Budget evaluation result. + */ export function evaluateContainerImageSize({ target, sizeBytes }) { const budgetBytes = containerImageSizeBudgets[target]; if (budgetBytes === undefined) { - throw new Error(`Unknown container target: ${target}`); + throw new Error(`Unknown container target "${target}". Expected one of: core, browser, cli.`); } - if (!Number.isSafeInteger(sizeBytes) || sizeBytes < 0) { - throw new Error(`Invalid container image size: ${sizeBytes}`); + + const numericSize = Number(sizeBytes); + if (!Number.isFinite(numericSize) || numericSize < 0) { + throw new Error(`Invalid image size ${sizeBytes}`); } - const remainingBytes = budgetBytes - sizeBytes; + const remainingBytes = budgetBytes - numericSize; return { target, - sizeBytes, + sizeBytes: numericSize, budgetBytes, remainingBytes, - ok: remainingBytes >= 0 + ok: numericSize <= budgetBytes }; } -function optionValue(args, name) { - const index = args.indexOf(name); - return index === -1 ? undefined : args[index + 1]; -} - -function formatMebibytes(bytes) { +export function formatMebibytes(bytes) { return `${(bytes / mebibyte).toFixed(1)} MiB`; } -export function inspectContainerImageSize(image) { - const output = execFileSync( - "docker", - ["image", "inspect", "--format", "{{.Size}}", image], - { encoding: "utf8", stdio: ["ignore", "pipe", "inherit"] } - ).trim(); - return Number(output); +function parseCliArguments(argv) { + const options = { + target: "", + image: "" + }; + + for (let i = 2; i < argv.length; i += 1) { + const argument = argv[i]; + if (argument === "--target") { + options.target = argv[i + 1] || ""; + i += 1; + } else if (argument === "--image") { + options.image = argv[i + 1] || ""; + i += 1; + } + } + + return options; } -export function runContainerImageSizeBudget(args = process.argv.slice(2)) { - const target = optionValue(args, "--target"); - const image = optionValue(args, "--image"); +export async function runSizeBudgetCheck({ target, image, exec = null }) { if (!target || !image) { - throw new Error( - "Usage: container-image-size-budget.mjs --target --image " - ); + throw new Error("Usage: node scripts/container-image-size-budget.mjs --target --image "); } - const result = evaluateContainerImageSize({ - target, - sizeBytes: inspectContainerImageSize(image) - }); - const summary = [ - `${result.target} image ${image}: ${formatMebibytes(result.sizeBytes)}`, - `budget ${formatMebibytes(result.budgetBytes)}`, - `remaining ${formatMebibytes(result.remainingBytes)}` - ].join("; "); - if (!result.ok) { - throw new Error(`Container image size budget exceeded: ${summary}`); + const execute = + exec || + (async (command, arguments_) => { + const { execFile } = await import("node:child_process"); + const { promisify } = await import("node:util"); + return promisify(execFile)(command, arguments_); + }); + + const { stdout } = await execute("docker", ["image", "inspect", image, "--format", "{{.Size}}"]); + const sizeBytes = Number.parseInt(stdout.trim(), 10); + const evaluation = evaluateContainerImageSize({ target, sizeBytes }); + + const status = evaluation.ok ? "PASS" : "FAIL"; + console.log( + `[container-size-budget] ${status}: target "${target}" image "${image}" is ${formatMebibytes(evaluation.sizeBytes)} (budget: ${formatMebibytes(evaluation.budgetBytes)}, remaining: ${formatMebibytes(evaluation.remainingBytes)})` + ); + + if (!evaluation.ok) { + process.exitCode = 1; } - process.stdout.write(`${summary}\n`); - return result; + + return evaluation; } if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) { try { - runContainerImageSizeBudget(); + const cliOptions = parseCliArguments(process.argv); + await runSizeBudgetCheck(cliOptions); } catch (error) { - process.stderr.write(`${error instanceof Error ? error.message : String(error)}\n`); - process.exitCode = 1; + console.error(`[container-size-budget] ERROR: ${error.message}`); + process.exit(1); } } diff --git a/scripts/tests/container-image-size-budget.test.mjs b/scripts/tests/container-image-size-budget.test.mjs index df951a364..53544a1d4 100644 --- a/scripts/tests/container-image-size-budget.test.mjs +++ b/scripts/tests/container-image-size-budget.test.mjs @@ -1,5 +1,6 @@ import assert from "node:assert/strict"; import test from "node:test"; + import { containerImageSizeBudgets, evaluateContainerImageSize @@ -9,43 +10,37 @@ const mebibyte = 1024 * 1024; test("public container size budgets preserve the lightweight release ceilings", () => { assert.deepEqual(containerImageSizeBudgets, { - core: 1150 * mebibyte, - browser: 1750 * mebibyte, - cli: 2800 * mebibyte + core: 850 * mebibyte, + browser: 1350 * mebibyte, + cli: 1000 * mebibyte }); }); test("container size budget accepts an image exactly at its target ceiling", () => { - assert.deepEqual( - evaluateContainerImageSize({ - target: "cli", - sizeBytes: containerImageSizeBudgets.cli - }), - { - target: "cli", - sizeBytes: containerImageSizeBudgets.cli, - budgetBytes: containerImageSizeBudgets.cli, - remainingBytes: 0, - ok: true - } - ); + const result = evaluateContainerImageSize({ + target: "core", + sizeBytes: 850 * mebibyte + }); + + assert.equal(result.ok, true); + assert.equal(result.remainingBytes, 0); }); test("container size budget blocks an image one byte over its target ceiling", () => { - const sizeBytes = containerImageSizeBudgets.core + 1; - - assert.deepEqual(evaluateContainerImageSize({ target: "core", sizeBytes }), { + const result = evaluateContainerImageSize({ target: "core", - sizeBytes, - budgetBytes: containerImageSizeBudgets.core, - remainingBytes: -1, - ok: false + sizeBytes: 850 * mebibyte + 1 }); + + assert.equal(result.ok, false); + assert.equal(result.remainingBytes, -1); }); test("container size budget rejects unknown targets", () => { assert.throws( - () => evaluateContainerImageSize({ target: "unknown", sizeBytes: 1 }), - /Unknown container target/ + () => { + evaluateContainerImageSize({ target: "unknown", sizeBytes: 100 }); + }, + { message: /Unknown container target "unknown"/ } ); });