From 61d68d2b095d3b884f874f4960ef6ac7de77b518 Mon Sep 17 00:00:00 2001 From: Luke Snopkiewicz Date: Tue, 27 Oct 2026 23:16:42 +0000 Subject: [PATCH 01/10] feat(launcher): support rootless Podman Signed-off-by: Luke Snopkiewicz --- .github/workflows/podman.yml | 75 +++++++ README.md | 11 +- apps/api/tests/app-studio.test.ts | 5 +- apps/api/tests/mail-copilot-sync.test.ts | 4 +- docs/getting-started.md | 46 ++++- packages/run-spaceapp/README.md | 51 ++++- packages/run-spaceapp/package.json | 2 +- packages/run-spaceapp/src/cli.mjs | 106 +++++++--- packages/run-spaceapp/src/engine.mjs | 187 +++++++++++++++++ packages/run-spaceapp/src/index.mjs | 48 +++-- packages/run-spaceapp/src/storage.mjs | 25 ++- packages/run-spaceapp/tests/engine.test.mjs | 194 ++++++++++++++++++ scripts/podman-launcher-smoke.mjs | 142 +++++++++++++ .../tests/public-docker-distribution.test.mjs | 4 +- 14 files changed, 840 insertions(+), 60 deletions(-) create mode 100644 .github/workflows/podman.yml create mode 100644 packages/run-spaceapp/src/engine.mjs create mode 100644 packages/run-spaceapp/tests/engine.test.mjs create mode 100644 scripts/podman-launcher-smoke.mjs diff --git a/.github/workflows/podman.yml b/.github/workflows/podman.yml new file mode 100644 index 000000000..8ab1b73ab --- /dev/null +++ b/.github/workflows/podman.yml @@ -0,0 +1,75 @@ +name: Rootless Podman launcher +on: + pull_request: + paths: + - 'packages/run-spaceapp/**' + - 'scripts/podman-launcher-smoke.mjs' + - '.github/workflows/podman.yml' + - 'Dockerfile' + push: + paths: + - 'packages/run-spaceapp/**' + - 'scripts/podman-launcher-smoke.mjs' + - '.github/workflows/podman.yml' + workflow_dispatch: +permissions: + contents: read +concurrency: + group: podman-${{ github.ref }} + cancel-in-progress: true +jobs: + small: + runs-on: ubuntu-latest + timeout-minutes: 45 + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0 + with: + node-version: '22' + - name: Install rootless engine and Compose provider + shell: bash + run: | + set -euo pipefail + # ubuntu-latest currently includes Podman 4.9; use the Linux Homebrew bottle for >=5. + eval "$(/home/linuxbrew/.linuxbrew/bin/brew shellenv)" + brew install podman docker-compose + echo "$(brew --prefix)/bin" >> "$GITHUB_PATH" + echo "PODMAN_COMPOSE_PROVIDER=$(brew --prefix)/bin/docker-compose" >> "$GITHUB_ENV" + sudo apt-get update + sudo apt-get install -y uidmap + # Grant user namespaces to this engine binary on Ubuntu's restricted-userns hosts. + # This leaves the system restriction and other applications' profiles in place. + if command -v apparmor_parser >/dev/null; then + printf 'abi ,\ninclude \nprofile spaceapp-ci-podman /home/linuxbrew/.linuxbrew/Cellar/podman/*/bin/podman flags=(unconfined) {\n userns,\n}\n' | sudo tee /etc/apparmor.d/spaceapp-ci-podman >/dev/null + sudo apparmor_parser -r /etc/apparmor.d/spaceapp-ci-podman + fi + test "$(id -u)" != 0 + podman info --format json + - name: Unit tests and runtime images + shell: bash + run: | + set -euo pipefail + npm test --workspace=run-spaceapp + version=$(node -p "require('./packages/run-spaceapp/package.json').spaceappRuntimeVersion") + for target in core cli; do + podman build --format docker --layers --build-arg TARGETARCH=amd64 --target "$target" -t "ghcr.io/oll4com/spaceapp-$target:$version" . + done + podman pull pgvector/pgvector:0.8.3-pg17-trixie@sha256:3a3147ccd9a0aee952bb69e1acea1eb3cc7183544185b87a662f872cfa47ae3b + - name: Real small install, readiness, ownership, backup, restore and purge + shell: bash + env: + SPACEAPP_SMOKE_LOCAL_IMAGES: '1' + run: | + set -euo pipefail + export XDG_RUNTIME_DIR="${XDG_RUNTIME_DIR:-/run/user/$(id -u)}" + mkdir -p "$XDG_RUNTIME_DIR/podman" + podman system service --time=0 "unix://$XDG_RUNTIME_DIR/podman/podman.sock" > "$RUNNER_TEMP/spaceapp-podman-api.log" 2>&1 & + service_pid=$! + trap 'kill "$service_pid" 2>/dev/null || true' EXIT + for attempt in {1..30}; do + if curl --fail --silent --unix-socket "$XDG_RUNTIME_DIR/podman/podman.sock" http://localhost/_ping; then break; fi + sleep 1 + done + node scripts/podman-launcher-smoke.mjs small diff --git a/README.md b/README.md index fd303d486..d4cd38f8f 100644 --- a/README.md +++ b/README.md @@ -92,7 +92,7 @@ Coordinate multi-step tasks conversationally through the Room Agent or speak nat - **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. -- **Docker**: If Docker is not installed, the installer automatically installs official Docker Desktop (Windows/macOS) or Docker Engine (Linux). +- **Container engine**: Docker on Linux/macOS/Windows, or rootless Podman >=5 with Docker Compose >=5.6.0 on Linux/macOS. Docker installation remains automatic when selected; Podman and its Compose provider must already be installed. ### Run the Installation Command @@ -102,10 +102,17 @@ Run the universal command in your terminal: npx --yes run-spaceapp@latest install ``` +For rootless Podman, use `install --engine podman --profile small`. On macOS, +start a rootless Podman machine with at least 4 CPUs, 8192 MiB RAM, and enough +free disk first. The engine is saved for every later command. A working Docker +engine is preferred automatically; Podman-only hosts use Podman without a +Docker install. Existing installations cannot switch engines in place, and +Podman requires isolated access. See [Podman setup and diagnostics](packages/run-spaceapp/README.md#rootless-podman-linux-and-macos). + > **Windows 11 Tip**: Use **Command Prompt** (`cmd.exe`) or the Command Prompt profile in Windows Terminal. If PowerShell has a restricted execution policy, run `npx.cmd --yes run-spaceapp@latest install`. ### What Happens Next (Step-by-Step Onboarding): -1. **System & Docker Verification**: The launcher verifies CPU, RAM, and disk space, and installs/starts Docker if needed. +1. **System & Engine Verification**: The launcher verifies CPU, RAM, disk space and the selected engine; the Docker path installs/starts Docker if needed. 2. **Container Initialization**: Pulls SpaceApp's images by the digest the npm package pins (from run-spaceapp 1.1.7; earlier packages by exact version tag; published with SBOM and provenance attestations that the release workflow verifies) and the third-party images pinned by digest, then starts the container stack. 3. **One-Time Setup Token**: The terminal prints a secure 15-minute setup token and automatically opens `http://127.0.0.1:4911`. 4. **Create Your Operator Account**: Paste the token into the setup screen, enter your email and password, and click Create Owner. diff --git a/apps/api/tests/app-studio.test.ts b/apps/api/tests/app-studio.test.ts index 63d514dde..e028ae40e 100644 --- a/apps/api/tests/app-studio.test.ts +++ b/apps/api/tests/app-studio.test.ts @@ -34,7 +34,10 @@ async function fixture() { const roomEvents: Event[] = []; eventBus.subscribe((event) => { if (event.type === "APP_STUDIO_UPDATED" || event.type === "ARTIFACT_CREATED") roomEvents.push(event); }); const kickoffs: string[] = []; - const app = await createApp({ store, config, eventBus, jobApplyMailTriage: false, marketingCopilotScheduler: false, appStudioKickoff: async (pane) => { kickoffs.push(pane.id); }, + // These route sequences test successive events, not equal-timestamp ID ordering. + let eventTime = Date.now(); + const repository = new InMemoryAppStudioRepository(() => new Date(eventTime = Math.max(Date.now(), eventTime + 1)).toISOString()); + const app = await createApp({ store, config, eventBus, jobApplyMailTriage: false, marketingCopilotScheduler: false, appStudioRepository: repository, appStudioKickoff: async (pane) => { kickoffs.push(pane.id); }, auth: { sessionSecret: "test-secret-with-enough-length", devLogin: true, secureCookies: false } }); const login = await app.inject({ method: "POST", url: "/api/auth/login", payload: { email: "space@space.local", password: "space-dev" } }); const cookie = `space_session=${login.cookies.find((entry) => entry.name === "space_session")?.value}`; diff --git a/apps/api/tests/mail-copilot-sync.test.ts b/apps/api/tests/mail-copilot-sync.test.ts index 94cde621a..27103dbba 100644 --- a/apps/api/tests/mail-copilot-sync.test.ts +++ b/apps/api/tests/mail-copilot-sync.test.ts @@ -88,7 +88,9 @@ async function fixture() { }); const store = new InMemorySpaceStore(); const gmail = fakeGmail("token-me"); - const repository = new InMemoryMailCopilotRepository(); + // Keep successive fixture events distinct when requests finish in one millisecond. + let eventTime = Date.now(); + const repository = new InMemoryMailCopilotRepository(() => new Date(eventTime = Math.max(Date.now(), eventTime + 1)).toISOString()); const kickoffs: string[] = []; const app = await createApp({ store, config, mailCopilotGoogle: google, mailCopilotGmailFetch: gmail.fetchImpl, jobApplyMailTriage: false, mailCopilotSync: false, mailCopilotRepository: repository, mailCopilotKickoff: async (_pane, run) => { await new Promise((resolve) => setTimeout(resolve, 50)); kickoffs.push(run.id); }, auth: { sessionSecret: "test-secret-with-enough-length", devLogin: true, secureCookies: false } }); diff --git a/docs/getting-started.md b/docs/getting-started.md index f892f4439..816a02141 100644 --- a/docs/getting-started.md +++ b/docs/getting-started.md @@ -1,9 +1,10 @@ # Getting started SpaceApp packages its application services and redistributable AI coding CLIs -in Docker. The host needs Node.js only for the small `spaceapp` launcher, -and enough resources for the stack. The launcher installs and starts Docker -automatically when it is missing. +in containers. The host needs Node.js only for the small `spaceapp` launcher, +and enough resources for the stack. Docker is the default engine; the launcher +installs and starts it when selected and missing. Rootless Podman is also +supported on Linux and macOS. ## Requirements @@ -53,6 +54,41 @@ its code signature, installs it, starts it, and waits until it is ready. Docker Desktop uses its managed lightweight Linux environment. Apple Silicon uses the native `linux/arm64` images and Intel Macs use `linux/amd64`. +## Rootless Podman on Linux or macOS + +Install Podman >=5 and Docker Compose >=5.6.0, then select the provider with +`PODMAN_COMPOSE_PROVIDER=/path/to/docker-compose` when needed. Use a normal +user with cgroup v2 and delegated CPU/memory controllers. `podman info` must +report rootless mode. On macOS, start a rootless Podman machine first and +allocate at least 4 CPUs and 8192 MiB RAM for `small`; the larger profiles need +their listed CPU/RAM allocation inside the machine too. Ensure both the host +and machine have enough free disk and share your installation/workspace paths. + +```bash +npx --yes run-spaceapp@latest install --engine podman --profile small +npx --yes run-spaceapp@latest doctor +``` + +The engine choice persists for every lifecycle, workspace, provider, backup, +restore, update/rollback and diagnostic command. `--answers` also accepts +`"engine": "podman"`. A working Docker engine wins automatic selection; a +Podman-only installation does not install Docker. Existing configurations +without an engine field use Docker. Changing an existing installation's engine +is refused; use a separate `SPACEAPP_HOME` for a fresh install. The Podman path +requires isolated access and refuses host-root access. + +`doctor` shows the engine and Compose versions, rootless/cgroup capabilities, +and actual CPU/RAM/disk capacity. On macOS, it includes machine limits and +resize guidance: stop the machine, run `podman machine set --cpus N --memory +MiB --disk-size GiB `, and start it again. The launcher does not resize it. + +With Podman's `keep-id` user namespace, application uid 10001 maps to your +host user. Workspace writes retain your ownership without host `chown` or ACL +changes. On SELinux systems, only launcher-owned binds and explicitly added +workspace paths receive shared `z` labels. Read-only workspaces remain +read-only. The same published application images are used by both engines. +For provider details and commands see the [launcher guide](../packages/run-spaceapp/README.md#rootless-podman-linux-and-macos). + ## Windows 11 — Command Prompt Enable hardware virtualization and install Node.js 20.11 or newer. Open @@ -312,8 +348,8 @@ npx --yes run-spaceapp@latest status npx --yes run-spaceapp@latest logs ``` -`doctor` checks Node.js, Docker CLI, Docker Compose, Docker Engine, CPU, -memory, and free disk exactly once each. It distinguishes a missing CLI or +`doctor` checks Node.js, the selected container engine and Compose provider, +CPU, memory, and free disk. It distinguishes a missing CLI or Compose plugin from an installed engine that is stopped or inaccessible. The `small` profile should show core, CLI, and PostgreSQL running; `medium` adds Temporal, and `large` also adds the browser service. If startup is still diff --git a/packages/run-spaceapp/README.md b/packages/run-spaceapp/README.md index 0e8b1ab3b..98b842f85 100644 --- a/packages/run-spaceapp/README.md +++ b/packages/run-spaceapp/README.md @@ -1,6 +1,6 @@ # run-spaceapp -Cross-platform Docker launcher for SpaceApp. +Cross-platform container launcher for SpaceApp: Docker, or rootless Podman on Linux and macOS. Run the same command in Linux, macOS, or Windows 11 Command Prompt: @@ -17,7 +17,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 -of matching an existing local launcher. If Docker is missing, the universal +of matching an existing local launcher. With the Docker engine selected, 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 `pacman` packages on Arch-family Linux including CachyOS. It starts Docker and @@ -41,7 +41,7 @@ credential-free config so Docker Desktop's graphical credential helper is never invoked outside an interactive logon. The launcher selects `small`, `medium`, or `large` automatically using CPU, -RAM and free disk. Docker's CPU and RAM allocation is checked as well. +RAM and free disk. The selected engine's CPU and RAM allocation is checked as well. | Profile | CPU | Usable RAM | Fresh free space | Services | | --- | --- | --- | --- | --- | @@ -62,6 +62,51 @@ before pausing writers. Existing data is kept in place; the launcher never delet images, backups or volumes to free space. Separate Docker disk capacity is checked when measurable and its availability is reported. +### Rootless Podman (Linux and macOS) + +Install Podman **5 or newer** and **Docker Compose 5.6.0 or newer** first. +Set `PODMAN_COMPOSE_PROVIDER` to the Docker Compose executable if Podman does +not select it automatically. The alternative `podman-compose` Python provider +is not supported. Run as your normal user; cgroup v2 with delegated CPU and +memory controllers is required. `podman info` must report rootless operation. + +```bash +npx --yes run-spaceapp@latest install --engine podman --profile small +``` + +The engine is saved in `config.json`; `up`, `down`, `status`, `logs`, updates, +rollback, backup/restore, provider commands, `doctor --fix`, and uninstall all +reuse it. Non-interactive installation accepts `"engine": "podman"` in +`--answers`. Without an explicit choice, a working Docker engine takes +precedence; a Podman-only host selects Podman without installing Docker. +Existing configurations without an engine field continue using Docker. +SpaceApp refuses to switch an existing installation's engine because its +volumes belong to that engine. Use a separate `SPACEAPP_HOME` for a fresh +installation; there is no automatic data migration. Podman only supports +`--access isolated`; `--access host-root` is refused before changes. + +On macOS, create/start a rootless `podman machine` first. Its resources must +meet the selected profile, in addition to the host requirements above. For +example, `small` needs at least 4 CPUs, 8192 MiB machine RAM, and sufficient +free space inside the machine and on the host. Inspect with +`podman machine inspect `. To increase capacity, stop the machine, run +`podman machine set --cpus 4 --memory 8192 --disk-size 40 `, and start it +again. SpaceApp reports insufficient capacity and never resizes a machine. +Installation and workspace directories must be shared with that machine. + +The regular published images are used. Containers initialize volumes as +namespace root and run application processes as uid 10001; `keep-id` maps +that uid to your host user so workspace writes keep your ownership. On +SELinux hosts, launcher-owned secret binds and explicitly added workspaces +receive a shared `z` label; read-only workspaces stay read-only. No whole-home +relabel or host `chown` is performed. `doctor` and `support-bundle` report the +engine/version, Compose provider, rootless state, cgroup delegation and +available CPU/RAM/disk without including the raw engine environment. + +`uninstall` removes containers while preserving data; `uninstall --purge` +(or `--purge-data`) additionally removes this installation's volumes after +confirmation. + ### Telemetry Telemetry is off unless you turn it on. The install wizard lists what is sent diff --git a/packages/run-spaceapp/package.json b/packages/run-spaceapp/package.json index 86528ff2a..8be6766ee 100644 --- a/packages/run-spaceapp/package.json +++ b/packages/run-spaceapp/package.json @@ -36,7 +36,7 @@ ], "scripts": { "test": "node --test tests/*.test.mjs", - "check": "node --check bin/spaceapp.mjs && node --check src/package-info.mjs && node --check src/index.mjs && node --check src/cli.mjs && node --check src/storage.mjs && node --check src/telemetry.mjs && node --check src/prerequisites.mjs", + "check": "node --check bin/spaceapp.mjs && node --check src/package-info.mjs && node --check src/index.mjs && node --check src/cli.mjs && node --check src/engine.mjs && node --check src/storage.mjs && node --check src/telemetry.mjs && node --check src/prerequisites.mjs", "pack:dry-run": "npm pack --dry-run" }, "repository": { diff --git a/packages/run-spaceapp/src/cli.mjs b/packages/run-spaceapp/src/cli.mjs index e0e35fc03..f7adbb167 100644 --- a/packages/run-spaceapp/src/cli.mjs +++ b/packages/run-spaceapp/src/cli.mjs @@ -3,6 +3,7 @@ import { createHash, randomBytes, randomUUID } from "node:crypto"; import { createWriteStream } from "node:fs"; import { createInterface } from "node:readline"; import { once } from "node:events"; +import { containerEngine, selectContainerEngine, assertEngineSelection, engineExecutor, ensurePodmanAvailable, inspectContainerEngine, podmanChecks, withPodmanWritersPaused } from "./engine.mjs"; import { PromptController } from "./prompt-controller.mjs"; import { copyFile, @@ -23,6 +24,7 @@ import { applyConfigRepairs, commitInstallation, composeCommand, + composeProjectName, credentialProviders, initializeInstallation, inspectSystemResources, @@ -81,6 +83,7 @@ const SETUP_STATUS_TIMEOUT_MS = 10_000; const trustedCommands = new Set([ "codesign", "docker", + "podman", "hdiutil", "open", "powershell.exe", @@ -293,12 +296,17 @@ export async function run(argv, { plan: false, nonInteractive: false, logFile: null, + requestedEngine: undefined, answersFile: null }; const filteredArgv = []; for (let i = 0; i < argv.length; i++) { const arg = argv[i]; - if (arg === "--local-images") { + if (arg === "--engine") { + globalFlags.requestedEngine = containerEngine(argv[++i] ?? ""); + } else if (arg.startsWith("--engine=")) { + globalFlags.requestedEngine = containerEngine(arg.slice("--engine=".length)); + } else if (arg === "--local-images") { globalFlags.localImages = true; } else if (arg === "--json") { globalFlags.json = true; @@ -341,10 +349,26 @@ export async function run(argv, { stdout.write(helpText()); return 0; } + const installationCommand = ["install", "reinstall", "init"].includes(command); + const requestedEngine = globalFlags.requestedEngine ?? (installationCommand ? promptController.answers.engine : undefined); + if (requestedEngine !== undefined && !installationCommand) { + throw new Error("--engine is an installation choice; later commands use the saved engine."); + } + const existingEngineConfig = await readRawConfig(root); + const selectedEngine = ["install", "reinstall"].includes(command) + ? await selectContainerEngine({ requested: requestedEngine, existing: existingEngineConfig, execute, platform, env }) + : containerEngine(requestedEngine ?? existingEngineConfig?.engine); + const accessIndex = args.indexOf("--access"); + const requestedAccess = accessIndex >= 0 ? args[accessIndex + 1] : args.find((arg) => arg.startsWith("--access="))?.slice(9); + assertEngineSelection(selectedEngine, existingEngineConfig, requestedAccess ?? promptController.answers.access ?? existingEngineConfig?.accessMode, platform); + if (selectedEngine === "podman") { + ensureDocker = ensurePodmanAvailable; + stdout.write("Container engine: Podman (rootless).\n"); + } if (globalFlags.localImages) { const originalExecute = execute; execute = async (spec, io) => { - if (spec.command === "docker" && spec.args?.includes("compose")) { + if (["docker", "podman"].includes(spec.command) && spec.args?.includes("compose")) { if (spec.args.at(-1) === "pull") { stdout.write("Local images only: skipping registry downloads; missing images will fail startup.\n"); return 0; @@ -355,6 +379,8 @@ export async function run(argv, { return originalExecute(spec, io); }; } + execute = engineExecutor(execute, selectedEngine, platform, env); + if (selectedEngine === "podman") prepareDockerPath = async () => {}; if (command !== "install") { await prepareDockerPath({ platform, env }); } @@ -400,7 +426,7 @@ export async function run(argv, { if (!approved) { return 0; } - const result = await initializeInstallation(root, { version: runtimeVersion }); + const result = await initializeInstallation(root, { version: runtimeVersion, engine: selectedEngine }); stdout.write(`SpaceApp initialized at ${root}\n`); if (result.setupToken) { stdout.write(`One-time setup token: ${result.setupToken}\n`); @@ -421,6 +447,12 @@ export async function run(argv, { return repairRuntime({ root, config: null, platform, stdin, stdout, stderr, execute, request, sleep, ensureDocker, env, arch, inspectResources, dryRun: args.includes("--dry-run") }); } const config = await loadConfig(root); + // Recheck the selected connection before runtime writes: a saved Podman choice + // must not silently use a rootful connection selected after installation. + if (selectedEngine === "podman" && !["support-bundle", "open"].includes(command)) { + const prerequisites = await ensurePodmanAvailable({ execute, platform, stdout, stderr }); + if (prerequisites.code !== 0) return prerequisites.code; + } if (commandNeedsRuntimeFiles(command, args)) { await writeRuntimeFiles(root, config); } @@ -431,6 +463,10 @@ export async function run(argv, { { platform, stderr } ); + runtimeExecute.engine = selectedEngine; + runtimeExecute.platform = platform; + runtimeExecute.engineEnvironment = env; + if (["up", "down", "status", "logs"].includes(command)) { assertNoArgs(args, command); return runtimeExecute(composeCommand(command, root, { profile: config.profile, companionsEnabled: config.companionsEnabled }), { stdin, stdout, stderr }); @@ -458,7 +494,7 @@ export async function run(argv, { } if (command === "repair") { const dryRun = args.includes("--dry-run"); - return repairRuntime({ root, config, platform, stdin, stdout, stderr, execute: runtimeExecute, request, sleep, dryRun }); + return repairRuntime({ root, config, platform, stdin, stdout, stderr, execute: runtimeExecute, request, sleep, dryRun, ensureDocker, env, arch, inspectResources }); } if (command === "reinstall") { const keepData = !args.includes("--purge-data"); @@ -589,11 +625,12 @@ export async function run(argv, { return 0; } if (command === "uninstall") { + const engineName = selectedEngine === "podman" ? "Podman" : "Docker"; if (args.length === 0) { const approved = await requireChangeApproval(stdin, stdout, stderr, [ "Stop and remove SpaceApp containers and network.", "Data, configuration, secrets, and backups remain at the installation root.", - "Docker volumes are retained; the global SpaceApp CLI remains installed." + `${engineName} volumes are retained; the global SpaceApp CLI remains installed.` ]); if (!approved) { return 0; @@ -606,37 +643,37 @@ export async function run(argv, { if (code === 0) { stdout.write("SpaceApp runtime removed successfully. It is safe to run this command again.\n"); stdout.write(`Data, configuration, secrets, and backups remain at ${root}.\n`); - stdout.write("Docker volumes are retained. The global SpaceApp CLI remains installed.\n"); + stdout.write(`${engineName} volumes are retained. The global SpaceApp CLI remains installed.\n`); stdout.write("To remove only the global CLI, run: npm uninstall -g run-spaceapp\n"); } else { - stderr.write(`Uninstall could not remove the runtime (Docker exit ${code}).\n`); + stderr.write(`Uninstall could not remove the runtime (${engineName} exit ${code}).\n`); stdout.write(`Data, configuration, secrets, and backups remain at ${root}.\n`); stdout.write("The global SpaceApp CLI remains installed.\n"); } return code; } - if (args.length === 1 && args[0] === "--purge-data") { - const confirmation = await readSecret(stdin, stdout, "Type DELETE to remove Docker volumes: ", { mask: false, answerKey: "purgeConfirm" }); + if (args.length === 1 && ["--purge-data", "--purge"].includes(args[0])) { + const confirmation = await readSecret(stdin, stdout, `Type DELETE to remove ${engineName} volumes: `, { mask: false, answerKey: "purgeConfirm" }); if (confirmation !== "DELETE") { throw new Error("Purge cancelled."); } - stdout.write("Removing SpaceApp containers, network, and Docker volumes...\n"); + stdout.write(`Removing SpaceApp containers, network, and ${engineName} volumes...\n`); const code = await runtimeExecute( composeCommand("purge", root, { profile: config.profile, companionsEnabled: config.companionsEnabled }), { stdin, stdout, stderr } ); if (code === 0) { - stdout.write("SpaceApp runtime and Docker volumes removed successfully.\n"); + stdout.write(`SpaceApp runtime and ${engineName} volumes removed successfully.\n`); stdout.write(`Host configuration and backups remain at ${root} for manual review.\n`); stdout.write("The global SpaceApp CLI remains installed.\n"); stdout.write("To remove only the global CLI, run: npm uninstall -g run-spaceapp\n"); } else { - stderr.write(`SpaceApp Docker volume purge failed (Docker exit ${code}).\n`); + stderr.write(`SpaceApp ${engineName} volume purge failed (${engineName} exit ${code}).\n`); stdout.write(`Host files and the global SpaceApp CLI remain at ${root}.\n`); } return code; } - throw new Error(`Usage: ${UNIVERSAL_COMMAND} uninstall [--purge-data]`); + throw new Error(`Usage: ${UNIVERSAL_COMMAND} uninstall [--purge-data|--purge]`); } throw new Error(`Unknown command "${command}". Run "${UNIVERSAL_COMMAND} help".`); @@ -688,6 +725,7 @@ async function installCommand(args, { requestedAccessMode, existingConfig?.accessMode ?? "isolated" ); + assertEngineSelection(execute.engine ?? "docker", existingConfig, accessMode, platform); if (accessMode === "host-root" && platform !== "linux") { throw new Error("Host-root access is supported only on Linux."); } @@ -707,6 +745,7 @@ async function installCommand(args, { } const result = await prepareInstallation(root, { version: runtimeVersion, + engine: execute.engine ?? "docker", profile, accessMode, ...(companionsEnabled ? { companionsEnabled } : {}) @@ -1148,7 +1187,7 @@ async function planInteractiveSetup({ `Access mode: ${accessChoice}`, `Companions: ${companionsChoice ? "enabled" : "disabled"}`, `Telemetry: ${telemetryChoice ? "enabled" : "disabled"}`, - `Docker installation: ${dockerChoice ? "automatic if missing" : "not included"}`, + execute.engine === "podman" ? "Container engine: Podman (already installed; rootless)" : `Docker installation: ${dockerChoice ? "automatic if missing" : "not included"}`, `Open browser afterwards: ${openBrowserChoice ? "yes" : "no"}`, "Future refreshes preserve data, workspaces, credentials, secrets, and persistent Docker volumes." ]); @@ -1322,8 +1361,7 @@ async function repairRuntime({ root, config, platform, stdin, stdout, stderr, ex if(prerequisites.reexecuted || prerequisites.code !== 0) return prerequisites.code; config = repaired; if (await verifyStorage({ root, config, existingConfig: config, operation: "repair", inspectResources, execute, stdout, stderr }) !== 0) return 1; - await saveConfig(root, config); - await writeRuntimeFiles(root, config); + await commitInstallation(root, config); const pullCode = await withHeadlessDockerConfig( platform, composeCommand("pull", root, { profile: config.profile, companionsEnabled: config.companionsEnabled }), @@ -1480,6 +1518,8 @@ async function collectFiles(directory, logicalDirectory) { async function backupWithWritersPaused(execute, root, config, io) { const options = { profile: config.profile, companionsEnabled: config.companionsEnabled }; io.stdout.write("Pausing SpaceApp writers for the backup; the app and CLI sessions resume afterwards...\n"); + if (config.engine === "podman") return withPodmanWritersPaused(execute, composeProjectName(root), io, + () => execute(composeCommand("backup", root, options), io)); const pauseCode = await execute(composeCommand("pauseWriters", root, options), io); if (pauseCode !== 0) return pauseCode; let backupCode = 1; @@ -1747,7 +1787,7 @@ function parseInstallArgs(args) { continue; } throw new Error( - `Usage: ${UNIVERSAL_COMMAND} install [--profile auto|small|medium|large|light|standard] [--access isolated|host-root] [--with-companions] [--no-open]` + `Usage: ${UNIVERSAL_COMMAND} install [--engine docker|podman] [--profile auto|small|medium|large|light|standard] [--access isolated|host-root] [--with-companions] [--no-open]` ); } if ( @@ -1758,7 +1798,7 @@ function parseInstallArgs(args) { ) ) { throw new Error( - `Usage: ${UNIVERSAL_COMMAND} install [--profile auto|small|medium|large|light|standard] [--access isolated|host-root] [--with-companions] [--no-open]` + `Usage: ${UNIVERSAL_COMMAND} install [--engine docker|podman] [--profile auto|small|medium|large|light|standard] [--access isolated|host-root] [--with-companions] [--no-open]` ); } return { requestedProfile, requestedAccessMode, noOpen, companionsEnabled }; @@ -1811,7 +1851,7 @@ async function workspaceCommand(rawArgs, { root, config, platform, stdin, stdout await saveConfig(root, updated); await writeRuntimeFiles(root, updated); stdout.write(`Workspace registered: ${identity}\n`); - if (platform === "linux" && !readOnly) await writeAccessHint(identity, stdout); + if (platform === "linux" && config.engine !== "podman" && !readOnly) await writeAccessHint(identity, stdout); return applyWorkspaces(updated); } if (action === "remove") { @@ -2067,7 +2107,7 @@ async function providerCommand(rawArgs, { root, config, stdin, stdout, stderr, e const provider=args[1]; // A user's CLIs run in their own container, so an optional CLI goes into that container's volume. const service = userId ? `spaceapp-cli-${userSlug(userId)}` : undefined; - stdout.write(`Installing optional provider ${provider} into ${userId ? `the private provider volume of ${userId}` : "this installation's private provider volume"}.\n`); + stdout.write(provider === "opencode" ? "Verifying bundled OpenCode in the CLI container.\n" : `Installing optional provider ${provider} into ${userId ? `the private provider volume of ${userId}` : "this installation's private provider volume"}.\n`); // Keep the existing Claude path compatible with older runtime images. const action=provider === "claude" ? "installClaude" : "installProvider"; return execute(composeCommand(action, root, { profile: config.profile, companionsEnabled: config.companionsEnabled, provider, service }), { stdin, stdout, stderr }); @@ -2170,6 +2210,8 @@ async function supportBundleCommand({ request, outPath }) { + const resources = await inspectSystemResources(root); + const storage = config ? await inspectStoragePlan({ root, config, resources, execute, operation: "repair" }) : null; const bundle = { schemaVersion: "1.0.0", collectedAt: new Date().toISOString(), @@ -2197,6 +2239,14 @@ async function supportBundleCommand({ } : null }, + engine: await inspectContainerEngine(execute, containerEngine(config?.engine), platform), + capacity: { + hostCpuCount: resources.cpuCount, hostMemoryBytes: resources.totalMemoryBytes, + hostFreeDiskBytes: resources.freeDiskBytes, + engineCpuCount: storage?.resources.engineCpuCount ?? null, + engineMemoryBytes: storage?.resources.engineMemoryBytes ?? null, + engineFreeDiskBytes: storage?.dockerFreeDiskBytes ?? null + }, services: {}, health: {} }; @@ -2279,7 +2329,7 @@ async function factoryResetCommand({ await rm(join(root, "compose.yml"), { force: true }); } catch {} - const result = await initializeInstallation(root, { version: runtimeVersion }); + const result = await initializeInstallation(root, { version: runtimeVersion, engine: config.engine }); stdout.write(`SpaceApp factory reset complete. Clean installation initialized at ${root}\n`); if (result.setupToken) { stdout.write(`One-time setup token: ${result.setupToken}\n`); @@ -2433,6 +2483,15 @@ async function doctor({ ...resourceChecks.filter((check) => !storagePlan || check.name !== "Free disk"), ...(storagePlan ? storageChecks(storagePlan, detectedResources) : []) ]; + if (execute.engine === "podman") { + const info = await inspectContainerEngine(execute, "podman", platform); + checks.push(...podmanChecks(info, platform)); + if (!storagePlan) checks.push(...installResourceChecks({ ...detectedResources, engineName: "Podman", + engineCpuCount: info.cpuCount, engineMemoryBytes: info.memoryBytes }, resolvedProfile, { operation }) + .filter((check) => check.name.startsWith("Podman"))); + for (const check of checks) (check.ok ? stdout : stderr).write(`${check.ok ? "PASS" : "FAIL"} ${check.name}: ${check.detail}\n`); + return checks.every((check) => check.ok) ? 0 : 1; + } const dockerResults = []; for (const probe of [ { name: "Docker CLI", command: "docker", args: ["--version"] }, @@ -2655,6 +2714,7 @@ function spawnTrustedCommand(spec, options) { switch (spec.command) { case "codesign": return spawn("codesign", args, options); case "docker": return spawn("docker", args, options); + case "podman": return spawn("podman", args, options); case "hdiutil": return spawn("hdiutil", args, options); case "open": return spawn("open", args, options); case "powershell.exe": @@ -2760,12 +2820,12 @@ Usage: ${UNIVERSAL_COMMAND} spaceapp Optional global launcher init Create a local SpaceApp installation - install [--profile auto|small|medium|large|light|standard] [--access isolated|host-root] [--with-companions] [--no-open] + install [--engine docker|podman] [--profile auto|small|medium|large|light|standard] [--access isolated|host-root] [--with-companions] [--no-open] Install prerequisites, initialize, and start reinstall [--keep-data] [--profile auto|small|medium|large|light|standard] [--access isolated|host-root] [--with-companions] [--no-open] Clean reinstallation preserving data and credentials repair [--dry-run] Verify and repair configuration and runtime files - up | down | status | logs Manage the Docker application + up | down | status | logs Manage the saved container engine application open Open the local web application doctor [--fix] Diagnose; --fix repairs existing config and runtime support-bundle Generate a diagnostic bundle JSON for support diff --git a/packages/run-spaceapp/src/engine.mjs b/packages/run-spaceapp/src/engine.mjs new file mode 100644 index 000000000..1d8559369 --- /dev/null +++ b/packages/run-spaceapp/src/engine.mjs @@ -0,0 +1,187 @@ +import { access } from "node:fs/promises"; +import { constants } from "node:fs"; +import { delimiter, join } from "node:path"; +import { Writable } from "node:stream"; + +export function containerEngine(value = "docker") { + if (value !== "docker" && value !== "podman") throw new Error("Container engine must be docker or podman."); + return value; +} + +export function assertEngineSelection(engine, existing, accessMode, platform) { + containerEngine(engine); + if (existing && containerEngine(existing.engine) !== engine) { + throw new Error("Changing container engines would disconnect existing SpaceApp data. Keep the saved engine or use a separate SPACEAPP_HOME for a fresh installation; in-place migration is not supported."); + } + if (engine === "podman" && platform !== "linux" && platform !== "darwin") { + throw new Error("Podman is supported on Linux and macOS only."); + } + if (engine === "podman" && accessMode === "host-root") { + throw new Error("Podman requires isolated access; host-root is not supported."); + } +} + +async function available(execute, command, args) { + try { return await execute({ command, args, timeoutMs: 15000 }, { stdin: null, stdout: null, stderr: null }) === 0; } + catch { return false; } +} + +async function commandOnPath(command, env) { + for (const directory of (env.PATH ?? "").split(delimiter)) { + if (directory && await access(join(directory, command), constants.X_OK).then(() => true, () => false)) return true; + } + return false; +} + +export async function selectContainerEngine({ requested, existing, execute, platform, env = process.env, hasPodman = commandOnPath }) { + if (requested !== undefined) return containerEngine(requested); + if (existing) return containerEngine(existing.engine); + if (platform === "win32" || !await hasPodman("podman", env) || await available(execute, "docker", ["info"])) return "docker"; + if (await available(execute, "podman", ["--version"])) return "podman"; + // Keep the existing Docker prerequisite installer when neither engine is available. + return "docker"; +} + +/** Bind all runtime actions, including nested checkpoint/repair/credential operations, to this installation. */ +export function engineExecutor(execute, engine, platform = process.platform, env = process.env) { + containerEngine(engine); + const run = (spec, io) => { + if (spec.command === "docker") spec = { ...spec, command: engine }; + // Serialize keep-id namespace creation through the compatibility API. A parallel + // large-profile startup on Podman 5.8 returned an incomplete UID mapping. + if (engine === "podman" && spec.command === "podman" && spec.args?.[0] === "compose") { + spec = { ...spec, args: ["compose", "--parallel", "1", ...spec.args.slice(1)] }; + } + return execute(spec, io); + }; + run.engine = engine; + run.platform = platform; + run.engineEnvironment = env; + return run; +} + +async function capture(execute, command, args) { + let text = ""; + const stdout = new Writable({ write(chunk, _encoding, done) { if (text.length < 1024 * 1024) text += chunk.toString(); done(); } }); + try { + return await execute({ command, args, timeoutMs: 15000 }, { stdin: null, stdout, stderr: null }) === 0 ? text.trim() : null; + } catch { return null; } +} + +function json(text) { try { return JSON.parse(text); } catch { return null; } } +function versionAtLeast(version, major, minor = 0) { + const match = /^(?:v)?(\d+)\.(\d+)(?:\.|$)/.exec(version ?? ""); + return Boolean(match && (Number(match[1]) > major || Number(match[1]) === major && Number(match[2]) >= minor)); +} + +/** Resolve the machine behind Podman's selected connection, including custom machine names. */ +export async function selectedPodmanMachine(execute) { + const env = execute.engineEnvironment ?? process.env; + const connections = json(await capture(execute, "podman", ["system", "connection", "list", "--format", "json"])); + const machines = json(await capture(execute, "podman", ["machine", "list", "--format", "json"])); + if (!Array.isArray(connections) || !Array.isArray(machines)) return null; + const connection = connections.find((c) => env.CONTAINER_HOST ? c.URI === env.CONTAINER_HOST + : env.CONTAINER_CONNECTION ? c.Name === env.CONTAINER_CONNECTION : c.Default); + if (!connection?.IsMachine) return null; + let port; + try { port = Number(new URL(connection.URI).port); } catch { return null; } + const selected = machines.find((m) => Number(m.Port) === port && [m.Name, `${m.Name}-root`].includes(connection.Name)); + if (!selected?.Running || typeof selected.Name !== "string" || !/^[A-Za-z0-9][A-Za-z0-9_.-]*$/.test(selected.Name)) return null; + const inspected = json(await capture(execute, "podman", ["machine", "inspect", selected.Name])); + const machine = inspected?.find?.((m) => m.Name === selected.Name); + return machine ? { name: machine.Name, state: machine.State, cpuCount: machine.Resources?.CPUs, + memoryMiB: machine.Resources?.Memory, diskGiB: machine.Resources?.DiskSize, rootful: machine.Rootful } : null; +} + +/** Only allowlisted capability fields enter a support bundle; never the raw info/environment/connection payload. */ +export async function inspectContainerEngine(execute, engine, platform = process.platform) { + containerEngine(engine); + const info = json(await capture(execute, engine, ["info", "--format", "json"])) ?? {}; + const compose = await capture(execute, engine, ["compose", "version", "--short"]); + if (engine === "docker") return { + name: engine, version: info.ServerVersion ?? null, composeVersion: compose, + rootless: Array.isArray(info.SecurityOptions) ? info.SecurityOptions.some((x) => x.includes("rootless")) : null, + cgroupVersion: info.CgroupVersion ?? null, cpuCount: info.NCPU ?? null, memoryBytes: info.MemTotal ?? null + }; + const machine = platform === "darwin" ? await selectedPodmanMachine(execute) : null; + return { + name: engine, version: info.version?.Version ?? null, composeVersion: compose, + rootless: info.host?.security?.rootless ?? null, selinux: info.host?.security?.selinuxEnabled ?? null, + cgroupVersion: info.host?.cgroupVersion ?? null, + cgroupControllers: Array.isArray(info.host?.cgroupControllers) ? info.host.cgroupControllers.filter((x) => ["cpu", "cpuset", "memory", "pids", "io"].includes(x)) : [], + cpuCount: info.host?.cpus ?? null, memoryBytes: info.host?.memTotal ?? null, machine + }; +} + +export function podmanChecks(info, platform = process.platform) { + const checks = [ + { name: "Podman", ok: versionAtLeast(info.version, 5), detail: info.version ?? "not running or inaccessible; start Podman first" }, + { name: "Podman Compose provider", ok: versionAtLeast(info.composeVersion, 5, 6), detail: `${info.composeVersion ?? "unavailable"}; Docker Compose >=5.6.0 is the supported provider (set PODMAN_COMPOSE_PROVIDER to its executable)` }, + { name: "Podman rootless", ok: info.rootless === true, detail: info.rootless === true ? "rootless" : "rootful or unknown; run as your normal user with a rootless connection" }, + { name: "Podman capacity", ok: Number.isFinite(info.cpuCount) && info.cpuCount > 0 && Number.isFinite(info.memoryBytes) && info.memoryBytes > 0, + detail: `${info.cpuCount ?? "unknown"} CPUs, ${info.memoryBytes ?? "unknown"} bytes RAM; the profile allocation is checked separately` }, + { name: "Podman cgroups", ok: info.cgroupVersion === "v2" && ["cpu", "memory"].every((x) => info.cgroupControllers?.includes(x)), + detail: `${info.cgroupVersion ?? "unknown"}; delegated controllers: ${(info.cgroupControllers ?? []).join(", ") || "none"}. CPU and memory delegation are required.` } + ]; + if (platform === "darwin") checks.push({ name: "Podman machine", ok: info.machine?.state === "running" && info.machine.rootful === false, + detail: info.machine ? `${info.machine.name}: ${info.machine.cpuCount} CPUs, ${info.machine.memoryMiB} MiB RAM, ${info.machine.diskGiB} GiB disk. To resize, stop it and use podman machine set --cpus N --memory MiB --disk-size GiB, then start it.` : "Start a rootless Podman machine, then retry. No machine is created or resized automatically." }); + return checks; +} + +export async function ensurePodmanAvailable({ execute, platform, stdout, stderr }) { + const info = await inspectContainerEngine(execute, "podman", platform); + const checks = podmanChecks(info, platform); + for (const check of checks) (check.ok ? stdout : stderr).write(`${check.ok ? "PASS" : "FAIL"} ${check.name}: ${check.detail}\n`); + return { code: checks.every((check) => check.ok) ? 0 : 1, reexecuted: false, info }; +} + +/** Adapt our fixed Compose template, keeping Docker's generated bytes unchanged. */ +export function renderEngineCompose(template, engine) { + if (containerEngine(engine) === "docker") return template; + // Compose's file secrets are read-only binds, but its Docker provider cannot label those binds for SELinux. + // Use equivalent read-only, shared-label binds for the four launcher-owned files. Never relabel the whole home. + return template.replace(/^ {2}([a-z][a-z0-9-]*):\n([\s\S]*?)(?=^ {2}[a-z][a-z0-9-]*:|^\S|$(?![\s\S]))/gm, (block, service) => { + // Only service blocks have an image; top-level named volume/secret declarations do not. + if (!/^ {4}image:/m.test(block)) return block; + const secretMounts = []; + block = block.replace(/^ {4}secrets:\n((?: {6}- [a-z-]+\n)+)/m, (_match, refs) => { + for (const name of refs.matchAll(/- ([a-z-]+)/g)) { + if (!["setup-token", "session-secret", "database-url", "postgres-password"].includes(name[1])) throw new Error("Unknown runtime secret."); + secretMounts.push(` - ./secrets/${name[1]}:/run/secrets/${name[1]}:ro,z`); + } + return ""; + }); + if (secretMounts.length) { + if (/^ {4}volumes:\n/m.test(block)) block = block.replace(/^ {4}volumes:\n/m, ` volumes:\n${secretMounts.join("\n")}\n`); + else block += ` volumes:\n${secretMounts.join("\n")}\n`; + } + if (["spaceapp-core", "spaceapp-cli", "spaceapp-browser", "codex-sessions-init"].includes(service)) { + block = block.replace(/^( {4}image:.*\n)/m, '$1 userns_mode: "keep-id:uid=10001,gid=10001"\n'); + // Existing images initialize owned volumes as namespace root, then drop to uid10001. + if (!/^ {4}user:/m.test(block)) block = block.replace(/^( {4}image:.*\n)/m, '$1 user: "0:0"\n'); + } + return block.replace(/^( {6}- \.\/[^\n]+)$/gm, (line) => line.endsWith(",z") ? line : `${line}${/:ro$/.test(line) ? ",z" : ":z"}`); + }); +} + + +/** Compose's compatibility unpause may omit paused Podman containers; resume the exact IDs we paused. */ +export async function withPodmanWritersPaused(execute, project, io, backup) { + const rows = json(await capture(execute, "podman", ["ps", "--filter", `label=com.docker.compose.project=${project}`, "--filter", "status=running", "--format", "json"])); + if (!Array.isArray(rows)) throw new Error("Cannot determine the running Podman writers; backup was not started."); + const ids = rows.filter((row) => { + const service = row.Labels?.["com.docker.compose.service"]; + return ["spaceapp-core", "spaceapp-cli", "spaceapp-browser", "temporal"].includes(service) || service?.startsWith("spaceapp-cli-"); + }).map((row) => row.Id ?? row.ID); + if (ids.some((id) => !/^[a-f0-9]{12,64}$/.test(id ?? ""))) throw new Error("Podman returned an invalid writer ID."); + if (!ids.length) return backup(); + let code, resumeCode; + try { + code = await execute({ command: "podman", args: ["pause", ...ids] }, io); + if (code === 0) code = await backup(); + } finally { + resumeCode = await execute({ command: "podman", args: ["unpause", ...ids] }, io).catch(() => 1); + if (resumeCode !== 0) io.stderr.write("Podman could not resume all backup writers. Check their status before continuing.\n"); + } + return code !== 0 ? code : resumeCode; +} diff --git a/packages/run-spaceapp/src/index.mjs b/packages/run-spaceapp/src/index.mjs index e41d6986c..a1474b823 100644 --- a/packages/run-spaceapp/src/index.mjs +++ b/packages/run-spaceapp/src/index.mjs @@ -21,6 +21,8 @@ import { UNIVERSAL_COMMAND } from "./package-info.mjs"; import { imageVerificationOff, verifyLockedImages } from "./image-attestations.mjs"; import { IMAGE_TARGETS, imageDigestEnv, PACKAGE_IMAGE_LOCK, parseRecordedDigests } from "./image-lock.mjs"; +import { containerEngine, assertEngineSelection, renderEngineCompose } from "./engine.mjs"; + const CONFIG_SCHEMA_VERSION = 4; const CONTAINER_SECRET_MODE = 0o644; export const MIN_INSTALL_FREE_DISK_BYTES_STANDARD = 15 * 1024 ** 3; @@ -120,6 +122,7 @@ export const CLI_SETTINGS_FILES = Object.freeze([ "claude/settings.json", "claude/.claude.json", "codex/config.toml", "opencode/config/opencode/opencode.json" ]); const CONFIG_KEYS = new Set([ + "engine", "schemaVersion", "version", "previousVersion", @@ -283,7 +286,9 @@ export function installResourceChecks(resources, profile = "small", { requiredDi const requirements = PROFILE_REQUIREMENTS[profile]; if (!requirements) throw new Error("Unknown installation profile."); const minFreeDisk = requiredDiskBytes ?? (operation === "fresh" ? requirements.freshDiskBytes : 512 * 1024 ** 2); - const engineMemoryBytes = { small: 3, medium: 5, large: 7, light: 3, standard: 3 }[profile] * 1024 ** 3; + const engineMemoryBytes = resources.engineName === "Podman" ? requirements.memoryBytes + : { small: 3, medium: 5, large: 7, light: 3, standard: 3 }[profile] * 1024 ** 3; + const engineCpuCount = resources.engineName === "Podman" ? requirements.cpuCount : 4; return [ { name: "CPU", @@ -300,10 +305,10 @@ export function installResourceChecks(resources, profile = "small", { requiredDi ok: freeDiskBytes >= minFreeDisk, detail: `${formatGibibytes(freeDiskBytes)} GiB available; ${formatGibibytes(minFreeDisk)} GiB required${operation === "fresh" ? "" : " additionally for " + operation}` }, - ...(resources.engineMemoryBytes > 0 ? [{ name: "Docker memory", ok: resources.engineMemoryBytes >= engineMemoryBytes, + ...(resources.engineMemoryBytes > 0 ? [{ name: `${resources.engineName ?? "Docker"} memory`, ok: resources.engineMemoryBytes >= engineMemoryBytes, detail: `${formatGibibytes(resources.engineMemoryBytes)} GiB allocated; ${formatGibibytes(engineMemoryBytes)} GiB required for ${profile}` }] : []), - ...(resources.engineCpuCount > 0 ? [{ name: "Docker CPU", ok: resources.engineCpuCount >= 4, - detail: `${resources.engineCpuCount} allocated; 4 required` }] : []) + ...(resources.engineCpuCount > 0 ? [{ name: `${resources.engineName ?? "Docker"} CPU`, ok: resources.engineCpuCount >= engineCpuCount, + detail: `${resources.engineCpuCount} allocated; ${engineCpuCount} required` }] : []) ]; } @@ -311,7 +316,8 @@ export function createDefaultConfig({ version, profile = "light", accessMode = "isolated", - companionsEnabled = false + companionsEnabled = false, + engine }) { assertVersion(version); if (!INSTALL_PROFILES.includes(profile)) { @@ -319,6 +325,7 @@ export function createDefaultConfig({ } const resolvedAccessMode = resolveInstallAccessMode(accessMode); return { + ...(engine === undefined ? {} : { engine: containerEngine(engine) }), schemaVersion: CONFIG_SCHEMA_VERSION, version, previousVersion: null, @@ -348,6 +355,7 @@ export function validateConfig(config) { if (config.schemaVersion !== CONFIG_SCHEMA_VERSION) { throw new Error(`Unsupported SpaceApp config schema ${config.schemaVersion}.`); } + assertEngineSelection(containerEngine(config.engine), null, config.accessMode, process.platform); assertVersion(config.version); if (config.previousVersion !== null) { assertVersion(config.previousVersion); @@ -676,11 +684,15 @@ const CLI_SANDBOX_OPTIONS = [" security_opt:", " - seccomp=./seccomp-cli export function renderWorkspaceCompose(config, { cliSandbox = false } = {}) { validateConfig(config); + // Podman uses its native default seccomp/AppArmor policy. Docker Compose sends + // custom seccomp JSON inline, which Podman's compatibility API treats as a path. + cliSandbox = cliSandbox && config.engine !== "podman"; const mounts = config.workspaces.flatMap((workspace) => [ " - type: bind", ` source: ${JSON.stringify(workspace.hostPath)}`, ` target: ${JSON.stringify(workspace.containerPath)}`, - ` read_only: ${workspace.readOnly}` + ` read_only: ${workspace.readOnly}`, + ...(config.engine === "podman" ? [" bind:", " selinux: z"] : []) ]); const service = mounts.length > 0 ? [" volumes:", ...mounts] @@ -695,12 +707,14 @@ export function renderWorkspaceCompose(config, { cliSandbox = false } = {}) { " - type: bind", ` source: ${JSON.stringify(workspace.hostPath)}`, ` target: ${JSON.stringify(workspace.containerPath)}`, - ` read_only: ${workspace.readOnly}` + ` read_only: ${workspace.readOnly}`, + ...(config.engine === "podman" ? [" bind:", " selinux: z"] : []) ]; const userServices = users.flatMap((user) => { const slug = userSlug(user.id); return [ ` spaceapp-cli-${slug}:`, + ...(config.engine === "podman" ? [' userns_mode: "keep-id:uid=10001,gid=10001"', ' user: "0:0"'] : []), // The owner's pinned CLI image (runtime.env sets the digest; 1.1.7 review: a moved tag cannot change a user's). " image: ghcr.io/oll4com/spaceapp-cli:${SPACEAPP_IMAGE_TAG}${SPACEAPP_CLI_IMAGE_DIGEST:-}", " restart: unless-stopped", @@ -719,7 +733,7 @@ export function renderWorkspaceCompose(config, { cliSandbox = false } = {}) { ` - spaceapp-memory-${slug}:/var/lib/spaceapp/memory`, ` - spaceapp-cli-socket-${slug}:/run/spaceapp-cli`, ` - spaceapp-workspaces-${slug}:/workspaces`, - ` - ./secrets/users/${slug}/providers:/run/spaceapp-secrets/providers:ro`, + ` - ./secrets/users/${slug}/providers:/run/spaceapp-secrets/providers:ro${config.engine === "podman" ? ",z" : ""}`, ...user.workspaces.flatMap(bind), // Its own network: internet only, never the owner's internal services (temporal, companions) or other users. " networks:", @@ -734,7 +748,7 @@ export function renderWorkspaceCompose(config, { cliSandbox = false } = {}) { " SPACE_CLI_HOST_SOCKETS_FILE: /etc/spaceapp/cli-hosts.json", " volumes:", ...(mounts.length > 0 ? mounts : []), - " - ./cli-hosts.json:/etc/spaceapp/cli-hosts.json:ro", + ` - ./cli-hosts.json:/etc/spaceapp/cli-hosts.json:ro${config.engine === "podman" ? ",z" : ""}`, ...users.map((user) => ` - spaceapp-cli-socket-${userSlug(user.id)}:/run/spaceapp-cli-users/${userSlug(user.id)}`), " spaceapp-cli:", ...service, @@ -879,7 +893,8 @@ export function composeCommand(action, root, options = {}) { }; if (action === "installProvider") { assertProvider(options.provider); - actions.installProvider = ["exec", "-T", "--user", "10001:10001", options.service ?? "spaceapp-cli", "node", "/app/deploy/docker/provider-install.mjs", options.provider]; + actions.installProvider = ["exec", "-T", "--user", "10001:10001", options.service ?? "spaceapp-cli", + ...(options.provider === "opencode" ? ["opencode", "--version"] : ["node", "/app/deploy/docker/provider-install.mjs", options.provider])]; } let selected = actions[action]; if (action === "restore") { @@ -909,7 +924,7 @@ export function composeCommand(action, root, options = {}) { if (!selected) { throw new Error(`Unsupported Compose action "${action}".`); } - return { command: "docker", args: [...base, ...selected] }; + return { command: containerEngine(options.engine), args: [...base, ...selected] }; } export function composeProjectName(root) { @@ -984,9 +999,12 @@ export async function prepareInstallation(root, { version, profile, accessMode, - companionsEnabled + companionsEnabled, + engine }) { validateHome(root); + const existing = await loadConfig(root).catch((error) => { if (error.code === "ENOENT") return null; throw error; }); + if (engine !== undefined) assertEngineSelection(engine, existing, accessMode ?? existing?.accessMode, process.platform); await mkdir(root, { recursive: true, mode: 0o700 }); await Promise.all([ mkdir(join(root, "backups"), { recursive: true, mode: 0o700 }), @@ -1007,7 +1025,8 @@ export async function prepareInstallation(root, { version, profile: resolvedProfile ?? "light", accessMode: resolveInstallAccessMode(accessMode), - companionsEnabled + companionsEnabled, + engine }); } if (config.version !== version) { @@ -1027,6 +1046,7 @@ export async function prepareInstallation(root, { if (companionsEnabled !== undefined && config.companionsEnabled !== companionsEnabled) { config = { ...config, companionsEnabled }; } + if (engine !== undefined) config = { ...config, engine: containerEngine(engine) }; validateConfig(config); const postgresPasswordPath = join(root, "secrets", "postgres-password"); @@ -1093,7 +1113,7 @@ export async function commitInstallation(root, config, { validateConfig(config); const composeTemplate = await readFile(join(templateDir, "compose.yml"), "utf8"); await mkdir(root, { recursive: true, mode: 0o700 }); - await atomicWrite(join(root, "compose.yml"), composeTemplate); + await atomicWrite(join(root, "compose.yml"), renderEngineCompose(composeTemplate, config.engine)); await mkdir(projectRoot, { recursive: true, mode: 0o700 }); await atomicWrite(join(projectRoot, "seccomp-cli.json"), await readFile(join(defaultTemplateDir(), "seccomp-cli.json"), "utf8")); await writeRuntimeFiles(root, config); diff --git a/packages/run-spaceapp/src/storage.mjs b/packages/run-spaceapp/src/storage.mjs index c8b4cfadc..994c296cf 100644 --- a/packages/run-spaceapp/src/storage.mjs +++ b/packages/run-spaceapp/src/storage.mjs @@ -1,3 +1,4 @@ +import { selectedPodmanMachine } from "./engine.mjs"; import { Writable } from "node:stream"; import { readdir, readFile, stat } from "node:fs/promises"; import { join } from "node:path"; @@ -99,6 +100,8 @@ export async function inspectStoragePlan({ root, config, existingConfig, resourc const dockerInfo = await captureProbe(execute, { command: "docker", args: ["info", "--format", "{{json .}}"] }); let engine = null; try { engine = JSON.parse(dockerInfo); } catch { /* Conservative reserve when the engine is unavailable. */ } + if (engine?.host) engine = { ...engine, DockerRootDir: engine.store?.graphRoot, + NCPU: engine.host.cpus, MemTotal: engine.host.memTotal, Architecture: engine.host.arch }; const cachedLayers = new Set(); if (engine && existingConfig) { const existingDigests = await installedImageDigests(root, existingConfig.version); @@ -127,9 +130,9 @@ export async function inspectStoragePlan({ root, config, existingConfig, resourc let cached = null; for (const ref of refs) { cached = await captureProbe(execute, { command: "docker", args: ["image", "inspect", "--format", "{{.Id}}", ref] }); - if (/^sha256:[a-f0-9]{64}$/.test(cached ?? "")) break; + if (/^(?:sha256:)?[a-f0-9]{64}$/.test(cached ?? "")) break; } - if (!/^sha256:[a-f0-9]{64}$/.test(cached ?? "")) { + if (!/^(?:sha256:)?[a-f0-9]{64}$/.test(cached ?? "")) { missingImages.push(image); let reserve = imageReserve[type] ?? GiB; if (engine && existingConfig && cachedLayers.size && typeof fetch === "function") { @@ -154,13 +157,19 @@ export async function inspectStoragePlan({ root, config, existingConfig, resourc const free = await captureProbe(execute, composeCommand("storageFree", root, existingConfig)); if (/^\d+$/.test(free ?? "")) dockerFreeDiskBytes = Number(free); } - // Linux can measure the Docker filesystem directly even on a fresh install. - if (engine?.DockerRootDir && process.platform === "linux") { + if (config.engine === "podman" && (execute.platform ?? process.platform) === "darwin") { + const machine = await selectedPodmanMachine(execute); + const free = machine && await captureProbe(execute, { command: "podman", args: ["machine", "ssh", machine.name, "--", "df", "-B1", "--output=avail", "/var/home"] }); + const value = free?.trim().split(/\s+/).at(-1); + if (/^\d+$/.test(value ?? "")) dockerFreeDiskBytes = Number(value); + } + // Linux can measure the engine filesystem directly even on a fresh install. + if (engine?.DockerRootDir && (execute.platform ?? process.platform) === "linux") { const { inspectSystemResources } = await import("./index.mjs"); try { dockerFreeDiskBytes = (await inspectSystemResources(engine.DockerRootDir)).freeDiskBytes; } catch {} } - return { ...plan, missingImages, sharedLayersEstimated, dockerFreeDiskBytes, - resources: { ...resources, + return { ...plan, engineName: config.engine === "podman" ? "Podman" : "Docker", missingImages, sharedLayersEstimated, dockerFreeDiskBytes, + resources: { ...resources, engineName: config.engine === "podman" ? "Podman" : "Docker", freeDiskBytes: dockerFreeDiskBytes === null ? resources.freeDiskBytes : Math.min(resources.freeDiskBytes, dockerFreeDiskBytes), engineCpuCount: engine?.NCPU, engineMemoryBytes: engine?.MemTotal @@ -170,9 +179,9 @@ export async function inspectStoragePlan({ root, config, existingConfig, resourc export function storageChecks(plan, resources) { const checks = [{ name: "Free disk", ok: resources.freeDiskBytes >= plan.requiredDiskBytes, detail: `${gib(resources.freeDiskBytes)} GiB available; ${gib(plan.requiredDiskBytes)} GiB required additionally for ${plan.operation} (images ${gib(plan.missingImageBytes)}, checkpoint ${gib(plan.checkpointBytes)}, reserve 0.5)` }]; - if (plan.dockerFreeDiskBytes !== null) checks.push({ name: "Docker storage", ok: plan.dockerFreeDiskBytes >= plan.requiredDiskBytes, + if (plan.dockerFreeDiskBytes !== null) checks.push({ name: `${plan.engineName ?? "Docker"} storage`, ok: plan.dockerFreeDiskBytes >= plan.requiredDiskBytes, detail: `${gib(plan.dockerFreeDiskBytes)} GiB available; ${gib(plan.requiredDiskBytes)} GiB required additionally` }); - else checks.push({ name: "Docker storage", ok: true, detail: "Separate engine disk capacity unavailable; host estimate used. Docker ENOSPC will stop the operation before cutover." }); + else checks.push({ name: `${plan.engineName ?? "Docker"} storage`, ok: plan.engineName !== "Podman", detail: plan.engineName === "Podman" ? "Cannot measure Podman storage. Start the selected rootless machine/engine and retry; disk capacity must be known before cutover." : "Separate engine disk capacity unavailable; host estimate used. Docker ENOSPC will stop the operation before cutover." }); return checks; } diff --git a/packages/run-spaceapp/tests/engine.test.mjs b/packages/run-spaceapp/tests/engine.test.mjs new file mode 100644 index 000000000..56366a0fb --- /dev/null +++ b/packages/run-spaceapp/tests/engine.test.mjs @@ -0,0 +1,194 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { mkdtemp, readFile, rm } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { Readable, Writable } from "node:stream"; +import { containerEngine, selectContainerEngine, engineExecutor, inspectContainerEngine, podmanChecks, renderEngineCompose, withPodmanWritersPaused } from "../src/engine.mjs"; +import { composeCommand, createDefaultConfig, initializeInstallation, installResourceChecks, loadConfig, prepareInstallation, renderWorkspaceCompose, validateConfig } from "../src/index.mjs"; +import { run } from "../src/cli.mjs"; +import { inspectStoragePlan } from "../src/storage.mjs"; + +const info = { version: { Version: "5.8.1" }, host: { security: { rootless: true, selinuxEnabled: true }, + cgroupVersion: "v2", cgroupControllers: ["cpu", "memory", "pids"], cpus: 8, memTotal: 16 * 1024 ** 3 }, + registries: { auth: "must-not-appear" }, store: { graphRoot: "/private/storage" } }; +const sink = () => new Writable({ write(_chunk, _encoding, done) { done(); } }); +async function fixture(t) { + const root = await mkdtemp(join(tmpdir(), "spaceapp-podman-")); + t.after(() => rm(root, { recursive: true, force: true })); + const calls = []; + const execute = async (spec, io) => { + calls.push(spec); + if (spec.command === "docker") throw new Error("Docker must not be invoked for saved Podman installs"); + if (spec.args[0] === "info") io?.stdout?.write(JSON.stringify({ ...info, store: { graphRoot: root } })); + else if (spec.args[0] === "compose" && spec.args.includes("version")) io?.stdout?.write("5.6.0\n"); + else if (spec.args[0] === "image") return 1; + else if (spec.args.includes("df")) io?.stdout?.write("20000000000"); + return 0; + }; + const options = { env: { SPACEAPP_HOME: root, SPACEAPP_TELEMETRY: "0" }, platform: "linux", stdin: Readable.from([]), stdout: sink(), stderr: sink(), execute, + ensureDocker: async () => { throw new Error("Docker installation must not run"); }, + prepareDockerPath: async () => { throw new Error("Docker preparation must not run"); }, + inspectResources: async () => ({ cpuCount: 8, totalMemoryBytes: 16 * 1024 ** 3, freeDiskBytes: 30 * 1024 ** 3 }), + sleep: async () => {}, request: async (url) => ({ ok: true, status: 200, json: async () => url.endsWith("/readyz") ? { ok: true } : { setupRequired: true, expiresAt: null } }) }; + return { root, calls, execute, options }; +} + +test("automatic selection prefers working Docker; Podman-only hosts never install Docker", async () => { + const selection = { platform: "linux", hasPodman: async () => true }; + assert.equal(await selectContainerEngine({ ...selection, execute: async () => 0 }), "docker"); + const seen = []; + assert.equal(await selectContainerEngine({ ...selection, execute: async (spec) => { seen.push(spec.command); return spec.command === "docker" ? 127 : 0; } }), "podman"); + assert.deepEqual(seen, ["docker", "podman"]); + assert.equal(await selectContainerEngine({ ...selection, existing: { engine: "podman" }, execute: () => { throw new Error("no autodetection for saved choice"); } }), "podman"); + assert.throws(() => containerEngine("sh"), /docker or podman/); +}); + +test("Compose construction and executor preserve arguments and bind only runtime commands", async () => { + const docker = composeCommand("backup", "/tmp/space-engine", { profile: "small" }); + const podman = composeCommand("backup", "/tmp/space-engine", { profile: "small", engine: "podman" }); + assert.equal(podman.command, "podman"); + assert.deepEqual(podman.args, docker.args); + const specs = []; + const execute = engineExecutor(async (spec) => { specs.push(spec); return 0; }, "podman"); + await execute(docker); + await execute({ command: "open", args: ["http://127.0.0.1:4911"] }); + assert.equal(specs[0].command, "podman"); assert.equal(specs[1].command, "open"); + assert.deepEqual(specs[0].args, ["compose", "--parallel", "1", ...docker.args.slice(1)]); + assert.deepEqual(composeCommand("installProvider", "/tmp/space-engine", { provider: "opencode" }).args.slice(-3), ["spaceapp-cli", "opencode", "--version"]); +}); + +test("old Docker configurations remain compatible and engine switches fail before writes", async (t) => { + const f = await fixture(t); + await initializeInstallation(f.root, { version: "1.0.2" }); + const before = await readFile(join(f.root, "config.json"), "utf8"); + assert.equal(containerEngine((await loadConfig(f.root)).engine), "docker"); + await assert.rejects(prepareInstallation(f.root, { version: "1.0.3", engine: "podman" }), /Changing container engines/); + assert.equal(await readFile(join(f.root, "config.json"), "utf8"), before); + assert.throws(() => validateConfig({ ...createDefaultConfig({ version: "1.0.2" }), engine: "podman", accessMode: "host-root" }), /host-root/); +}); + +test("Podman render keeps shared UID mappings and labels only relevant binds, preserving read-only mounts", async () => { + const template = await readFile(new URL("../templates/compose.yml", import.meta.url), "utf8"); + assert.equal(renderEngineCompose(template, "docker"), template); + const rendered = renderEngineCompose(template, "podman"); + assert.equal((rendered.match(/userns_mode:/g) ?? []).length, 4); + assert.match(rendered, /\.\/secrets\/database-url:\/run\/secrets\/database-url:ro,z/); + assert.doesNotMatch(rendered, /^ {4}secrets:/m); + assert.match(rendered, /spaceapp-codex-sessions:\/var\/lib\/spaceapp-cli\/providers\/codex\/sessions:ro$/m); + assert.match(rendered, /\.\/backups:\/backups:z/); + assert.doesNotMatch(rendered, /label=disable|privileged/); + const config = createDefaultConfig({ version: "1.0.2", engine: "podman" }); + config.workspaces = [{ id: "fixture-12345678", hostPath: "/tmp/engine fixture", containerPath: "/workspaces/fixture", readOnly: true }]; + assert.match(renderWorkspaceCompose(config), /read_only: true\n {8}bind:\n {10}selinux: z/); + assert.doesNotMatch(renderWorkspaceCompose(config, { cliSandbox: true }), /seccomp=|apparmor=unconfined/); +}); + +test("engine diagnostics omit auth/storage payload and reject rootful or missing delegation", async (t) => { + const f = await fixture(t); + const observed = await inspectContainerEngine(f.execute, "podman", "linux"); + assert.equal(observed.rootless, true); assert.equal(observed.selinux, true); + assert.ok(podmanChecks(observed, "linux").every((check) => check.ok)); + assert.doesNotMatch(JSON.stringify(observed), /must-not-appear|private\/storage/); + assert.ok(podmanChecks({ ...observed, rootless: false }).some((check) => !check.ok)); + assert.ok(podmanChecks({ ...observed, cgroupControllers: ["memory"] }).some((check) => !check.ok)); + assert.ok(podmanChecks({ ...observed, composeVersion: "2.0.0" }).some((check) => !check.ok)); +}); + +test("--answers selects Podman and all later commands reuse the saved engine", async (t) => { + const f = await fixture(t); + const answer = { engine: "podman", confirm: true, profile: "small", access: "isolated", companions: false, telemetry: false, open: false }; + assert.equal(await run(["install", "--non-interactive", "--answers", JSON.stringify(answer)], f.options), 0); + assert.equal((await loadConfig(f.root)).engine, "podman"); + assert.equal(await run(["status", "--non-interactive", "--answers", JSON.stringify(answer)], { ...f.options, stdin: Readable.from([]) }), 0); + assert.match(await readFile(join(f.root, "compose.yml"), "utf8"), /keep-id/); + for (const args of [["status"], ["logs"], ["down"], ["up"], ["provider", "install", "opencode"], ["doctor"], ["doctor", "--fix", "--non-interactive", "--answers", '{"confirm":true}']]) { + assert.equal(await run(args, { ...f.options, stdin: Readable.from([]) }), 0, args.join(" ")); + } + assert.ok(f.calls.every((spec) => spec.command === "podman")); + const out = join(f.root, "support.json"); + assert.equal(await run(["support-bundle", "--out", out], f.options), 0); + assert.equal(JSON.parse(await readFile(out, "utf8")).engine.name, "podman"); +}); + +test("explicit Podman host-root is refused before any command or filesystem mutation", async (t) => { + const f = await fixture(t); + await assert.rejects(run(["install", "--engine", "podman", "--access", "host-root"], f.options), /host-root/); + assert.equal(f.calls.length, 0); + await assert.rejects(readFile(join(f.root, "config.json")), { code: "ENOENT" }); +}); + +test("a saved Podman install refuses runtime writes after switching to a rootful connection", async (t) => { + const f = await fixture(t); + await initializeInstallation(f.root, { version: "1.1.8", engine: "podman" }); + const before = await readFile(join(f.root, "config.json"), "utf8"); + const calls = []; + const execute = async (spec, io) => { + calls.push(spec); + if (spec.args[0] === "info") { + io.stdout.write(JSON.stringify({ ...info, host: { ...info.host, security: { rootless: false } } })); + return 0; + } + return f.execute(spec, io); + }; + assert.equal(await run(["up"], { ...f.options, execute }), 1); + assert.ok(calls.every((spec) => spec.args[0] === "info" || spec.args.includes("version"))); + assert.equal(await readFile(join(f.root, "config.json"), "utf8"), before); +}); + + +test("Podman image IDs without a sha256 prefix count as cached images", async (t) => { + const f = await fixture(t); + const execute = engineExecutor(async (spec, io) => { + if (spec.args[0] === "image") { io?.stdout?.write("a".repeat(64)); return 0; } + return f.execute(spec, io); + }, "podman", "linux"); + const plan = await inspectStoragePlan({ root: f.root, config: createDefaultConfig({ version: "1.1.8", profile: "small", engine: "podman" }), + resources: { freeDiskBytes: 30 * 1024 ** 3 }, execute, operation: "fresh" }); + assert.deepEqual(plan.missingImages, []); + assert.equal(plan.missingImageBytes, 0); +}); + + +test("macOS diagnostics use the selected named machine and omit connection identities", async () => { + const calls = []; + const execute = engineExecutor(async (spec, io) => { + calls.push(spec.args); + let value; + if (spec.args[0] === "info") value = info; + else if (spec.args[0] === "compose") { io.stdout.write("5.6.0"); return 0; } + else if (spec.args[0] === "system") value = [{ Name: "my-machine", IsMachine: true, Default: true, URI: "ssh://core@127.0.0.1:1234/run/podman.sock", Identity: "/private/key" }]; + else if (spec.args[1] === "list") value = [{ Name: "my-machine", Port: 1234, Running: true }]; + else value = [{ Name: "my-machine", State: "running", Rootful: false, Resources: { CPUs: 4, Memory: 8192, DiskSize: 40 } }]; + io.stdout.write(JSON.stringify(value)); return 0; + }, "podman", "darwin", {}); + const observed = await inspectContainerEngine(execute, "podman", "darwin"); + assert.equal(observed.machine.name, "my-machine"); + assert.ok(calls.some((args) => args.join(" ") === "machine inspect my-machine")); + assert.doesNotMatch(JSON.stringify(observed), /private\/key|ssh:\/\//); +}); + +test("Podman machine allocation must satisfy the selected profile independently of host capacity", () => { + const resources = { cpuCount: 12, totalMemoryBytes: 32 * 1024 ** 3, freeDiskBytes: 30 * 1024 ** 3, + engineName: "Podman", engineCpuCount: 4, engineMemoryBytes: 8 * 1024 ** 3 }; + assert.ok(installResourceChecks(resources, "small").every((check) => check.ok)); + assert.deepEqual(installResourceChecks(resources, "large").filter((check) => !check.ok).map((check) => check.name), ["Podman memory", "Podman CPU"]); +}); + + +test("Podman resumes exactly the running writers after backup failure", async () => { + const calls = []; + const id = "a".repeat(64); + const execute = async (spec, io) => { + calls.push(spec.args); + if (spec.args[0] === "ps") io.stdout.write(JSON.stringify([ + { Id: id, Labels: { "com.docker.compose.service": "spaceapp-core" } }, + { Id: "b".repeat(64), Labels: { "com.docker.compose.service": "postgres" } } + ])); + return 0; + }; + await assert.rejects(withPodmanWritersPaused(execute, "spaceapp-test", { stderr: sink() }, async () => { throw new Error("backup failed"); }), /backup failed/); + assert.deepEqual(calls.slice(1), [["pause", id], ["unpause", id]]); + assert.ok(calls[0].includes("label=com.docker.compose.project=spaceapp-test")); + assert.ok(calls[0].includes("status=running")); +}); diff --git a/scripts/podman-launcher-smoke.mjs b/scripts/podman-launcher-smoke.mjs new file mode 100644 index 000000000..663c69d58 --- /dev/null +++ b/scripts/podman-launcher-smoke.mjs @@ -0,0 +1,142 @@ +#!/usr/bin/env node +// Opt-in acceptance against a disposable, uniquely named installation; uses real images and the real launcher. +import assert from "node:assert/strict"; +import { spawn } from "node:child_process"; +import { mkdtemp, mkdir, readFile, readdir, rm, stat } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { fileURLToPath } from "node:url"; +import { RUNTIME_VERSION } from "../packages/run-spaceapp/src/package-info.mjs"; +import { composeCommand, composeProjectName, loadConfig } from "../packages/run-spaceapp/src/index.mjs"; + +const profile = process.argv[2] ?? "small"; +assert.ok(["small", "medium", "large"].includes(profile), "Pass small, medium or large"); +const fixture = await mkdtemp(join(process.env.SPACEAPP_SMOKE_TMPDIR ?? tmpdir(), "spaceapp-podman-smoke-")); +const root = join(fixture, "installation"); +const workspace = join(fixture, "workspace"); +await mkdir(workspace); +const project = composeProjectName(root); +const env = { ...process.env, SPACEAPP_HOME: root, SPACEAPP_TELEMETRY: "0" }; +const answers = { confirm: true, profile, access: "isolated", companions: false, telemetry: false, open: false, purgeConfirm: "DELETE" }; +const localImages = process.env.SPACEAPP_SMOKE_LOCAL_IMAGES === "1"; +const sourceVersion = process.env.SPACEAPP_SMOKE_SOURCE_VERSION; +if (sourceVersion) assert.match(sourceVersion, /^\d+\.\d+\.\d+$/); + +function podman(args, input) { + return new Promise((resolve, reject) => { + const child = spawn("podman", args, { env, stdio: ["pipe", "pipe", "inherit"], shell: false }); + let output = ""; + child.stdout.on("data", (chunk) => { output += chunk; }); + child.on("error", reject); + child.on("close", (code) => code === 0 ? resolve(output.trim()) : reject(new Error(`podman ${args[0]} exited ${code}`))); + child.stdin.end(input); + }); +} +async function launch(args, { input, version } = {}) { + console.log(`ACCEPT ${profile}: ${args.join(" ")}`); + const flags = input === undefined ? ["--non-interactive", "--answers", JSON.stringify(answers)] : []; + const code = await new Promise((resolve, reject) => { + // The exported launcher runs with a real older release for upgrade acceptance; no executor is replaced. + const entry = version ? ["--input-type=module", "--eval", `import {run} from ${JSON.stringify(new URL("../packages/run-spaceapp/src/cli.mjs", import.meta.url).href)}; process.exitCode = await run(process.argv.slice(1), {runtimeVersion:${JSON.stringify(version)}});`, "--"] + : [fileURLToPath(new URL("../packages/run-spaceapp/bin/spaceapp.mjs", import.meta.url))]; + const child = spawn(process.execPath, [...entry, + ...args, ...flags, ...(localImages ? ["--local-images"] : [])], { env, stdio: ["pipe", "inherit", "inherit"], shell: false }); + child.on("error", reject); + child.on("close", resolve); + child.stdin.end(input); + }); + assert.equal(code, 0, args.join(" ")); +} +async function compose(args) { + const config = await loadConfig(root); + const spec = composeCommand("status", root, config); + return podman([...spec.args.slice(0, -1), ...args]); +} +async function ready() { + for (let attempt = 0; attempt < 90; attempt++) { + try { + const response = await fetch("http://127.0.0.1:4911/readyz", { signal: AbortSignal.timeout(2000) }); + if (response.ok && (await response.json()).ok === true) { + assert.equal((await fetch("http://127.0.0.1:4911/", { signal: AbortSignal.timeout(5000) })).status, 200); + return; + } + } catch { /* Startup may not have bound the port yet. */ } + await new Promise((resolve) => setTimeout(resolve, 2000)); + } + throw new Error("readyz/web did not become ready"); +} +let cleaned = false; +try { + await launch(["install", "--engine", "podman", "--profile", profile], { version: sourceVersion }); + assert.equal((await loadConfig(root)).engine, "podman"); + await ready(); + await launch(["status"]); + await launch(["logs"]); + await launch(["workspace", "add", workspace]); + let config = await loadConfig(root); + const containerPath = config.workspaces[0].containerPath; + await compose(["exec", "-T", "--user", "10001:10001", "spaceapp-cli", "node", "-e", "require('node:fs').writeFileSync(process.argv[1], 'rootless ownership')", `${containerPath}/ownership`]); + assert.equal((await stat(join(workspace, "ownership"))).uid, process.getuid()); + await launch(["workspace", "remove", workspace]); + await launch(["workspace", "add", workspace, "--read-only"]); + await compose(["exec", "-T", "--user", "10001:10001", "spaceapp-cli", "node", "-e", "try{require('node:fs').writeFileSync(process.argv[1],'unexpected');process.exit(1)}catch(e){if(!['EROFS','EACCES'].includes(e.code))throw e}", `${containerPath}/must-not-write`]); + await compose(["exec", "-T", "--user", "10001:10001", "spaceapp-core", "node", "-e", + "require('node:net').connect('/run/spaceapp-cli/host.sock').on('connect',()=>process.exit(0)).on('error',()=>process.exit(1))"]); + const coreId = await compose(["ps", "-q", "spaceapp-core"]); + const inspection = JSON.parse(await podman(["inspect", coreId]))[0]; + assert.ok(inspection.HostConfig.Memory > 0, "memory limit is enforced"); + assert.ok(inspection.HostConfig.CpuQuota > 0, "CPU quota is enforced"); + assert.equal(inspection.HostConfig.Init, true, "container init is enabled"); + assert.equal(await compose(["run", "--rm", "--no-deps", "--user", "10001:10001", "--entrypoint", "node", "spaceapp-cli", "-p", "process.getuid()"]), "10001"); + if (sourceVersion) { + await launch(["update", RUNTIME_VERSION]); + await ready(); + assert.equal((await loadConfig(root)).previousVersion, sourceVersion); + const checkpoints = await readdir(join(root, "checkpoints")); + assert.ok(checkpoints.length > 0); + assert.ok(await readFile(join(root, "checkpoints", checkpoints[0], "verified.json"), "utf8")); + await launch(["rollback"]); + await ready(); + assert.equal((await loadConfig(root)).version, sourceVersion); + await launch(["update", RUNTIME_VERSION]); + await ready(); + } + await launch(["provider", "install", "opencode"]); + await compose(["exec", "-T", "--user", "10001:10001", "spaceapp-core", "node", "-e", "require('node:fs').writeFileSync('/var/lib/spaceapp/memory/podman-smoke.txt','before backup')"]); + await launch(["backup"]); + assert.ok((await readdir(join(root, "backups"))).some((name) => name.startsWith("spaceapp-backup-"))); + await compose(["exec", "-T", "--user", "10001:10001", "spaceapp-core", "node", "-e", "require('node:fs').writeFileSync('/var/lib/spaceapp/memory/podman-smoke.txt','after backup')"]); + await launch(["restore"], { input: "RESTORE\n" }); + await ready(); + assert.equal(await compose(["exec", "-T", "--user", "10001:10001", "spaceapp-core", "cat", "/var/lib/spaceapp/memory/podman-smoke.txt"]), "before backup"); + await launch(["doctor"]); + await launch(["doctor", "--fix"]); + await launch(["support-bundle"]); + const bundleName = (await readdir(root)).find((name) => name.startsWith("support-bundle-")); + const bundle = JSON.parse(await readFile(join(root, bundleName), "utf8")); + assert.equal(bundle.engine.name, "podman"); assert.equal(bundle.engine.rootless, true); + await launch(["down"]); + await launch(["up"]); + await ready(); + await launch(["uninstall"]); + assert.equal(await podman(["ps", "-aq", "--filter", `label=com.docker.compose.project=${project}`]), ""); + await launch(["up"]); + await ready(); + await launch(["uninstall", "--purge"]); + assert.equal(await podman(["ps", "-aq", "--filter", `label=com.docker.compose.project=${project}`]), ""); + assert.equal(await podman(["volume", "ls", "-q", "--filter", `label=com.docker.compose.project=${project}`]), ""); + assert.equal(await podman(["network", "ls", "-q", "--filter", `label=com.docker.compose.project=${project}`]), ""); + cleaned = true; + console.log(JSON.stringify({ profile, passed: true, engine: "podman", rootless: true, cleaned: true })); +} finally { + // A failed install may not have committed its config, so also address only this project's labelled resources. + if (!cleaned) { + const ids = (await podman(["ps", "-aq", "--filter", `label=com.docker.compose.project=${project}`]).catch(() => "")).split(/\s+/).filter(Boolean); + if (ids.length) await podman(["rm", "-f", ...ids]); + for (const kind of ["volume", "network"]) { + const names = (await podman([kind, "ls", "-q", "--filter", `label=com.docker.compose.project=${project}`]).catch(() => "")).split(/\s+/).filter(Boolean); + if (names.length) await podman([kind, "rm", ...names]); + } + } + await rm(fixture, { recursive: true, force: true }); +} diff --git a/scripts/tests/public-docker-distribution.test.mjs b/scripts/tests/public-docker-distribution.test.mjs index 444f944f8..89ec29d37 100644 --- a/scripts/tests/public-docker-distribution.test.mjs +++ b/scripts/tests/public-docker-distribution.test.mjs @@ -95,7 +95,7 @@ test("runtime images patch fixable vulnerabilities in npm's bundled dependencies assert.match( normalizedRuntimeStage, - /npm pack --ignore-scripts --no-audit --no-fund --pack-destination \/tmp\/npm-security-patches brace-expansion@5\.0\.12 tar@7\.5\.21 ip-address@10\.7\.4 undici@6\.28\.1 postcss-selector-parser@7\.1\.6/ + /npm pack --ignore-scripts --no-audit --no-fund --pack-destination \/tmp\/npm-security-patches brace-expansion@5\.0\.12 tar@7\.5\.21 ip-address@10\.7\.4 undici@6\.29\.1 postcss-selector-parser@7\.1\.6/ ); assert.match( normalizedRuntimeStage, @@ -115,7 +115,7 @@ test("runtime images patch fixable vulnerabilities in npm's bundled dependencies ); assert.match( normalizedRuntimeStage, - /tar -xzf \/tmp\/npm-security-patches\/undici-6\.28\.1\.tgz -C \/usr\/local\/lib\/node_modules\/npm\/node_modules\/undici --strip-components=1/ + /tar -xzf \/tmp\/npm-security-patches\/undici-6\.29\.1\.tgz -C \/usr\/local\/lib\/node_modules\/npm\/node_modules\/undici --strip-components=1/ ); assert.match( normalizedRuntimeStage, From fff2513a073f6599591e72e61a8512b962e1b767 Mon Sep 17 00:00:00 2001 From: soundcake Date: Tue, 27 Oct 2026 23:26:16 +0000 Subject: [PATCH 02/10] ci(podman): isolate rootless runner storage Signed-off-by: Luke Snopkiewicz --- .github/workflows/podman.yml | 27 ++++++++++++++++++++++++++- 1 file changed, 26 insertions(+), 1 deletion(-) diff --git a/.github/workflows/podman.yml b/.github/workflows/podman.yml index 8ab1b73ab..006dc98a1 100644 --- a/.github/workflows/podman.yml +++ b/.github/workflows/podman.yml @@ -32,13 +32,38 @@ jobs: shell: bash run: | set -euo pipefail - # ubuntu-latest currently includes Podman 4.9; use the Linux Homebrew bottle for >=5. + # Install the supported engine/provider independently of the runner's bundled version. eval "$(/home/linuxbrew/.linuxbrew/bin/brew shellenv)" brew install podman docker-compose echo "$(brew --prefix)/bin" >> "$GITHUB_PATH" echo "PODMAN_COMPOSE_PROVIDER=$(brew --prefix)/bin/docker-compose" >> "$GITHUB_ENV" sudo apt-get update sudo apt-get install -y uidmap + # Keep the new engine separate from bundled rootful storage/configuration. + export XDG_CONFIG_HOME="$RUNNER_TEMP/spaceapp-podman-config" + export XDG_DATA_HOME="$RUNNER_TEMP/spaceapp-podman-data" + export XDG_RUNTIME_DIR="/run/user/$(id -u)" + export CONTAINERS_STORAGE_CONF="$XDG_CONFIG_HOME/containers/storage.conf" + export CONTAINERS_CONF="$XDG_CONFIG_HOME/containers/containers.conf" + mkdir -p "$XDG_CONFIG_HOME/containers" "$XDG_DATA_HOME/containers/storage" "$XDG_RUNTIME_DIR/containers" + cat > "$CONTAINERS_STORAGE_CONF" < "$CONTAINERS_CONF" <> "$GITHUB_ENV" # Grant user namespaces to this engine binary on Ubuntu's restricted-userns hosts. # This leaves the system restriction and other applications' profiles in place. if command -v apparmor_parser >/dev/null; then From a8af7fdcaf2ea7d20320e3c2fff6cb7712c382dc Mon Sep 17 00:00:00 2001 From: soundcake Date: Tue, 27 Oct 2026 23:30:38 +0000 Subject: [PATCH 03/10] fix(launcher): verify Podman version during engine detection Signed-off-by: Luke Snopkiewicz --- packages/run-spaceapp/src/engine.mjs | 3 ++- packages/run-spaceapp/tests/engine.test.mjs | 8 +++++++- 2 files changed, 9 insertions(+), 2 deletions(-) diff --git a/packages/run-spaceapp/src/engine.mjs b/packages/run-spaceapp/src/engine.mjs index 1d8559369..d4fe87a20 100644 --- a/packages/run-spaceapp/src/engine.mjs +++ b/packages/run-spaceapp/src/engine.mjs @@ -37,7 +37,8 @@ export async function selectContainerEngine({ requested, existing, execute, plat if (requested !== undefined) return containerEngine(requested); if (existing) return containerEngine(existing.engine); if (platform === "win32" || !await hasPodman("podman", env) || await available(execute, "docker", ["info"])) return "docker"; - if (await available(execute, "podman", ["--version"])) return "podman"; + const podmanVersion = await capture(execute, "podman", ["--version"]); + if (/^podman version \d+\.\d+(?:\.\d+)?(?:\s|$)/i.test(podmanVersion ?? "")) return "podman"; // Keep the existing Docker prerequisite installer when neither engine is available. return "docker"; } diff --git a/packages/run-spaceapp/tests/engine.test.mjs b/packages/run-spaceapp/tests/engine.test.mjs index 56366a0fb..fd3f1db15 100644 --- a/packages/run-spaceapp/tests/engine.test.mjs +++ b/packages/run-spaceapp/tests/engine.test.mjs @@ -38,8 +38,14 @@ test("automatic selection prefers working Docker; Podman-only hosts never instal const selection = { platform: "linux", hasPodman: async () => true }; assert.equal(await selectContainerEngine({ ...selection, execute: async () => 0 }), "docker"); const seen = []; - assert.equal(await selectContainerEngine({ ...selection, execute: async (spec) => { seen.push(spec.command); return spec.command === "docker" ? 127 : 0; } }), "podman"); + assert.equal(await selectContainerEngine({ ...selection, execute: async (spec, io) => { + seen.push(spec.command); + if (spec.command === "docker") return 127; + io.stdout.write("podman version 6.1.4\n"); + return 0; + } }), "podman"); assert.deepEqual(seen, ["docker", "podman"]); + assert.equal(await selectContainerEngine({ ...selection, execute: async (spec) => spec.command === "docker" ? 127 : 0 }), "docker", "an empty successful response does not identify a Podman executable"); assert.equal(await selectContainerEngine({ ...selection, existing: { engine: "podman" }, execute: () => { throw new Error("no autodetection for saved choice"); } }), "podman"); assert.throws(() => containerEngine("sh"), /docker or podman/); }); From eca532f937b4651437348f3d1111f862ed9d5327 Mon Sep 17 00:00:00 2001 From: Luke Snopkiewicz Date: Tue, 27 Oct 2026 23:45:19 +0000 Subject: [PATCH 04/10] ci(podman): avoid warning during ownership mapping Signed-off-by: Luke Snopkiewicz --- .github/workflows/podman.yml | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/.github/workflows/podman.yml b/.github/workflows/podman.yml index 006dc98a1..bd27c75d4 100644 --- a/.github/workflows/podman.yml +++ b/.github/workflows/podman.yml @@ -51,7 +51,6 @@ jobs: driver = "overlay" runroot = "$XDG_RUNTIME_DIR/containers" graphroot = "$XDG_DATA_HOME/containers/storage" - rootless_storage_path = "$XDG_DATA_HOME/containers/storage" EOF cat > "$CONTAINERS_CONF" < Date: Wed, 28 Oct 2026 00:08:10 +0000 Subject: [PATCH 05/10] ci(podman): configure automatic health checks Signed-off-by: Luke Snopkiewicz --- .github/workflows/podman.yml | 20 +++++++++++++++++++- 1 file changed, 19 insertions(+), 1 deletion(-) diff --git a/.github/workflows/podman.yml b/.github/workflows/podman.yml index bd27c75d4..bb674dfea 100644 --- a/.github/workflows/podman.yml +++ b/.github/workflows/podman.yml @@ -63,6 +63,8 @@ jobs: printf '%s\n' "XDG_CONFIG_HOME=$XDG_CONFIG_HOME" "XDG_DATA_HOME=$XDG_DATA_HOME" \ "XDG_RUNTIME_DIR=$XDG_RUNTIME_DIR" "CONTAINERS_STORAGE_CONF=$CONTAINERS_STORAGE_CONF" \ "CONTAINERS_CONF=$CONTAINERS_CONF" >> "$GITHUB_ENV" + # Health checks run in systemd's user manager, outside this shell. + systemctl --user import-environment XDG_CONFIG_HOME XDG_DATA_HOME CONTAINERS_STORAGE_CONF CONTAINERS_CONF # Grant user namespaces to this engine binary on Ubuntu's restricted-userns hosts. # This leaves the system restriction and other applications' profiles in place. if command -v apparmor_parser >/dev/null; then @@ -79,6 +81,22 @@ jobs: docker.io/library/node:22.23.0-trixie-slim@sha256:cfd8f2a5bc50526aee08e88970979f92722828e7dcc6d8983607fb8bff4bdb82 \ node -e 'if (process.getuid() !== 10001) process.exit(1); require("node:fs").writeFileSync("/probe/owned", "ok")' test "$(stat -c '%u' "$probe/owned")" = "$(id -u)" + # Require an automatically scheduled health check, not a manual invocation. + health_container=$(podman run -d --network none --health-cmd 'node -e "process.exit(0)"' --health-interval 1s \ + docker.io/library/node:22.23.0-trixie-slim@sha256:cfd8f2a5bc50526aee08e88970979f92722828e7dcc6d8983607fb8bff4bdb82 \ + node -e 'setInterval(() => {}, 1000)') + trap 'podman rm -f "$health_container"; rm -rf -- "$probe"' EXIT + for attempt in {1..30}; do + health=$(podman inspect --format '{{.State.Health.Status}}' "$health_container") + if [ "$health" = healthy ]; then break; fi + sleep 1 + done + if [ "$health" != healthy ]; then + podman inspect --format '{{json .State.Health}}' "$health_container" + systemctl --user list-timers --all --no-pager + exit 1 + fi + echo 'PASS automatic Podman health check' - name: Unit tests and runtime images shell: bash run: | @@ -104,4 +122,4 @@ jobs: if curl --fail --silent --unix-socket "$XDG_RUNTIME_DIR/podman/podman.sock" http://localhost/_ping; then break; fi sleep 1 done - node scripts/podman-launcher-smoke.mjs small + timeout --signal=TERM --kill-after=30s 20m node scripts/podman-launcher-smoke.mjs small From 5fd714d6dfd495959ef1061e9140484f99d097b3 Mon Sep 17 00:00:00 2001 From: Luke Snopkiewicz Date: Wed, 28 Oct 2026 01:35:02 +0000 Subject: [PATCH 06/10] fix(launcher): preserve Podman output and Docker config contracts Signed-off-by: Luke Snopkiewicz --- packages/run-spaceapp/src/cli.mjs | 4 +- packages/run-spaceapp/src/index.mjs | 5 +- packages/run-spaceapp/tests/engine.test.mjs | 55 ++++++++++++++++++++- 3 files changed, 59 insertions(+), 5 deletions(-) diff --git a/packages/run-spaceapp/src/cli.mjs b/packages/run-spaceapp/src/cli.mjs index f7adbb167..e18cf7544 100644 --- a/packages/run-spaceapp/src/cli.mjs +++ b/packages/run-spaceapp/src/cli.mjs @@ -363,7 +363,7 @@ export async function run(argv, { assertEngineSelection(selectedEngine, existingEngineConfig, requestedAccess ?? promptController.answers.access ?? existingEngineConfig?.accessMode, platform); if (selectedEngine === "podman") { ensureDocker = ensurePodmanAvailable; - stdout.write("Container engine: Podman (rootless).\n"); + stderr.write("Container engine: Podman (rootless).\n"); } if (globalFlags.localImages) { const originalExecute = execute; @@ -450,7 +450,7 @@ export async function run(argv, { // Recheck the selected connection before runtime writes: a saved Podman choice // must not silently use a rootful connection selected after installation. if (selectedEngine === "podman" && !["support-bundle", "open"].includes(command)) { - const prerequisites = await ensurePodmanAvailable({ execute, platform, stdout, stderr }); + const prerequisites = await ensurePodmanAvailable({ execute, platform, stdout: { write() {} }, stderr }); if (prerequisites.code !== 0) return prerequisites.code; } if (commandNeedsRuntimeFiles(command, args)) { diff --git a/packages/run-spaceapp/src/index.mjs b/packages/run-spaceapp/src/index.mjs index a1474b823..159a9ede6 100644 --- a/packages/run-spaceapp/src/index.mjs +++ b/packages/run-spaceapp/src/index.mjs @@ -1005,6 +1005,7 @@ export async function prepareInstallation(root, { validateHome(root); const existing = await loadConfig(root).catch((error) => { if (error.code === "ENOENT") return null; throw error; }); if (engine !== undefined) assertEngineSelection(engine, existing, accessMode ?? existing?.accessMode, process.platform); + const persistEngine = engine === "podman" || existing?.engine !== undefined; await mkdir(root, { recursive: true, mode: 0o700 }); await Promise.all([ mkdir(join(root, "backups"), { recursive: true, mode: 0o700 }), @@ -1026,7 +1027,7 @@ export async function prepareInstallation(root, { profile: resolvedProfile ?? "light", accessMode: resolveInstallAccessMode(accessMode), companionsEnabled, - engine + engine: persistEngine ? engine : undefined }); } if (config.version !== version) { @@ -1046,7 +1047,7 @@ export async function prepareInstallation(root, { if (companionsEnabled !== undefined && config.companionsEnabled !== companionsEnabled) { config = { ...config, companionsEnabled }; } - if (engine !== undefined) config = { ...config, engine: containerEngine(engine) }; + if (persistEngine && engine !== undefined) config = { ...config, engine: containerEngine(engine) }; validateConfig(config); const postgresPasswordPath = join(root, "secrets", "postgres-password"); diff --git a/packages/run-spaceapp/tests/engine.test.mjs b/packages/run-spaceapp/tests/engine.test.mjs index fd3f1db15..8206a2203 100644 --- a/packages/run-spaceapp/tests/engine.test.mjs +++ b/packages/run-spaceapp/tests/engine.test.mjs @@ -5,7 +5,7 @@ import { tmpdir } from "node:os"; import { join } from "node:path"; import { Readable, Writable } from "node:stream"; import { containerEngine, selectContainerEngine, engineExecutor, inspectContainerEngine, podmanChecks, renderEngineCompose, withPodmanWritersPaused } from "../src/engine.mjs"; -import { composeCommand, createDefaultConfig, initializeInstallation, installResourceChecks, loadConfig, prepareInstallation, renderWorkspaceCompose, validateConfig } from "../src/index.mjs"; +import { composeCommand, createDefaultConfig, initializeInstallation, installResourceChecks, loadConfig, prepareInstallation, renderWorkspaceCompose, saveConfig, validateConfig } from "../src/index.mjs"; import { run } from "../src/cli.mjs"; import { inspectStoragePlan } from "../src/storage.mjs"; @@ -198,3 +198,56 @@ test("Podman resumes exactly the running writers after backup failure", async () assert.ok(calls[0].includes("label=com.docker.compose.project=spaceapp-test")); assert.ok(calls[0].includes("status=running")); }); + + +test("Podman preserves machine-readable version and list output", async (t) => { + const f = await fixture(t); + await initializeInstallation(f.root, { version: "1.1.8", engine: "podman" }); + for (const args of [["--version"], ["-v"], ["workspace", "list"], ["credentials", "list"], ["user", "list"]]) { + let output = "", diagnostics = ""; + const stdout = new Writable({ write(chunk, _encoding, done) { output += chunk; done(); } }); + const stderr = new Writable({ write(chunk, _encoding, done) { diagnostics += chunk; done(); } }); + assert.equal(await run(args, { ...f.options, stdout, stderr }), 0); + if (args.length === 1) assert.match(output, /^\d+\.\d+\.\d+\n$/); + else assert.doesNotThrow(() => JSON.parse(output), args.join(" ")); + assert.match(diagnostics, /Container engine: Podman/); + } +}); + +test("Podman recheck failures remain visible on stderr without stdout pollution", async (t) => { + const f = await fixture(t); + await initializeInstallation(f.root, { version: "1.1.8", engine: "podman" }); + let output = "", diagnostics = ""; + const stdout = new Writable({ write(chunk, _encoding, done) { output += chunk; done(); } }); + const stderr = new Writable({ write(chunk, _encoding, done) { diagnostics += chunk; done(); } }); + const execute = async (spec, io) => { + if (spec.args[0] === "info") { + io.stdout.write(JSON.stringify({ ...info, host: { ...info.host, security: { rootless: false } } })); + return 0; + } + return f.execute(spec, io); + }; + assert.equal(await run(["workspace", "list"], { ...f.options, execute, stdout, stderr }), 1); + assert.equal(output, ""); + assert.match(diagnostics, /FAIL/); +}); + +test("explicit Docker selection preserves the legacy config shape on install and update", async (t) => { + const f = await fixture(t); + await initializeInstallation(f.root, { version: "1.1.8", engine: "docker" }); + assert.equal(Object.hasOwn(await loadConfig(f.root), "engine"), false); + const update = await prepareInstallation(f.root, { version: "1.1.9", engine: "docker" }); + assert.equal(Object.hasOwn(update.config, "engine"), false); +}); + +test("existing explicit Docker and Podman engine choices remain persisted", async (t) => { + for (const engine of ["docker", "podman"]) { + const f = await fixture(t); + await initializeInstallation(f.root, { version: "1.1.8", engine }); + const config = { ...await loadConfig(f.root), engine }; + await saveConfig(f.root, config); + const update = await prepareInstallation(f.root, { version: "1.1.9", engine }); + assert.equal(update.config.engine, engine); + assert.equal((await prepareInstallation(f.root, { version: "1.1.9" })).config.engine, engine); + } +}); From e1345d948079355b6fa2deb0a99e7198c747e0f9 Mon Sep 17 00:00:00 2001 From: Luke Snopkiewicz Date: Wed, 28 Oct 2026 04:28:06 +0000 Subject: [PATCH 07/10] fix(podman): finish writer and smoke cleanup on failures Signed-off-by: Luke Snopkiewicz --- .github/workflows/podman.yml | 3 + packages/run-spaceapp/src/engine.mjs | 15 ++++- packages/run-spaceapp/tests/engine.test.mjs | 39 +++++++++++ scripts/podman-launcher-smoke.mjs | 74 ++++++++++++++++----- scripts/tests/podman-smoke-cleanup.test.mjs | 60 +++++++++++++++++ 5 files changed, 171 insertions(+), 20 deletions(-) create mode 100644 scripts/tests/podman-smoke-cleanup.test.mjs diff --git a/.github/workflows/podman.yml b/.github/workflows/podman.yml index bb674dfea..544c3299e 100644 --- a/.github/workflows/podman.yml +++ b/.github/workflows/podman.yml @@ -4,12 +4,14 @@ on: paths: - 'packages/run-spaceapp/**' - 'scripts/podman-launcher-smoke.mjs' + - 'scripts/tests/podman-smoke-cleanup.test.mjs' - '.github/workflows/podman.yml' - 'Dockerfile' push: paths: - 'packages/run-spaceapp/**' - 'scripts/podman-launcher-smoke.mjs' + - 'scripts/tests/podman-smoke-cleanup.test.mjs' - '.github/workflows/podman.yml' workflow_dispatch: permissions: @@ -102,6 +104,7 @@ jobs: run: | set -euo pipefail npm test --workspace=run-spaceapp + node --test scripts/tests/podman-smoke-cleanup.test.mjs version=$(node -p "require('./packages/run-spaceapp/package.json').spaceappRuntimeVersion") for target in core cli; do podman build --format docker --layers --build-arg TARGETARCH=amd64 --target "$target" -t "ghcr.io/oll4com/spaceapp-$target:$version" . diff --git a/packages/run-spaceapp/src/engine.mjs b/packages/run-spaceapp/src/engine.mjs index d4fe87a20..209d8e96e 100644 --- a/packages/run-spaceapp/src/engine.mjs +++ b/packages/run-spaceapp/src/engine.mjs @@ -176,12 +176,21 @@ export async function withPodmanWritersPaused(execute, project, io, backup) { }).map((row) => row.Id ?? row.ID); if (ids.some((id) => !/^[a-f0-9]{12,64}$/.test(id ?? ""))) throw new Error("Podman returned an invalid writer ID."); if (!ids.length) return backup(); - let code, resumeCode; + let code = 0, resumeCode = 0; + const pausedIds = []; try { - code = await execute({ command: "podman", args: ["pause", ...ids] }, io); + for (const id of ids) { + code = await execute({ command: "podman", args: ["pause", id] }, io); + if (code !== 0) break; + pausedIds.push(id); + } if (code === 0) code = await backup(); } finally { - resumeCode = await execute({ command: "podman", args: ["unpause", ...ids] }, io).catch(() => 1); + // Resolve each ID separately: one removed writer must not strand the others. + for (const id of pausedIds) { + const current = await execute({ command: "podman", args: ["unpause", id] }, io).catch(() => 1); + if (current !== 0) resumeCode = current; + } if (resumeCode !== 0) io.stderr.write("Podman could not resume all backup writers. Check their status before continuing.\n"); } return code !== 0 ? code : resumeCode; diff --git a/packages/run-spaceapp/tests/engine.test.mjs b/packages/run-spaceapp/tests/engine.test.mjs index 8206a2203..131ca2643 100644 --- a/packages/run-spaceapp/tests/engine.test.mjs +++ b/packages/run-spaceapp/tests/engine.test.mjs @@ -199,6 +199,45 @@ test("Podman resumes exactly the running writers after backup failure", async () assert.ok(calls[0].includes("status=running")); }); +test("Podman resumes surviving writers when another writer disappears during backup", async () => { + const ids = ["a".repeat(64), "b".repeat(64)]; + const paused = new Set(); + let backupRan = false, warning = ""; + const execute = async (spec, io) => { + const [command, ...args] = spec.args; + if (command === "ps") io.stdout.write(JSON.stringify(ids.map((Id) => ({ Id, Labels: { "com.docker.compose.service": "spaceapp-core" } })))); + if (command === "pause") args.forEach((id) => paused.add(id)); + if (command === "unpause") { + // Podman resolves the whole request before unpausing any container. + if (args.some((id) => !paused.has(id))) return 125; + args.forEach((id) => paused.delete(id)); + } + return 0; + }; + const code = await withPodmanWritersPaused(execute, "spaceapp-test", { stderr: { write: (text) => { warning += text; } } }, async () => { + backupRan = true; + paused.delete(ids[0]); + return 0; + }); + assert.equal(backupRan, true); + assert.equal(code, 125); + assert.equal(paused.size, 0); + assert.match(warning, /could not resume all backup writers/); +}); + +test("Podman only resumes writers it successfully paused after a partial pause failure", async () => { + const ids = ["a".repeat(64), "b".repeat(64), "c".repeat(64)]; + const calls = []; + const execute = async (spec, io) => { + calls.push(spec.args); + if (spec.args[0] === "ps") io.stdout.write(JSON.stringify(ids.map((Id) => ({ Id, Labels: { "com.docker.compose.service": "temporal" } })))); + if (spec.args[0] === "pause" && spec.args.includes(ids[1])) return 125; + return 0; + }; + assert.equal(await withPodmanWritersPaused(execute, "spaceapp-test", { stderr: sink() }, async () => assert.fail("Backup must not run with an unpaused writer")), 125); + assert.deepEqual(calls.slice(1), [["pause", ids[0]], ["pause", ids[1]], ["unpause", ids[0]]]); +}); + test("Podman preserves machine-readable version and list output", async (t) => { const f = await fixture(t); diff --git a/scripts/podman-launcher-smoke.mjs b/scripts/podman-launcher-smoke.mjs index 663c69d58..8892cb0a1 100644 --- a/scripts/podman-launcher-smoke.mjs +++ b/scripts/podman-launcher-smoke.mjs @@ -6,6 +6,7 @@ import { mkdtemp, mkdir, readFile, readdir, rm, stat } from "node:fs/promises"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { fileURLToPath } from "node:url"; +import { setTimeout as delay } from "node:timers/promises"; import { RUNTIME_VERSION } from "../packages/run-spaceapp/src/package-info.mjs"; import { composeCommand, composeProjectName, loadConfig } from "../packages/run-spaceapp/src/index.mjs"; @@ -14,17 +15,38 @@ assert.ok(["small", "medium", "large"].includes(profile), "Pass small, medium or const fixture = await mkdtemp(join(process.env.SPACEAPP_SMOKE_TMPDIR ?? tmpdir(), "spaceapp-podman-smoke-")); const root = join(fixture, "installation"); const workspace = join(fixture, "workspace"); -await mkdir(workspace); const project = composeProjectName(root); const env = { ...process.env, SPACEAPP_HOME: root, SPACEAPP_TELEMETRY: "0" }; const answers = { confirm: true, profile, access: "isolated", companions: false, telemetry: false, open: false, purgeConfirm: "DELETE" }; const localImages = process.env.SPACEAPP_SMOKE_LOCAL_IMAGES === "1"; const sourceVersion = process.env.SPACEAPP_SMOKE_SOURCE_VERSION; if (sourceVersion) assert.match(sourceVersion, /^\d+\.\d+\.\d+$/); +const stopping = new AbortController(); +const activeCommands = new Set(); -function podman(args, input) { +// Keep each acceptance command and its descendants in one cancellable process group. +function startCommand(command, args, options) { + stopping.signal.throwIfAborted(); + const child = spawn(command, args, { ...options, detached: true }); + const stop = () => { + if (child.pid) { + try { process.kill(-child.pid, "SIGTERM"); } + catch (error) { if (error.code !== "ESRCH") console.error(error); } + } + }; + stopping.signal.addEventListener("abort", stop, { once: true }); + const closed = new Promise((resolve) => child.once("close", () => { + stopping.signal.removeEventListener("abort", stop); + activeCommands.delete(closed); + resolve(); + })); + activeCommands.add(closed); + return child; +} + +function podman(args, input, cleanup = false) { return new Promise((resolve, reject) => { - const child = spawn("podman", args, { env, stdio: ["pipe", "pipe", "inherit"], shell: false }); + const child = (cleanup ? spawn : startCommand)("podman", args, { env, stdio: ["pipe", "pipe", "inherit"], shell: false }); let output = ""; child.stdout.on("data", (chunk) => { output += chunk; }); child.on("error", reject); @@ -39,7 +61,7 @@ async function launch(args, { input, version } = {}) { // The exported launcher runs with a real older release for upgrade acceptance; no executor is replaced. const entry = version ? ["--input-type=module", "--eval", `import {run} from ${JSON.stringify(new URL("../packages/run-spaceapp/src/cli.mjs", import.meta.url).href)}; process.exitCode = await run(process.argv.slice(1), {runtimeVersion:${JSON.stringify(version)}});`, "--"] : [fileURLToPath(new URL("../packages/run-spaceapp/bin/spaceapp.mjs", import.meta.url))]; - const child = spawn(process.execPath, [...entry, + const child = startCommand(process.execPath, [...entry, ...args, ...flags, ...(localImages ? ["--local-images"] : [])], { env, stdio: ["pipe", "inherit", "inherit"], shell: false }); child.on("error", reject); child.on("close", resolve); @@ -54,19 +76,43 @@ async function compose(args) { } async function ready() { for (let attempt = 0; attempt < 90; attempt++) { + stopping.signal.throwIfAborted(); try { - const response = await fetch("http://127.0.0.1:4911/readyz", { signal: AbortSignal.timeout(2000) }); + const response = await fetch("http://127.0.0.1:4911/readyz", { signal: AbortSignal.any([stopping.signal, AbortSignal.timeout(2000)]) }); if (response.ok && (await response.json()).ok === true) { - assert.equal((await fetch("http://127.0.0.1:4911/", { signal: AbortSignal.timeout(5000) })).status, 200); + assert.equal((await fetch("http://127.0.0.1:4911/", { signal: AbortSignal.any([stopping.signal, AbortSignal.timeout(5000)]) })).status, 200); return; } } catch { /* Startup may not have bound the port yet. */ } - await new Promise((resolve) => setTimeout(resolve, 2000)); + await delay(2000, undefined, { signal: stopping.signal }); } throw new Error("readyz/web did not become ready"); } let cleaned = false; +let cleanupPromise; +function cleanup() { + return cleanupPromise ??= (async () => { + stopping.abort(); + await Promise.all(activeCommands); + // A failed install may not have committed its config; only address this project's labelled resources. + if (!cleaned) { + const ids = (await podman(["ps", "-aq", "--filter", `label=com.docker.compose.project=${project}`], undefined, true).catch(() => "")).split(/\s+/).filter(Boolean); + if (ids.length) await podman(["rm", "-f", ...ids], undefined, true); + for (const kind of ["volume", "network"]) { + const names = (await podman([kind, "ls", "-q", "--filter", `label=com.docker.compose.project=${project}`], undefined, true).catch(() => "")).split(/\s+/).filter(Boolean); + if (names.length) await podman([kind, "rm", ...names], undefined, true); + } + } + await rm(fixture, { recursive: true, force: true }); + })(); +} +const terminate = () => { + process.exitCode = 143; + void cleanup().catch((error) => { console.error(error); process.exitCode = 1; }); +}; +process.once("SIGTERM", terminate); try { + await mkdir(workspace); await launch(["install", "--engine", "podman", "--profile", profile], { version: sourceVersion }); assert.equal((await loadConfig(root)).engine, "podman"); await ready(); @@ -128,15 +174,9 @@ try { assert.equal(await podman(["network", "ls", "-q", "--filter", `label=com.docker.compose.project=${project}`]), ""); cleaned = true; console.log(JSON.stringify({ profile, passed: true, engine: "podman", rootless: true, cleaned: true })); +} catch (error) { + if (!stopping.signal.aborted) throw error; } finally { - // A failed install may not have committed its config, so also address only this project's labelled resources. - if (!cleaned) { - const ids = (await podman(["ps", "-aq", "--filter", `label=com.docker.compose.project=${project}`]).catch(() => "")).split(/\s+/).filter(Boolean); - if (ids.length) await podman(["rm", "-f", ...ids]); - for (const kind of ["volume", "network"]) { - const names = (await podman([kind, "ls", "-q", "--filter", `label=com.docker.compose.project=${project}`]).catch(() => "")).split(/\s+/).filter(Boolean); - if (names.length) await podman([kind, "rm", ...names]); - } - } - await rm(fixture, { recursive: true, force: true }); + await cleanup(); + process.off("SIGTERM", terminate); } diff --git a/scripts/tests/podman-smoke-cleanup.test.mjs b/scripts/tests/podman-smoke-cleanup.test.mjs new file mode 100644 index 000000000..7e7a60483 --- /dev/null +++ b/scripts/tests/podman-smoke-cleanup.test.mjs @@ -0,0 +1,60 @@ +import assert from "node:assert/strict"; +import { spawn } from "node:child_process"; +import { once } from "node:events"; +import { mkdtemp, mkdir, readFile, readdir, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import test from "node:test"; + +for (const mode of ["signal", "failure"]) { + test(`Podman smoke awaits project cleanup after ${mode}`, { skip: process.platform === "win32", timeout: 15000 }, async (t) => { + const root = await mkdtemp(join(tmpdir(), "podman-cleanup-test-")); + t.after(() => rm(root, { recursive: true, force: true })); + for (const dir of ["scripts", "packages/run-spaceapp/src", "packages/run-spaceapp/bin", "bin", "fixtures"]) await mkdir(join(root, dir), { recursive: true }); + // Run the unmodified smoke entry point against disposable command-boundary fixtures. + await writeFile(join(root, "scripts/podman-launcher-smoke.mjs"), await readFile(new URL("../podman-launcher-smoke.mjs", import.meta.url))); + await writeFile(join(root, "packages/run-spaceapp/src/package-info.mjs"), 'export const RUNTIME_VERSION="1.1.8";'); + await writeFile(join(root, "packages/run-spaceapp/src/index.mjs"), 'export const composeProjectName=()=>"spaceapp-signal-test"; export const composeCommand=()=>{}; export const loadConfig=()=>{};'); + await writeFile(join(root, "packages/run-spaceapp/bin/spaceapp.mjs"), mode === "signal" + ? `import {spawn} from 'node:child_process'; +console.log('INSTALLER '+process.pid); +const child=spawn(process.execPath,['-e','console.log("CHILD_READY");setInterval(()=>{},1000)'],{stdio:['ignore','pipe','inherit']}); +child.stdout.on('data',()=>{console.log('READY '+child.pid)});setInterval(()=>{},1000);` + : 'process.exit(7);'); + await writeFile(join(root, "bin/podman"), `#!${process.execPath} +import('node:fs/promises').then(async ({appendFile})=>{ +const args=process.argv.slice(2);await appendFile(process.env.TEST_LOG,JSON.stringify(args)+'\\n'); +if(args.includes('--filter')&&!args.includes('label=com.docker.compose.project=spaceapp-signal-test'))process.exit(9); +if(args[0]==='ps')console.log('test-container'); +else if(args[1]==='ls')console.log('test-'+args[0]); +else if(args.includes('rm'))await new Promise(resolve=>setTimeout(resolve,80)); +});`, { mode: 0o755 }); + const child = spawn(process.execPath, [join(root, "scripts/podman-launcher-smoke.mjs")], { + env: { ...process.env, PATH: `${join(root, "bin")}:${process.env.PATH}`, SPACEAPP_SMOKE_TMPDIR: join(root, "fixtures"), SPACEAPP_SMOKE_SOURCE_VERSION: "", TEST_LOG: join(root, "commands.jsonl") }, + stdio: ["ignore", "pipe", "pipe"] + }); + const closed = once(child, "close"); + let output = "", errors = "", descendant, installer; + child.stdout.on("data", (chunk) => { + output += chunk; + installer = Number(/INSTALLER (\d+)/.exec(output)?.[1]) || installer; + const ready = /READY (\d+)/.exec(output); + if (ready && !descendant) { descendant = Number(ready[1]); child.kill("SIGTERM"); } + }); + child.stderr.on("data", (chunk) => { errors += chunk; }); + t.after(() => { + child.kill("SIGKILL"); + if (installer) { + try { process.kill(-installer, "SIGKILL"); } + catch (error) { if (error.code !== "ESRCH") throw error; } + } + }); + const [code, signal] = await closed; + assert.equal(signal, null, errors); + assert.equal(code, mode === "signal" ? 143 : 1, errors); + if (descendant) assert.throws(() => process.kill(descendant, 0), { code: "ESRCH" }); + assert.deepEqual(await readdir(join(root, "fixtures")), []); + const commands = (await readFile(join(root, "commands.jsonl"), "utf8")).trim().split("\n").map(JSON.parse); + assert.deepEqual(commands.filter((args) => args.includes("rm")), [["rm", "-f", "test-container"], ["volume", "rm", "test-volume"], ["network", "rm", "test-network"]]); + }); +} From a9fb59407bcd19fc186b89b6f302bc95b133d2d2 Mon Sep 17 00:00:00 2001 From: Luke Snopkiewicz Date: Wed, 28 Oct 2026 05:05:15 +0000 Subject: [PATCH 08/10] fix(podman): continue cleanup after removal failures Signed-off-by: Luke Snopkiewicz --- scripts/podman-launcher-smoke.mjs | 22 ++++++++++++------ scripts/tests/podman-smoke-cleanup.test.mjs | 25 ++++++++++++++++----- 2 files changed, 34 insertions(+), 13 deletions(-) diff --git a/scripts/podman-launcher-smoke.mjs b/scripts/podman-launcher-smoke.mjs index 8892cb0a1..6beafa62c 100644 --- a/scripts/podman-launcher-smoke.mjs +++ b/scripts/podman-launcher-smoke.mjs @@ -90,20 +90,28 @@ async function ready() { } let cleaned = false; let cleanupPromise; +function cleanupError(error) { + console.error("Podman smoke cleanup failed:", error); + process.exitCode ||= 1; + return ""; +} function cleanup() { return cleanupPromise ??= (async () => { stopping.abort(); await Promise.all(activeCommands); // A failed install may not have committed its config; only address this project's labelled resources. - if (!cleaned) { - const ids = (await podman(["ps", "-aq", "--filter", `label=com.docker.compose.project=${project}`], undefined, true).catch(() => "")).split(/\s+/).filter(Boolean); - if (ids.length) await podman(["rm", "-f", ...ids], undefined, true); - for (const kind of ["volume", "network"]) { - const names = (await podman([kind, "ls", "-q", "--filter", `label=com.docker.compose.project=${project}`], undefined, true).catch(() => "")).split(/\s+/).filter(Boolean); - if (names.length) await podman([kind, "rm", ...names], undefined, true); + try { + if (!cleaned) { + const ids = (await podman(["ps", "-aq", "--filter", `label=com.docker.compose.project=${project}`], undefined, true).catch(cleanupError)).split(/\s+/).filter(Boolean); + if (ids.length) await podman(["rm", "-f", ...ids], undefined, true).catch(cleanupError); + for (const kind of ["volume", "network"]) { + const names = (await podman([kind, "ls", "-q", "--filter", `label=com.docker.compose.project=${project}`], undefined, true).catch(cleanupError)).split(/\s+/).filter(Boolean); + if (names.length) await podman([kind, "rm", ...names], undefined, true).catch(cleanupError); + } } + } finally { + await rm(fixture, { recursive: true, force: true }).catch(cleanupError); } - await rm(fixture, { recursive: true, force: true }); })(); } const terminate = () => { diff --git a/scripts/tests/podman-smoke-cleanup.test.mjs b/scripts/tests/podman-smoke-cleanup.test.mjs index 7e7a60483..0340a685d 100644 --- a/scripts/tests/podman-smoke-cleanup.test.mjs +++ b/scripts/tests/podman-smoke-cleanup.test.mjs @@ -6,8 +6,14 @@ import { tmpdir } from "node:os"; import { join } from "node:path"; import test from "node:test"; -for (const mode of ["signal", "failure"]) { - test(`Podman smoke awaits project cleanup after ${mode}`, { skip: process.platform === "win32", timeout: 15000 }, async (t) => { +const scenarios = [ + { mode: "signal", failedRemoval: "" }, + { mode: "failure", failedRemoval: "" }, + ...["rm", "volume", "network", "all"].map((failedRemoval) => ({ mode: "failure", failedRemoval })), + { mode: "signal", failedRemoval: "all" } +]; +for (const { mode, failedRemoval } of scenarios) { + test(`Podman smoke awaits project cleanup after ${mode}${failedRemoval ? ` with ${failedRemoval} removal failing` : ""}`, { skip: process.platform === "win32", timeout: 15000 }, async (t) => { const root = await mkdtemp(join(tmpdir(), "podman-cleanup-test-")); t.after(() => rm(root, { recursive: true, force: true })); for (const dir of ["scripts", "packages/run-spaceapp/src", "packages/run-spaceapp/bin", "bin", "fixtures"]) await mkdir(join(root, dir), { recursive: true }); @@ -15,22 +21,27 @@ for (const mode of ["signal", "failure"]) { await writeFile(join(root, "scripts/podman-launcher-smoke.mjs"), await readFile(new URL("../podman-launcher-smoke.mjs", import.meta.url))); await writeFile(join(root, "packages/run-spaceapp/src/package-info.mjs"), 'export const RUNTIME_VERSION="1.1.8";'); await writeFile(join(root, "packages/run-spaceapp/src/index.mjs"), 'export const composeProjectName=()=>"spaceapp-signal-test"; export const composeCommand=()=>{}; export const loadConfig=()=>{};'); - await writeFile(join(root, "packages/run-spaceapp/bin/spaceapp.mjs"), mode === "signal" + const setup = `import {mkdirSync,writeFileSync} from 'node:fs'; +mkdirSync(process.env.SPACEAPP_HOME,{recursive:true});writeFileSync(process.env.SPACEAPP_HOME+'/synthetic-secret','fixture');`; + await writeFile(join(root, "packages/run-spaceapp/bin/spaceapp.mjs"), setup + (mode === "signal" ? `import {spawn} from 'node:child_process'; console.log('INSTALLER '+process.pid); const child=spawn(process.execPath,['-e','console.log("CHILD_READY");setInterval(()=>{},1000)'],{stdio:['ignore','pipe','inherit']}); child.stdout.on('data',()=>{console.log('READY '+child.pid)});setInterval(()=>{},1000);` - : 'process.exit(7);'); + : 'process.exit(7);')); await writeFile(join(root, "bin/podman"), `#!${process.execPath} import('node:fs/promises').then(async ({appendFile})=>{ const args=process.argv.slice(2);await appendFile(process.env.TEST_LOG,JSON.stringify(args)+'\\n'); if(args.includes('--filter')&&!args.includes('label=com.docker.compose.project=spaceapp-signal-test'))process.exit(9); if(args[0]==='ps')console.log('test-container'); else if(args[1]==='ls')console.log('test-'+args[0]); -else if(args.includes('rm'))await new Promise(resolve=>setTimeout(resolve,80)); +else if(args.includes('rm')){ +await new Promise(resolve=>setTimeout(resolve,80)); +if(process.env.TEST_FAIL_REMOVAL==='all'||process.env.TEST_FAIL_REMOVAL===args[0])process.exit(73); +} });`, { mode: 0o755 }); const child = spawn(process.execPath, [join(root, "scripts/podman-launcher-smoke.mjs")], { - env: { ...process.env, PATH: `${join(root, "bin")}:${process.env.PATH}`, SPACEAPP_SMOKE_TMPDIR: join(root, "fixtures"), SPACEAPP_SMOKE_SOURCE_VERSION: "", TEST_LOG: join(root, "commands.jsonl") }, + env: { ...process.env, PATH: `${join(root, "bin")}:${process.env.PATH}`, SPACEAPP_SMOKE_TMPDIR: join(root, "fixtures"), SPACEAPP_SMOKE_SOURCE_VERSION: "", TEST_LOG: join(root, "commands.jsonl"), TEST_FAIL_REMOVAL: failedRemoval }, stdio: ["ignore", "pipe", "pipe"] }); const closed = once(child, "close"); @@ -52,6 +63,8 @@ else if(args.includes('rm'))await new Promise(resolve=>setTimeout(resolve,80)); const [code, signal] = await closed; assert.equal(signal, null, errors); assert.equal(code, mode === "signal" ? 143 : 1, errors); + if (mode === "failure") assert.match(errors, /AssertionError \[ERR_ASSERTION\]: install --engine podman --profile small/); + if (failedRemoval) assert.match(errors, /Podman smoke cleanup failed: Error: podman \w+ exited 73/); if (descendant) assert.throws(() => process.kill(descendant, 0), { code: "ESRCH" }); assert.deepEqual(await readdir(join(root, "fixtures")), []); const commands = (await readFile(join(root, "commands.jsonl"), "utf8")).trim().split("\n").map(JSON.parse); From 7b9dfb2bbf0d368a87be7267e6d2a1f20592aa0e Mon Sep 17 00:00:00 2001 From: Luke Snopkiewicz Date: Wed, 28 Oct 2026 06:15:32 +0000 Subject: [PATCH 09/10] docs(podman): describe launcher helper contracts Signed-off-by: Luke Snopkiewicz --- packages/run-spaceapp/src/cli.mjs | 13 +++++++++++++ packages/run-spaceapp/src/engine.mjs | 10 ++++++++++ packages/run-spaceapp/src/index.mjs | 7 +++++++ packages/run-spaceapp/src/storage.mjs | 2 ++ packages/run-spaceapp/tests/engine.test.mjs | 1 + scripts/podman-launcher-smoke.mjs | 7 +++++++ 6 files changed, 40 insertions(+) diff --git a/packages/run-spaceapp/src/cli.mjs b/packages/run-spaceapp/src/cli.mjs index e18cf7544..c904fe6c3 100644 --- a/packages/run-spaceapp/src/cli.mjs +++ b/packages/run-spaceapp/src/cli.mjs @@ -272,6 +272,7 @@ async function writeRunOnceContinuation(root, { runtimeVersion, actions }) { await writeFile(runOnceContinuationPath(root), `${JSON.stringify(payload, null, 2)}\n`, { mode: 0o600 }); } +/** Dispatch launcher commands with injected I/O and host probes. Bind runtime operations to the saved engine before writes. */ export async function run(argv, { env = process.env, platform = process.platform, @@ -679,6 +680,7 @@ export async function run(argv, { throw new Error(`Unknown command "${command}". Run "${UNIVERSAL_COMMAND} help".`); } +/** Plan and confirm installation, enforce engine and capacity constraints, then commit and start the selected runtime. */ async function installCommand(args, { root, launcherVersion, @@ -1099,6 +1101,7 @@ async function removeInactiveProfileServices({root, config, stateRoot = root, ex return 0; } +/** Collect approved setup choices and a reviewable plan while preserving existing installation data. */ async function planInteractiveSetup({ args, root, @@ -1343,6 +1346,7 @@ async function offerUnattendedContinuation(root, runtimeVersion, stdin, stdout, } } +/** Repair approved configuration and runtime files after prerequisite/storage checks; dry runs make no changes. */ async function repairRuntime({ root, config, platform, stdin, stdout, stderr, execute, request, sleep, dryRun = false, ensureDocker = ensureDockerAvailable, env = process.env, arch = process.arch, inspectResources = inspectSystemResources }) { const raw = await readRawConfig(root); const plan = planConfigRepairs(raw); @@ -1515,6 +1519,7 @@ async function collectFiles(directory, logicalDirectory) { // Writers (core, CLI, browser, Temporal) are frozen, not stopped, while the backup runs in a one-off container: // the database, files, memory and Temporal histories are one moment and CLI sessions survive (external review // 2026-10-16). Compose skips services that are not running. +/** Run the backup while application writers are paused, then resume them even when the backup fails. */ async function backupWithWritersPaused(execute, root, config, io) { const options = { profile: config.profile, companionsEnabled: config.companionsEnabled }; io.stdout.write("Pausing SpaceApp writers for the backup; the app and CLI sessions resume afterwards...\n"); @@ -1753,6 +1758,7 @@ async function restoreRuntimeAfterFailedInstall({ } } +/** Parse installation-specific options after global flags are removed; reject unknown options and profile names. */ function parseInstallArgs(args) { let requestedProfile = "auto"; let requestedAccessMode; @@ -1830,6 +1836,7 @@ function assertRegisteredUser(config, userId) { } } +/** List or update registered workspace binds and apply their Compose configuration through the selected engine. */ async function workspaceCommand(rawArgs, { root, config, platform, stdin, stdout, stderr, execute }) { const { userId, args } = takeUserOption(rawArgs); if (userId) assertRegisteredUser(config, userId); @@ -2098,6 +2105,7 @@ async function userCommand(args, { root, config, stdin, stdout, stderr, execute, throw new Error(`Usage: ${UNIVERSAL_COMMAND} user [--memory <2g>]|restore |remove |list>`); } +/** Install an optional provider in the selected user container, or verify an already bundled provider. */ async function providerCommand(rawArgs, { root, config, stdin, stdout, stderr, execute }) { const { userId, args } = takeUserOption(rawArgs); if (args.length !== 2 || args[0] !== "install") { @@ -2199,6 +2207,7 @@ async function reinstallCommand({ return 0; } +/** Write allowlisted configuration, engine capacity and service diagnostics without copying credentials or raw engine metadata. */ async function supportBundleCommand({ root, config, @@ -2272,6 +2281,7 @@ async function supportBundleCommand({ return 0; } +/** Require explicit destructive confirmation, remove installation state and initialize a clean config using the saved engine. */ async function factoryResetCommand({ root, config, @@ -2446,6 +2456,7 @@ async function verifyStorage({ root, config, existingConfig, operation, inspectR return 0; } +/** Combine host, storage and selected-engine probes into diagnostics; return failure when a required capability is absent. */ async function doctor({ root, platform, @@ -2709,6 +2720,7 @@ export function executeCommand(spec, { stdin, stdout, stderr, input } = {}) { }); } +/** Launch only the enumerated executables, preserving literal arguments and the caller-supplied spawn options. */ function spawnTrustedCommand(spec, options) { const args = spec.args; switch (spec.command) { @@ -2813,6 +2825,7 @@ function wait(milliseconds) { return new Promise((resolve) => setTimeout(resolve, milliseconds)); } +/** Describe supported launcher commands and installation choices for the CLI help response. */ function helpText() { return `SpaceApp self-hosted launcher diff --git a/packages/run-spaceapp/src/engine.mjs b/packages/run-spaceapp/src/engine.mjs index 209d8e96e..ac2a95e02 100644 --- a/packages/run-spaceapp/src/engine.mjs +++ b/packages/run-spaceapp/src/engine.mjs @@ -3,11 +3,13 @@ import { constants } from "node:fs"; import { delimiter, join } from "node:path"; import { Writable } from "node:stream"; +/** Normalize an omitted engine to Docker and reject names outside the supported pair. */ export function containerEngine(value = "docker") { if (value !== "docker" && value !== "podman") throw new Error("Container engine must be docker or podman."); return value; } +/** Reject engine migrations, unsupported Podman hosts and host-root access before installation writes. */ export function assertEngineSelection(engine, existing, accessMode, platform) { containerEngine(engine); if (existing && containerEngine(existing.engine) !== engine) { @@ -21,11 +23,13 @@ export function assertEngineSelection(engine, existing, accessMode, platform) { } } +/** Probe a command without interactive input or output; a failure or thrown error means unavailable. */ async function available(execute, command, args) { try { return await execute({ command, args, timeoutMs: 15000 }, { stdin: null, stdout: null, stderr: null }) === 0; } catch { return false; } } +/** Check executable candidates on the supplied PATH without launching them. */ async function commandOnPath(command, env) { for (const directory of (env.PATH ?? "").split(delimiter)) { if (directory && await access(join(directory, command), constants.X_OK).then(() => true, () => false)) return true; @@ -33,6 +37,7 @@ async function commandOnPath(command, env) { return false; } +/** Honor explicit and saved choices first; otherwise prefer a working Docker engine, then identified Podman. */ export async function selectContainerEngine({ requested, existing, execute, platform, env = process.env, hasPodman = commandOnPath }) { if (requested !== undefined) return containerEngine(requested); if (existing) return containerEngine(existing.engine); @@ -61,6 +66,7 @@ export function engineExecutor(execute, engine, platform = process.platform, env return run; } +/** Capture bounded diagnostic output; failed or timed-out probes return null instead of leaking stderr. */ async function capture(execute, command, args) { let text = ""; const stdout = new Writable({ write(chunk, _encoding, done) { if (text.length < 1024 * 1024) text += chunk.toString(); done(); } }); @@ -69,7 +75,9 @@ async function capture(execute, command, args) { } catch { return null; } } +/** Decode optional probe output, returning null for unavailable or malformed JSON. */ function json(text) { try { return JSON.parse(text); } catch { return null; } } +/** Compare the leading major/minor version components; missing or malformed versions fail closed. */ function versionAtLeast(version, major, minor = 0) { const match = /^(?:v)?(\d+)\.(\d+)(?:\.|$)/.exec(version ?? ""); return Boolean(match && (Number(match[1]) > major || Number(match[1]) === major && Number(match[2]) >= minor)); @@ -114,6 +122,7 @@ export async function inspectContainerEngine(execute, engine, platform = process }; } +/** Build operator-facing checks for the supported provider, rootless mode, capacity and delegated cgroups. */ export function podmanChecks(info, platform = process.platform) { const checks = [ { name: "Podman", ok: versionAtLeast(info.version, 5), detail: info.version ?? "not running or inaccessible; start Podman first" }, @@ -129,6 +138,7 @@ export function podmanChecks(info, platform = process.platform) { return checks; } +/** Report prerequisite checks to the appropriate stream and return their aggregate exit status without installation. */ export async function ensurePodmanAvailable({ execute, platform, stdout, stderr }) { const info = await inspectContainerEngine(execute, "podman", platform); const checks = podmanChecks(info, platform); diff --git a/packages/run-spaceapp/src/index.mjs b/packages/run-spaceapp/src/index.mjs index 159a9ede6..53254b7bb 100644 --- a/packages/run-spaceapp/src/index.mjs +++ b/packages/run-spaceapp/src/index.mjs @@ -276,6 +276,7 @@ export async function inspectSystemResources(root) { }; } +/** Compare host and engine allocations with the chosen profile, including additional operation-specific disk requirements. */ export function installResourceChecks(resources, profile = "small", { requiredDiskBytes, operation = "fresh" } = {}) { const cpuCount = Number(resources?.cpuCount); const totalMemoryBytes = Number(resources?.totalMemoryBytes); @@ -312,6 +313,7 @@ export function installResourceChecks(resources, profile = "small", { requiredDi ]; } +/** Create a versioned installation configuration; omit the engine field for legacy-compatible Docker defaults. */ export function createDefaultConfig({ version, profile = "light", @@ -340,6 +342,7 @@ export function createDefaultConfig({ }; } +/** Validate persisted installation settings and reject incompatible engine, access, profile and user/workspace values. */ export function validateConfig(config) { if (!config || typeof config !== "object" || Array.isArray(config)) { throw new Error("SpaceApp config must be an object."); @@ -682,6 +685,7 @@ export async function userNamespacesUsable(platform = process.platform, read = ( // mounts). Only where the sandbox can run: elsewhere it would widen the container for nothing. const CLI_SANDBOX_OPTIONS = [" security_opt:", " - seccomp=./seccomp-cli.json", " - apparmor=unconfined"]; +/** Render configured workspace mounts for each runtime service, preserving read-only access and engine-specific isolation. */ export function renderWorkspaceCompose(config, { cliSandbox = false } = {}) { validateConfig(config); // Podman uses its native default seccomp/AppArmor policy. Docker Compose sends @@ -798,6 +802,7 @@ export function renderHostAccessCompose(config) { ].join("\n"); } +/** Build a literal Compose command for one installation and action, defaulting legacy configs to Docker. */ export function composeCommand(action, root, options = {}) { validateHome(root); const stateRoot = options.stateRoot ?? root; @@ -995,6 +1000,7 @@ export async function writeRuntimeFiles(root, config, imageLock = PACKAGE_IMAGE_ await atomicWrite(join(root, "compose.host-access.yml"), renderHostAccessCompose(config)); } +/** Prepare validated settings and required secrets without committing config or switching the runtime. */ export async function prepareInstallation(root, { version, profile, @@ -1104,6 +1110,7 @@ export async function prepareInstallation(root, { return { config, setupToken }; } +/** Write validated runtime files to the installation root and optionally persist the prepared configuration. */ export async function commitInstallation(root, config, { templateDir = defaultTemplateDir(), persistConfig = true, diff --git a/packages/run-spaceapp/src/storage.mjs b/packages/run-spaceapp/src/storage.mjs index 994c296cf..f98cf8ddb 100644 --- a/packages/run-spaceapp/src/storage.mjs +++ b/packages/run-spaceapp/src/storage.mjs @@ -96,6 +96,7 @@ async function installedImageDigests(root, version) { return digests; } +/** Estimate additional image/checkpoint space and inspect both host and selected-engine storage before cutover. */ export async function inspectStoragePlan({ root, config, existingConfig, resources, execute, operation, fetch = globalThis.fetch }) { const dockerInfo = await captureProbe(execute, { command: "docker", args: ["info", "--format", "{{json .}}"] }); let engine = null; @@ -176,6 +177,7 @@ export async function inspectStoragePlan({ root, config, existingConfig, resourc } }; } +/** Compare available space with the operation reserve; require measurable Podman storage instead of assuming host capacity. */ export function storageChecks(plan, resources) { const checks = [{ name: "Free disk", ok: resources.freeDiskBytes >= plan.requiredDiskBytes, detail: `${gib(resources.freeDiskBytes)} GiB available; ${gib(plan.requiredDiskBytes)} GiB required additionally for ${plan.operation} (images ${gib(plan.missingImageBytes)}, checkpoint ${gib(plan.checkpointBytes)}, reserve 0.5)` }]; diff --git a/packages/run-spaceapp/tests/engine.test.mjs b/packages/run-spaceapp/tests/engine.test.mjs index 131ca2643..6f364462d 100644 --- a/packages/run-spaceapp/tests/engine.test.mjs +++ b/packages/run-spaceapp/tests/engine.test.mjs @@ -13,6 +13,7 @@ const info = { version: { Version: "5.8.1" }, host: { security: { rootless: true cgroupVersion: "v2", cgroupControllers: ["cpu", "memory", "pids"], cpus: 8, memTotal: 16 * 1024 ** 3 }, registries: { auth: "must-not-appear" }, store: { graphRoot: "/private/storage" } }; const sink = () => new Writable({ write(_chunk, _encoding, done) { done(); } }); +/** Create an isolated Podman installation fixture with recorded command probes and automatic directory cleanup. */ async function fixture(t) { const root = await mkdtemp(join(tmpdir(), "spaceapp-podman-")); t.after(() => rm(root, { recursive: true, force: true })); diff --git a/scripts/podman-launcher-smoke.mjs b/scripts/podman-launcher-smoke.mjs index 6beafa62c..0f3f13352 100644 --- a/scripts/podman-launcher-smoke.mjs +++ b/scripts/podman-launcher-smoke.mjs @@ -25,6 +25,7 @@ const stopping = new AbortController(); const activeCommands = new Set(); // Keep each acceptance command and its descendants in one cancellable process group. +/** Start an acceptance command in a cancellable process group and track its close event for awaited cleanup. */ function startCommand(command, args, options) { stopping.signal.throwIfAborted(); const child = spawn(command, args, { ...options, detached: true }); @@ -44,6 +45,7 @@ function startCommand(command, args, options) { return child; } +/** Run Podman and collect stdout; cleanup commands bypass cancellation so resource removal can finish after failure. */ function podman(args, input, cleanup = false) { return new Promise((resolve, reject) => { const child = (cleanup ? spawn : startCommand)("podman", args, { env, stdio: ["pipe", "pipe", "inherit"], shell: false }); @@ -54,6 +56,7 @@ function podman(args, input, cleanup = false) { child.stdin.end(input); }); } +/** Exercise the real launcher with acceptance answers and optional source-version images; require a successful exit. */ async function launch(args, { input, version } = {}) { console.log(`ACCEPT ${profile}: ${args.join(" ")}`); const flags = input === undefined ? ["--non-interactive", "--answers", JSON.stringify(answers)] : []; @@ -69,11 +72,13 @@ async function launch(args, { input, version } = {}) { }); assert.equal(code, 0, args.join(" ")); } +/** Derive this installation's Compose arguments from its saved configuration and run the requested action. */ async function compose(args) { const config = await loadConfig(root); const spec = composeCommand("status", root, config); return podman([...spec.args.slice(0, -1), ...args]); } +/** Wait for both application readiness and the web entry point within bounded requests; abort with the smoke run. */ async function ready() { for (let attempt = 0; attempt < 90; attempt++) { stopping.signal.throwIfAborted(); @@ -90,11 +95,13 @@ async function ready() { } let cleaned = false; let cleanupPromise; +/** Record a cleanup failure without replacing an earlier failure or signal exit status, and allow subsequent removals. */ function cleanupError(error) { console.error("Podman smoke cleanup failed:", error); process.exitCode ||= 1; return ""; } +/** Share one cleanup promise: stop child groups, remove only project-labelled resources, and always remove the fixture. */ function cleanup() { return cleanupPromise ??= (async () => { stopping.abort(); From 8158d0d864c7c46486397fa644d63a94a8c0ad3e Mon Sep 17 00:00:00 2001 From: Luke Snopkiewicz Date: Wed, 28 Oct 2026 07:24:03 +0000 Subject: [PATCH 10/10] Keep Podman contribution focused on launcher support Signed-off-by: Luke Snopkiewicz --- apps/api/tests/app-studio.test.ts | 5 +---- apps/api/tests/mail-copilot-sync.test.ts | 4 +--- scripts/tests/public-docker-distribution.test.mjs | 4 ++-- 3 files changed, 4 insertions(+), 9 deletions(-) diff --git a/apps/api/tests/app-studio.test.ts b/apps/api/tests/app-studio.test.ts index e028ae40e..63d514dde 100644 --- a/apps/api/tests/app-studio.test.ts +++ b/apps/api/tests/app-studio.test.ts @@ -34,10 +34,7 @@ async function fixture() { const roomEvents: Event[] = []; eventBus.subscribe((event) => { if (event.type === "APP_STUDIO_UPDATED" || event.type === "ARTIFACT_CREATED") roomEvents.push(event); }); const kickoffs: string[] = []; - // These route sequences test successive events, not equal-timestamp ID ordering. - let eventTime = Date.now(); - const repository = new InMemoryAppStudioRepository(() => new Date(eventTime = Math.max(Date.now(), eventTime + 1)).toISOString()); - const app = await createApp({ store, config, eventBus, jobApplyMailTriage: false, marketingCopilotScheduler: false, appStudioRepository: repository, appStudioKickoff: async (pane) => { kickoffs.push(pane.id); }, + const app = await createApp({ store, config, eventBus, jobApplyMailTriage: false, marketingCopilotScheduler: false, appStudioKickoff: async (pane) => { kickoffs.push(pane.id); }, auth: { sessionSecret: "test-secret-with-enough-length", devLogin: true, secureCookies: false } }); const login = await app.inject({ method: "POST", url: "/api/auth/login", payload: { email: "space@space.local", password: "space-dev" } }); const cookie = `space_session=${login.cookies.find((entry) => entry.name === "space_session")?.value}`; diff --git a/apps/api/tests/mail-copilot-sync.test.ts b/apps/api/tests/mail-copilot-sync.test.ts index 27103dbba..94cde621a 100644 --- a/apps/api/tests/mail-copilot-sync.test.ts +++ b/apps/api/tests/mail-copilot-sync.test.ts @@ -88,9 +88,7 @@ async function fixture() { }); const store = new InMemorySpaceStore(); const gmail = fakeGmail("token-me"); - // Keep successive fixture events distinct when requests finish in one millisecond. - let eventTime = Date.now(); - const repository = new InMemoryMailCopilotRepository(() => new Date(eventTime = Math.max(Date.now(), eventTime + 1)).toISOString()); + const repository = new InMemoryMailCopilotRepository(); const kickoffs: string[] = []; const app = await createApp({ store, config, mailCopilotGoogle: google, mailCopilotGmailFetch: gmail.fetchImpl, jobApplyMailTriage: false, mailCopilotSync: false, mailCopilotRepository: repository, mailCopilotKickoff: async (_pane, run) => { await new Promise((resolve) => setTimeout(resolve, 50)); kickoffs.push(run.id); }, auth: { sessionSecret: "test-secret-with-enough-length", devLogin: true, secureCookies: false } }); diff --git a/scripts/tests/public-docker-distribution.test.mjs b/scripts/tests/public-docker-distribution.test.mjs index 89ec29d37..444f944f8 100644 --- a/scripts/tests/public-docker-distribution.test.mjs +++ b/scripts/tests/public-docker-distribution.test.mjs @@ -95,7 +95,7 @@ test("runtime images patch fixable vulnerabilities in npm's bundled dependencies assert.match( normalizedRuntimeStage, - /npm pack --ignore-scripts --no-audit --no-fund --pack-destination \/tmp\/npm-security-patches brace-expansion@5\.0\.12 tar@7\.5\.21 ip-address@10\.7\.4 undici@6\.29\.1 postcss-selector-parser@7\.1\.6/ + /npm pack --ignore-scripts --no-audit --no-fund --pack-destination \/tmp\/npm-security-patches brace-expansion@5\.0\.12 tar@7\.5\.21 ip-address@10\.7\.4 undici@6\.28\.1 postcss-selector-parser@7\.1\.6/ ); assert.match( normalizedRuntimeStage, @@ -115,7 +115,7 @@ test("runtime images patch fixable vulnerabilities in npm's bundled dependencies ); assert.match( normalizedRuntimeStage, - /tar -xzf \/tmp\/npm-security-patches\/undici-6\.29\.1\.tgz -C \/usr\/local\/lib\/node_modules\/npm\/node_modules\/undici --strip-components=1/ + /tar -xzf \/tmp\/npm-security-patches\/undici-6\.28\.1\.tgz -C \/usr\/local\/lib\/node_modules\/npm\/node_modules\/undici --strip-components=1/ ); assert.match( normalizedRuntimeStage,