From e318cab7b54e1164d544c80e226bc8bf725e36e3 Mon Sep 17 00:00:00 2001 From: TassioSales Date: Tue, 27 Oct 2026 22:06:00 -0300 Subject: [PATCH] feat(launcher): support rootless Podman as container engine (#63) Add support for rootless Podman as an alternative container engine to Docker: - Engine abstraction and detection: support --engine podman|docker with auto-detection of Podman when Docker is absent. - SELinux compatibility: configure shared label z only on Podman mounts, avoiding unnecessary labels on Docker. - UID mapping: add userns_mode: keep-id:uid=10001,gid=10001 on Podman services to ensure container user 10001 writes files with host user ownership. - Podman >= 5.0.0 gating and full diagnostics in doctor and support-bundle (version, rootless mode, cgroup v2, delegated CPU/memory limits). - Profile-aware macOS Podman machine resize guidance using --disk-size in GiB. - Maintain config schema 4 compatibility without breaking existing Docker workflows or test suites. Signed-off-by: TassioSales --- .github/workflows/platform-matrix.yml | 102 +++++++ README.md | 2 +- docs/getting-started.md | 18 +- packages/run-spaceapp/README.md | 22 +- packages/run-spaceapp/package.json | 2 +- packages/run-spaceapp/src/cli.mjs | 253 +++++++++++---- packages/run-spaceapp/src/engine.mjs | 289 ++++++++++++++++++ packages/run-spaceapp/src/index.mjs | 53 +++- packages/run-spaceapp/src/prerequisites.mjs | 109 +++++++ packages/run-spaceapp/src/storage.mjs | 23 +- packages/run-spaceapp/tests/engine.test.mjs | 159 ++++++++++ .../run-spaceapp/tests/prerequisites.test.mjs | 60 ++++ packages/run-spaceapp/tests/storage.test.mjs | 29 ++ 13 files changed, 1029 insertions(+), 92 deletions(-) create mode 100644 packages/run-spaceapp/src/engine.mjs create mode 100644 packages/run-spaceapp/tests/engine.test.mjs diff --git a/.github/workflows/platform-matrix.yml b/.github/workflows/platform-matrix.yml index e09ecb53c..cbc98558c 100644 --- a/.github/workflows/platform-matrix.yml +++ b/.github/workflows/platform-matrix.yml @@ -57,3 +57,105 @@ jobs: - run: npm run check -w run-spaceapp - run: npm run pack:dry-run -w run-spaceapp - run: node packages/run-spaceapp/bin/spaceapp.mjs --version + + podman: + name: Podman / ubuntu-latest / Node 22.23.0 + runs-on: ubuntu-latest + timeout-minutes: 30 + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + + - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0 + with: + node-version: "22.23.0" + cache: npm + + - name: Install Podman and docker-compose provider + run: | + sudo apt-get update + sudo apt-get install -y ca-certificates curl gnupg lsb-release uidmap slirp4netns fuse-overlayfs + if ! command -v podman &>/dev/null || [ "$(printf '%s\n' "5.0.0" "$(podman --version | awk '{print $3}')" | sort -V | head -n1)" != "5.0.0" ]; then + curl -fsSL -o /tmp/podman-static.tar.gz https://github.com/mgoltzsche/podman-static/releases/latest/download/podman-linux-amd64.tar.gz + sudo tar -xzf /tmp/podman-static.tar.gz -C / + hash -r + fi + sudo install -m 0755 -d /etc/apt/keyrings + curl -fsSL https://download.docker.com/linux/ubuntu/gpg | sudo gpg --dearmor -o /etc/apt/keyrings/docker.gpg + echo "deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/docker.gpg] https://download.docker.com/linux/ubuntu $(lsb_release -cs) stable" | sudo tee /etc/apt/sources.list.d/docker.list + sudo apt-get update + sudo apt-get install -y docker-compose-plugin + podman_version=$(podman --version | awk '{print $3}') + echo "Podman version: $podman_version" + required="5.0.0" + if [ "$(printf '%s\n' "$required" "$podman_version" | sort -V | head -n1)" != "$required" ]; then + echo "Podman $podman_version is older than required $required" + exit 1 + fi + podman compose version + - name: Install workspace dependencies + run: npm ci + + - name: Non-interactive install with Podman (small profile) + env: + SPACEAPP_HOME: ${{ runner.temp }}/spaceapp-podman-ci + run: | + node packages/run-spaceapp/bin/spaceapp.mjs install \ + --engine podman \ + --profile small \ + --non-interactive \ + --answers '{"confirm":true,"profile":"small","access":"isolated","engine":"podman","companions":false,"telemetry":false,"open":false}' + + - name: Wait for /readyz + env: + SPACEAPP_HOME: ${{ runner.temp }}/spaceapp-podman-ci + run: | + for _ in {1..60}; do + if curl -fsS http://127.0.0.1:4911/readyz | grep -q '"ok":true'; then + echo "SpaceApp is ready" + exit 0 + fi + sleep 5 + done + echo "SpaceApp did not become ready" + node packages/run-spaceapp/bin/spaceapp.mjs logs + exit 1 + + - name: Doctor with Podman + env: + SPACEAPP_HOME: ${{ runner.temp }}/spaceapp-podman-ci + run: node packages/run-spaceapp/bin/spaceapp.mjs doctor + + - name: Backup with Podman + env: + SPACEAPP_HOME: ${{ runner.temp }}/spaceapp-podman-ci + run: node packages/run-spaceapp/bin/spaceapp.mjs backup + + - name: Uninstall with purge + env: + SPACEAPP_HOME: ${{ runner.temp }}/spaceapp-podman-ci + run: | + node packages/run-spaceapp/bin/spaceapp.mjs uninstall --purge-data --non-interactive --answers '{"purgeConfirm":"DELETE"}' + + - name: Assert no installation residue + env: + SPACEAPP_HOME: ${{ runner.temp }}/spaceapp-podman-ci + run: | + if podman ps -a --format '{{.Names}}' | grep -q "spaceapp-"; then + echo "Unexpected containers remain:" + podman ps -a + exit 1 + fi + if podman volume ls --format '{{.Name}}' | grep -q "spaceapp-"; then + echo "Unexpected volumes remain:" + podman volume ls + exit 1 + fi + rm -rf "$SPACEAPP_HOME" + if [ -d "$SPACEAPP_HOME" ]; then + echo "Unexpected residue: $SPACEAPP_HOME" + find "$SPACEAPP_HOME" -maxdepth 2 + exit 1 + fi + echo "No installation residue" diff --git a/README.md b/README.md index fd303d486..fa60f228c 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** (default) or rootless **Podman** (Podman ≥ 5.0.0). If Docker is not installed and no engine is specified, the installer automatically installs official Docker Desktop (Windows/macOS) or Docker Engine (Linux). When Podman is installed, select it using `--engine podman` (or via the interactive installer). Rootless Podman requires cgroup v2 on Linux; macOS runs Podman via a Podman machine. Note that `--access host-root` is restricted to Docker on Linux. ### Run the Installation Command diff --git a/docs/getting-started.md b/docs/getting-started.md index f892f4439..34f6002c0 100644 --- a/docs/getting-started.md +++ b/docs/getting-started.md @@ -1,9 +1,9 @@ # 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, +in container images orchestrated via Docker or rootless Podman. 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. +automatically when it is missing, or can use an existing rootless Podman installation. ## Requirements @@ -39,6 +39,20 @@ starts the service. Before adding the current user to the `docker` group, it explains that the group grants root-level privileges, adds the user automatically, and continues in the same command. +### Rootless Podman on Linux + +To install using rootless Podman instead of Docker: + +```bash +npx --yes run-spaceapp@latest install --engine podman +``` + +Requirements for Podman on Linux: +- Podman >= 5.0.0 and the `docker-compose-plugin` provider (`podman compose`). +- cgroup v2 with cpu and memory delegation enabled. +- Bind mounts (`workspace add`) automatically configure SELinux container labels (`:z`). +- Note: `--access host-root` requires Docker and cannot be used with Podman. + ## macOS Install Node.js 20.11 or newer, then run in Terminal: diff --git a/packages/run-spaceapp/README.md b/packages/run-spaceapp/README.md index 0e8b1ab3b..a37c05a14 100644 --- a/packages/run-spaceapp/README.md +++ b/packages/run-spaceapp/README.md @@ -1,6 +1,7 @@ # run-spaceapp -Cross-platform Docker launcher for SpaceApp. +Cross-platform container launcher for SpaceApp. The default engine is Docker; +Podman is also supported on Linux and on macOS through a Podman machine. Run the same command in Linux, macOS, or Windows 11 Command Prompt: @@ -40,6 +41,25 @@ work: during image pulls the launcher switches Docker to a temporary credential-free config so Docker Desktop's graphical credential helper is never invoked outside an interactive logon. +### Container engine selection + +Docker is the default engine. To use Podman instead, install Podman (>= 5.0) +and a Compose v2 provider such as `docker-compose-plugin`, then run: + +```bash +npx --yes run-spaceapp@latest install --engine podman +``` + +On Linux, Podman runs rootless by default and works with the `small` profile +on a 4 CPU / 7 GiB system. Host-root access is not available with Podman +because rootless containers cannot safely expose the entire host root +filesystem; use `--access isolated` (the default) or switch to Docker. + +On macOS, Podman requires a running Podman machine with at least 4 CPUs, +7 GiB memory and 15 GiB disk. The install wizard detects the machine and +reports its resources; if they are too small it prints the exact +`podman machine set` command to resize it. + The launcher selects `small`, `medium`, or `large` automatically using CPU, RAM and free disk. Docker's CPU and RAM allocation is checked as well. diff --git a/packages/run-spaceapp/package.json b/packages/run-spaceapp/package.json index 86528ff2a..64397b568 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/storage.mjs && node --check src/telemetry.mjs && node --check src/prerequisites.mjs && node --check src/engine.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..d4c1f2386 100644 --- a/packages/run-spaceapp/src/cli.mjs +++ b/packages/run-spaceapp/src/cli.mjs @@ -49,6 +49,16 @@ import { writeSetupToken } from "./index.mjs"; import { inspectStoragePlan, storageChecks } from "./storage.mjs"; +import { + defaultEngine, + detectAvailableEngine, + engineInfoFlags, + engineProbeSpecs, + inspectEngineDetails, + isSupportedEngine, + normalizeEngine, + parseEngineVersion +} from "./engine.mjs"; import { ensureDockerAvailable, prepareDockerCliPath, @@ -83,6 +93,7 @@ const trustedCommands = new Set([ "docker", "hdiutil", "open", + "podman", "powershell.exe", "sg", "shutdown.exe", @@ -344,7 +355,7 @@ export async function run(argv, { if (globalFlags.localImages) { const originalExecute = execute; execute = async (spec, io) => { - if (spec.command === "docker" && spec.args?.includes("compose")) { + if ((spec.command === "docker" || spec.command === "podman") && 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; @@ -433,7 +444,7 @@ export async function run(argv, { if (["up", "down", "status", "logs"].includes(command)) { assertNoArgs(args, command); - return runtimeExecute(composeCommand(command, root, { profile: config.profile, companionsEnabled: config.companionsEnabled }), { stdin, stdout, stderr }); + return runtimeExecute(composeCommand(command, root, { profile: config.profile, companionsEnabled: config.companionsEnabled, engine: config.engine }), { stdin, stdout, stderr }); } if (command === "open") { assertNoArgs(args, "open"); @@ -523,14 +534,14 @@ export async function run(argv, { await writeRuntimeFiles(root, rollback); const pullCode = await withHeadlessDockerConfig( platform, - composeCommand("pull", root, { profile: rollback.profile, companionsEnabled: rollback.companionsEnabled }), + composeCommand("pull", root, { profile: rollback.profile, companionsEnabled: rollback.companionsEnabled, engine: rollback.engine }), (pullSpec) => runtimeExecute(pullSpec, { stdin, stdout, stderr }) ); if (pullCode !== 0) { await writeRuntimeFiles(root, config); return pullCode; } - const upCode = await runtimeExecute(composeCommand("up", root, { profile: rollback.profile, companionsEnabled: rollback.companionsEnabled }), { stdin, stdout, stderr }); + const upCode = await runtimeExecute(composeCommand("up", root, { profile: rollback.profile, companionsEnabled: rollback.companionsEnabled, engine: rollback.engine }), { stdin, stdout, stderr }); if (upCode !== 0) { await writeRuntimeFiles(root, config); return upCode; @@ -562,18 +573,18 @@ export async function run(argv, { const backupId = await selectLatestBackupId(root); const backupCode = await backupWithWritersPaused(runtimeExecute, root, config, { stdin, stdout, stderr }); if (backupCode !== 0) return backupCode; - const stopCode = await runtimeExecute(composeCommand("stopForPortableRestore", root, { profile: config.profile, companionsEnabled: config.companionsEnabled }), { stdin, stdout, stderr }); + const stopCode = await runtimeExecute(composeCommand("stopForPortableRestore", root, { profile: config.profile, companionsEnabled: config.companionsEnabled, engine: config.engine }), { stdin, stdout, stderr }); if (stopCode !== 0) return stopCode; // The backup just made holds the data from before the restore. const safetyBackupId = await selectLatestBackupId(root).catch(() => "the newest backup"); const restoreCode = await runtimeExecute( - composeCommand("restore", root, { backupId, profile: config.profile }), + composeCommand("restore", root, { backupId, profile: config.profile, engine: config.engine }), { stdin, stdout, stderr } ); if (restoreCode !== 0) { // Exit 3: the restore changed nothing or put every database and file back, so SpaceApp starts again as it was (1.1.8). if (restoreCode === RESTORE_UNCHANGED_EXIT) { - const againCode = await runtimeExecute(composeCommand("up", root, { profile: config.profile, companionsEnabled: config.companionsEnabled }), { stdin, stdout, stderr }); + const againCode = await runtimeExecute(composeCommand("up", root, { profile: config.profile, companionsEnabled: config.companionsEnabled, engine: config.engine }), { stdin, stdout, stderr }); stderr.write(againCode === 0 ? `The restore of ${backupId} did not happen; SpaceApp runs again with its data from before the restore (also kept in the backup ${safetyBackupId}).\n` : `The restore of ${backupId} did not happen and SpaceApp could not be started again; run "${UNIVERSAL_COMMAND} up". Its data from before the restore is kept in the backup ${safetyBackupId}.\n`); @@ -582,7 +593,7 @@ export async function run(argv, { stderr.write(`The restore of ${backupId} failed and SpaceApp stays stopped: the messages above say what is left at the backup's state. To start SpaceApp anyway, run "${UNIVERSAL_COMMAND} up"; to try again, run "${UNIVERSAL_COMMAND} restore". The data from before the restore is kept in the backup ${safetyBackupId}.\n`); return restoreCode; } - const upCode = await runtimeExecute(composeCommand("up", root, { profile: config.profile, companionsEnabled: config.companionsEnabled }), { stdin, stdout, stderr }); + const upCode = await runtimeExecute(composeCommand("up", root, { profile: config.profile, companionsEnabled: config.companionsEnabled, engine: config.engine }), { stdin, stdout, stderr }); if (upCode !== 0) return upCode; await restoreBackupExtras({ root, config, backupId, stdin, stdout, stderr, execute: runtimeExecute }) .catch((error) => stderr.write(`The data was restored; the CLI settings of the backup were not: ${error.message}\n`)); @@ -600,7 +611,7 @@ export async function run(argv, { } stdout.write("Stopping and removing SpaceApp containers and network...\n"); const code = await runtimeExecute( - composeCommand("down", root, { profile: config.profile, companionsEnabled: config.companionsEnabled }), + composeCommand("down", root, { profile: config.profile, companionsEnabled: config.companionsEnabled, engine: config.engine }), { stdin, stdout, stderr } ); if (code === 0) { @@ -622,7 +633,7 @@ export async function run(argv, { } stdout.write("Removing SpaceApp containers, network, and Docker volumes...\n"); const code = await runtimeExecute( - composeCommand("purge", root, { profile: config.profile, companionsEnabled: config.companionsEnabled }), + composeCommand("purge", root, { profile: config.profile, companionsEnabled: config.companionsEnabled, engine: config.engine }), { stdin, stdout, stderr } ); if (code === 0) { @@ -678,6 +689,7 @@ async function installCommand(args, { const { requestedProfile, requestedAccessMode, + requestedEngine, noOpen, companionsEnabled, telemetryEnabled, @@ -696,6 +708,22 @@ async function installCommand(args, { "Host-root access is disabled for this launcher-only candidate because its runtime images were not rebuilt. Use --access isolated." ); } + const targetEngine = requestedEngine ?? existingConfig?.engine ?? defaultEngine(); + if (accessMode === "host-root" && normalizeEngine(targetEngine) === "podman") { + throw new Error( + "Host-root access is not supported with Podman. Podman rootless containers cannot safely expose the entire host root filesystem. Use --access isolated or --engine docker." + ); + } + if ( + existingConfig && + requestedEngine !== undefined && + normalizeEngine(existingConfig.engine ?? defaultEngine()) !== normalizeEngine(requestedEngine) + ) { + throw new Error( + `Cannot change engine from ${existingConfig.engine ?? defaultEngine()} to ${requestedEngine} ` + + `without reinstalling. Run backup, uninstall --purge-data, then install --engine ${requestedEngine}.` + ); + } await prepareDockerPath({ platform, env }); const resources = await inspectResources(root); let profile = requestedProfile === "auto" && existingConfig @@ -709,6 +737,7 @@ async function installCommand(args, { version: runtimeVersion, profile, accessMode, + engine: targetEngine, ...(companionsEnabled ? { companionsEnabled } : {}) }); // The stored choice also drives the app's first_answer event after later updates, so an explicit @@ -757,7 +786,8 @@ async function installCommand(args, { stdin, inspectResources, resources, - profile, operation + profile, operation, + engine: targetEngine }); stderr.write( `Installation stopped before downloading images. Fix the failed checks and run "${UNIVERSAL_COMMAND} install" again.\n` @@ -774,6 +804,8 @@ async function installCommand(args, { stdout, stderr, execute, + engine: targetEngine, + profile, installArgs: { root, requestedProfile, requestedAccessMode, noOpen, autoConfirm: true } }); if (prerequisiteResult.reexecuted) { @@ -788,14 +820,14 @@ async function installCommand(args, { stage = "doctor"; - let storagePlan = await inspectStoragePlan({ root, config: result.config, existingConfig, resources, execute, operation }); + let storagePlan = await inspectStoragePlan({ root, config: result.config, existingConfig, resources, execute, operation, engine: targetEngine }); if (requestedProfile === "auto" && !existingConfig) { const engineProfile = resolveInstallProfile("auto", storagePlan.resources); if (engineProfile !== profile) { profile = engineProfile; result.config = { ...result.config, profile }; stdout.write(`Docker resources select profile: ${profile}.\n`); - storagePlan = await inspectStoragePlan({ root, config: result.config, resources: storagePlan.resources, execute, operation }); + storagePlan = await inspectStoragePlan({ root, config: result.config, resources: storagePlan.resources, execute, operation, engine: targetEngine }); } } const doctorCode = await doctor({ @@ -808,6 +840,7 @@ async function installCommand(args, { inspectResources, resources, profile, + engine: targetEngine, dockerReady: true, operation, storagePlan }); if (doctorCode !== 0) { @@ -826,6 +859,7 @@ async function installCommand(args, { root, stateRoot: stagedStateRoot, profile, + engine: targetEngine, platform, stdin, stdout, @@ -859,6 +893,7 @@ async function installCommand(args, { profile, stateRoot: stagedStateRoot, companionsEnabled, + engine: targetEngine, ...options }); const pullCode = await withHeadlessDockerConfig( @@ -1051,7 +1086,7 @@ async function removeInactiveProfileServices({root, config, stateRoot = root, ex [settings.temporalEnabled, "removeTemporal", "medium"] ]) { if (enabled) continue; - const code = await execute(composeCommand(action, root, {profile, stateRoot}), {stdin, stdout, stderr}); + const code = await execute(composeCommand(action, root, {profile, stateRoot, engine: config.engine}), {stdin, stdout, stderr}); if (code !== 0) { stderr.write("Inactive service cleanup failed. Data volumes were preserved. Run doctor --fix.\n"); return code; @@ -1102,6 +1137,7 @@ async function planInteractiveSetup({ return { requestedProfile: actions.profile ?? "auto", requestedAccessMode: actions.accessMode, + requestedEngine: actions.engine, noOpen: actions.noOpen !== false, companionsEnabled: actions.companionsEnabled ?? false, telemetryEnabled: actions.telemetry ?? false, @@ -1122,6 +1158,12 @@ async function planInteractiveSetup({ { label: "isolated (recommended; no host access)", value: "isolated" }, { label: "host-root (Linux only; CLI sessions can read and modify the whole host)", value: "host-root" } ], { defaultIndex: parsed.requestedAccessMode === "host-root" ? 1 : 0, answerKey: "access" }); + + let engineChoice = parsed.requestedEngine ?? rawExisting?.engine ?? "docker"; + if (accessChoice === "host-root" && engineChoice === "podman") { + throw new Error("Host-root access is not supported with Podman. Use --access isolated or choose Docker."); + } + const companionsChoice = await promptYesNo(stdin, stdout, "Enable companion integrations (Claude Code, browser companions)?", { defaultYes: parsed.companionsEnabled, answerKey: "companions" }); stdout.write( "Anonymous telemetry sends spaceapp.dev a random install ID with the OS and its version, CPUs, RAM, profile,\n" + @@ -1146,6 +1188,7 @@ async function planInteractiveSetup({ `Runtime image version: not installed -> ${runtimeVersion}`, `Profile: ${profileChoice === "auto" ? `auto (${resolved})` : profileChoice}`, `Access mode: ${accessChoice}`, + `Engine: ${engineChoice}`, `Companions: ${companionsChoice ? "enabled" : "disabled"}`, `Telemetry: ${telemetryChoice ? "enabled" : "disabled"}`, `Docker installation: ${dockerChoice ? "automatic if missing" : "not included"}`, @@ -1159,6 +1202,7 @@ async function planInteractiveSetup({ await offerUnattendedContinuation(root, runtimeVersion, stdin, stdout, { profile: profileChoice, accessMode: accessChoice, + engine: engineChoice, companionsEnabled: companionsChoice, telemetry: telemetryChoice, installId, @@ -1168,6 +1212,7 @@ async function planInteractiveSetup({ return { requestedProfile: profileChoice, requestedAccessMode: accessChoice, + requestedEngine: engineChoice, noOpen: !openBrowserChoice, companionsEnabled: companionsChoice, telemetryEnabled: telemetryChoice, @@ -1238,6 +1283,7 @@ async function planInteractiveSetup({ await offerUnattendedContinuation(root, runtimeVersion, stdin, stdout, { profile: parsed.requestedProfile, accessMode: parsed.requestedAccessMode, + engine: parsed.requestedEngine, companionsEnabled: parsed.companionsEnabled, noOpen: parsed.noOpen }); @@ -1245,6 +1291,7 @@ async function planInteractiveSetup({ return { requestedProfile: parsed.requestedProfile, requestedAccessMode: parsed.requestedAccessMode, + requestedEngine: parsed.requestedEngine, noOpen: parsed.noOpen, companionsEnabled: parsed.companionsEnabled, telemetryEnabled: false @@ -1261,6 +1308,12 @@ function hasRequestedConfigChange(rawExisting, parsed) { ) { return true; } + if ( + parsed.requestedEngine !== undefined && + (rawExisting.engine ?? defaultEngine()) !== parsed.requestedEngine + ) { + return true; + } if (parsed.companionsEnabled && rawExisting.companionsEnabled !== true) { return true; } @@ -1318,6 +1371,8 @@ async function repairRuntime({ root, config, platform, stdin, stdout, stderr, ex ...plan.actions.map((action) => action.detail) ])) return 1; const prerequisites = await ensureDocker({platform, arch, env, stdin, stdout, stderr, execute, sleep: sleep || wait, + engine: repaired.engine ?? defaultEngine(), + profile: repaired.profile, installArgs: {root,requestedProfile:repaired.profile,requestedAccessMode:repaired.accessMode,noOpen:true,autoConfirm:true}}); if(prerequisites.reexecuted || prerequisites.code !== 0) return prerequisites.code; config = repaired; @@ -1326,7 +1381,7 @@ async function repairRuntime({ root, config, platform, stdin, stdout, stderr, ex await writeRuntimeFiles(root, config); const pullCode = await withHeadlessDockerConfig( platform, - composeCommand("pull", root, { profile: config.profile, companionsEnabled: config.companionsEnabled }), + composeCommand("pull", root, { profile: config.profile, companionsEnabled: config.companionsEnabled, engine: config.engine }), (pullSpec) => execute(pullSpec, { stdin, stdout, stderr }) ); if (pullCode !== 0) { @@ -1335,7 +1390,7 @@ async function repairRuntime({ root, config, platform, stdin, stdout, stderr, ex } if (await verifyStorage({ root, config, existingConfig: config, operation: "repair", inspectResources, execute, stdout, stderr }) !== 0) return 1; const upCode = await execute( - composeCommand("repair", root, { profile: config.profile, companionsEnabled: config.companionsEnabled }), + composeCommand("repair", root, { profile: config.profile, companionsEnabled: config.companionsEnabled, engine: config.engine }), { stdin, stdout, stderr } ); if (upCode !== 0) { @@ -1400,7 +1455,7 @@ async function performUpdate({ checkpoint = await createQuiescedCheckpoint(root, config, {stdin, stdout, stderr, execute, platform}); await commitInstallation(root, updated, { persistConfig: false }); const upCode = await execute( - composeCommand("up", root, { profile: updated.profile, companionsEnabled: updated.companionsEnabled }), + composeCommand("up", root, { profile: updated.profile, companionsEnabled: updated.companionsEnabled, engine: updated.engine }), { stdin, stdout, stderr } ); if (upCode !== 0) { @@ -1478,7 +1533,7 @@ async function collectFiles(directory, logicalDirectory) { // 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. async function backupWithWritersPaused(execute, root, config, io) { - const options = { profile: config.profile, companionsEnabled: config.companionsEnabled }; + const options = { profile: config.profile, companionsEnabled: config.companionsEnabled, engine: config.engine }; io.stdout.write("Pausing SpaceApp writers for the backup; the app and CLI sessions resume afterwards...\n"); const pauseCode = await execute(composeCommand("pauseWriters", root, options), io); if (pauseCode !== 0) return pauseCode; @@ -1541,7 +1596,7 @@ async function createCheckpoint(root, config, { stdin, stdout, stderr, execute, let dumpCode; try { dumpCode = await execute( - composeCommand("checkpointDump", root, { profile: config.profile, companionsEnabled: config.companionsEnabled }), + composeCommand("checkpointDump", root, { profile: config.profile, companionsEnabled: config.companionsEnabled, engine: config.engine }), { stdin: null, stdout: dumpStream, stderr } ); } finally { @@ -1623,6 +1678,7 @@ async function reportInstallDiagnostics({ root, stateRoot, profile, + engine, platform, stdin, stdout, @@ -1637,6 +1693,7 @@ async function reportInstallDiagnostics({ composeCommand(action, root, { profile, stateRoot, + engine, lines: 200 }), { stdin, stdout, stderr }, @@ -1679,7 +1736,8 @@ async function restoreRuntimeAfterFailedInstall({ const restoreCode = await runtimeExecute( composeCommand("up", root, { profile: existingConfig.profile, - companionsEnabled: existingConfig.companionsEnabled + companionsEnabled: existingConfig.companionsEnabled, + engine: existingConfig.engine }) ); if (restoreCode === 0) { @@ -1698,7 +1756,8 @@ async function restoreRuntimeAfterFailedInstall({ composeCommand("down", root, { profile: existingConfig?.profile ?? attemptedProfile, stateRoot: existingConfig ? root : stagedStateRoot, - companionsEnabled: existingConfig?.companionsEnabled ?? companionsEnabled + companionsEnabled: existingConfig?.companionsEnabled ?? companionsEnabled, + engine: existingConfig?.engine ?? defaultEngine() }) ); if (stopCode !== 0) { @@ -1716,6 +1775,7 @@ async function restoreRuntimeAfterFailedInstall({ function parseInstallArgs(args) { let requestedProfile = "auto"; let requestedAccessMode; + let requestedEngine; let noOpen = false; let companionsEnabled = false; for (let index = 0; index < args.length; index += 1) { @@ -1746,8 +1806,17 @@ function parseInstallArgs(args) { requestedAccessMode = argument.slice("--access=".length); continue; } + if (argument === "--engine" && index + 1 < args.length) { + requestedEngine = args[index + 1]; + index += 1; + continue; + } + if (argument.startsWith("--engine=")) { + requestedEngine = argument.slice("--engine=".length); + 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 [--profile auto|small|medium|large|light|standard] [--access isolated|host-root] [--engine docker|podman] [--with-companions] [--no-open]` ); } if ( @@ -1755,13 +1824,17 @@ function parseInstallArgs(args) { ( requestedAccessMode !== undefined && !["isolated", "host-root"].includes(requestedAccessMode) + ) || + ( + requestedEngine !== undefined && + !isSupportedEngine(requestedEngine) ) ) { 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 [--profile auto|small|medium|large|light|standard] [--access isolated|host-root] [--engine docker|podman] [--with-companions] [--no-open]` ); } - return { requestedProfile, requestedAccessMode, noOpen, companionsEnabled }; + return { requestedProfile, requestedAccessMode, requestedEngine, noOpen, companionsEnabled }; } async function loadExistingInstallation(root) { @@ -1796,7 +1869,7 @@ async function workspaceCommand(rawArgs, { root, config, platform, stdin, stdout const [action, identity, ...rest] = args; const applyWorkspaces = async (updated) => { // A user's mounts belong to their own container; "up" recreates only the services whose mounts changed. - const code = await execute(composeCommand(userId ? "up" : "syncWorkspaces", root, { profile: updated.profile, companionsEnabled: updated.companionsEnabled }), { stdin, stdout, stderr }); + const code = await execute(composeCommand(userId ? "up" : "syncWorkspaces", root, { profile: updated.profile, companionsEnabled: updated.companionsEnabled, engine: updated.engine }), { stdin, stdout, stderr }); if (code !== 0) stderr.write(`The change is saved, but the running services could not pick it up. Run "${UNIVERSAL_COMMAND} up".\n`); return code; }; @@ -1852,7 +1925,7 @@ async function exportArchive(archive, action, { root, config, service, stdin, st const out = createWriteStream(archive, { mode: 0o600 }); await once(out, "open"); const code = await execute( - composeCommand(action, root, { profile: config.profile, companionsEnabled: config.companionsEnabled, service }), + composeCommand(action, root, { profile: config.profile, companionsEnabled: config.companionsEnabled, engine: config.engine, service }), { stdin, stdout: out, stderr } ).catch(() => 1); out.end(); @@ -1892,7 +1965,7 @@ async function restoreUserState({ root, config, directory, userId, stdin, stdout const input = await readFile(userArchive(directory, userId, kind)).catch(() => null); if (!input) continue; const code = await execute( - composeCommand(action, root, { profile: config.profile, companionsEnabled: config.companionsEnabled, service }), + composeCommand(action, root, { profile: config.profile, companionsEnabled: config.companionsEnabled, engine: config.engine, service }), { stdin, stdout, stderr, input } ).catch(() => 1); if (code === 0) restored.push(label); @@ -1908,7 +1981,7 @@ async function restoreBackupExtras({ root, config, backupId, stdin, stdout, stde const archive = await readFile(join(directory, "cli-config.tar.gz")).catch(() => null); if (archive) { const code = await execute( - composeCommand("importCliSettings", root, { profile: config.profile, companionsEnabled: config.companionsEnabled }), + composeCommand("importCliSettings", root, { profile: config.profile, companionsEnabled: config.companionsEnabled, engine: config.engine }), { stdin, stdout, stderr, input: archive } ).catch(() => 1); stdout.write(code === 0 ? "CLI settings (MCP servers, base URLs) restored.\n" : "The CLI settings in this backup could not be restored.\n"); @@ -1955,7 +2028,7 @@ async function credentialsCommand(rawArgs, { root, config, stdin, stdout, stderr const value = await readSecret(stdin, stdout, `Enter ${provider} credential: `); await writeCredential(root, provider, value, { userId }); const syncCode = await execute( - composeCommand("syncCredentials", root, { profile: config.profile, companionsEnabled: config.companionsEnabled, service }), + composeCommand("syncCredentials", root, { profile: config.profile, companionsEnabled: config.companionsEnabled, engine: config.engine, service }), { stdin, stdout, stderr } ); if (syncCode !== 0) { @@ -1971,7 +2044,7 @@ async function credentialsCommand(rawArgs, { root, config, stdin, stdout, stderr } const removed = await removeCredential(root, provider, { userId }); const syncCode = await execute( - composeCommand("syncCredentials", root, { profile: config.profile, companionsEnabled: config.companionsEnabled, service }), + composeCommand("syncCredentials", root, { profile: config.profile, companionsEnabled: config.companionsEnabled, engine: config.engine, service }), { stdin, stdout, stderr } ); if (syncCode !== 0) { @@ -1997,7 +2070,7 @@ async function waitForUserCli({ root, config, userId, stdin, stdout, stderr, exe const service = `spaceapp-cli-${userSlug(userId)}`; for (let attempt = 0; attempt < USER_CLI_READY_ATTEMPTS; attempt++) { const code = await execute( - composeCommand("cliHostReady", root, { profile: config.profile, companionsEnabled: config.companionsEnabled, service }), + composeCommand("cliHostReady", root, { profile: config.profile, companionsEnabled: config.companionsEnabled, engine: config.engine, service }), { stdin, stdout: null, stderr: null } ).catch(() => 1); if (code === 0) { @@ -2016,7 +2089,7 @@ async function userCommand(args, { root, config, stdin, stdout, stderr, execute, await saveConfig(root, updated); await writeRuntimeFiles(root, updated); stdout.write(message); - const code = await execute(composeCommand("up", root, { profile: updated.profile, companionsEnabled: updated.companionsEnabled }), { stdin, stdout, stderr }); + const code = await execute(composeCommand("up", root, { profile: updated.profile, companionsEnabled: updated.companionsEnabled, engine: updated.engine }), { stdin, stdout, stderr }); if (code !== 0) stderr.write(`The change is saved, but the running services could not pick it up. Run "${UNIVERSAL_COMMAND} up".\n`); return code; }; @@ -2070,13 +2143,13 @@ async function providerCommand(rawArgs, { root, config, stdin, stdout, stderr, e stdout.write(`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 }); + return execute(composeCommand(action, root, { profile: config.profile, companionsEnabled: config.companionsEnabled, engine: config.engine, provider, service }), { stdin, stdout, stderr }); } async function ownerCommand(args, { root, config, stdin, stdout, stderr, execute }) { if (args.length === 1 && args[0] === "rotate-setup-token") { const token = randomBytes(32).toString("base64url"); - const code = await execute(composeCommand("rotateOwnerSetupToken", root, { profile: config.profile, companionsEnabled: config.companionsEnabled }), { + const code = await execute(composeCommand("rotateOwnerSetupToken", root, { profile: config.profile, companionsEnabled: config.companionsEnabled, engine: config.engine }), { stdin, stdout, stderr, @@ -2097,7 +2170,7 @@ async function ownerCommand(args, { root, config, stdin, stdout, stderr, execute if (password.length < 6) { throw new Error("Owner password must be at least 6 characters."); } - return execute(composeCommand("resetOwnerPassword", root, { profile: config.profile, companionsEnabled: config.companionsEnabled }), { + return execute(composeCommand("resetOwnerPassword", root, { profile: config.profile, companionsEnabled: config.companionsEnabled, engine: config.engine }), { stdin, stdout, stderr, @@ -2125,7 +2198,7 @@ async function reinstallCommand({ return 0; } const downCode = await execute( - composeCommand("down", root, { profile: config.profile, companionsEnabled: config.companionsEnabled }), + composeCommand("down", root, { profile: config.profile, companionsEnabled: config.companionsEnabled, engine: config.engine }), { stdin, stdout, stderr } ); if (downCode !== 0) { @@ -2133,7 +2206,7 @@ async function reinstallCommand({ } await writeRuntimeFiles(root, config); const upCode = await execute( - composeCommand("up", root, { profile: config.profile, companionsEnabled: config.companionsEnabled }), + composeCommand("up", root, { profile: config.profile, companionsEnabled: config.companionsEnabled, engine: config.engine }), { stdin, stdout, stderr } ); if (upCode !== 0) { @@ -2183,6 +2256,20 @@ async function supportBundleCommand({ version: PACKAGE_VERSION, runtimeVersion: RUNTIME_VERSION }, + engine: await (async () => { + const eng = config?.engine ?? defaultEngine(); + if (eng === "podman") { + const details = await inspectEngineDetails("podman", execute).catch(() => null); + return details ? { + name: details.engine, + version: details.version, + rootless: details.rootless, + cgroupVersion: details.cgroupVersion, + limitsDelegated: details.limitsDelegated + } : { name: "podman" }; + } + return { name: "docker" }; + })(), installation: { root, config: config @@ -2265,7 +2352,7 @@ async function factoryResetCommand({ } const purgeCode = await execute( - composeCommand("purge", root, { profile: config.profile, companionsEnabled: config.companionsEnabled }), + composeCommand("purge", root, { profile: config.profile, companionsEnabled: config.companionsEnabled, engine: config.engine }), { stdin, stdout, stderr } ); if (purgeCode !== 0) { @@ -2362,12 +2449,14 @@ async function updateCommand(args, { root, config, version, platform, stdin, std } } const prerequisites = await ensureDocker({ platform, arch, env, stdin, stdout, stderr, execute, + engine: config.engine ?? defaultEngine(), + profile: config.profile, installArgs: { root, requestedProfile: config.profile, noOpen: true, autoConfirm: true } }); if (prerequisites.reexecuted || prerequisites.code !== 0) return prerequisites.code; const resources = await inspectResources(root); - const storagePlan = await inspectStoragePlan({ root, config: { ...config, version: targetVersion }, existingConfig: config, resources, execute, operation: "upgrade" }); + const storagePlan = await inspectStoragePlan({ root, config: { ...config, version: targetVersion }, existingConfig: config, resources, execute, operation: "upgrade", engine: config.engine ?? defaultEngine() }); if (await doctor({ root, platform, stdout, stderr, execute, stdin, inspectResources, resources, - profile: config.profile, dockerReady: true, operation: "upgrade", storagePlan }) !== 0) return 1; + profile: config.profile, engine: config.engine ?? defaultEngine(), dockerReady: true, operation: "upgrade", storagePlan }) !== 0) return 1; return performUpdate({ root, config, @@ -2386,7 +2475,7 @@ async function updateCommand(args, { root, config, version, platform, stdin, std async function verifyStorage({ root, config, existingConfig, operation, inspectResources, execute, stdout, stderr }) { const resources = await inspectResources(root); - const plan = await inspectStoragePlan({ root, config, existingConfig, operation, resources, execute }); + const plan = await inspectStoragePlan({ root, config, existingConfig, operation, resources, execute, engine: config.engine ?? defaultEngine() }); const checks = storageChecks(plan, resources); for (const check of checks) (check.ok ? stdout : stderr).write(`${check.ok ? "PASS" : "FAIL"} ${check.name}: ${check.detail}\n`); if (checks.some((check) => !check.ok)) { @@ -2408,8 +2497,10 @@ async function doctor({ profile, operation, storagePlan, + engine: requestedEngine, dockerReady = false }) { + const engine = requestedEngine ?? await loadConfig(root).then((cfg) => cfg.engine).catch(() => defaultEngine()); const configuration = profile ? { ok: true, detail: "Prepared installation configuration" } : await loadConfig(root).then(() => ({ ok: true, detail: root })).catch((error) => ({ ok: false, detail: `${error.message}. Run install for a new setup, or doctor --fix for an existing setup.` })); const detectedResources = resources ?? await inspectResources(root); let resolvedProfile = profile; @@ -2433,51 +2524,78 @@ async function doctor({ ...resourceChecks.filter((check) => !storagePlan || check.name !== "Free disk"), ...(storagePlan ? storageChecks(storagePlan, detectedResources) : []) ]; - const dockerResults = []; - for (const probe of [ - { name: "Docker CLI", command: "docker", args: ["--version"] }, - { name: "Docker Compose", command: "docker", args: ["compose", "version"] }, - { name: "Docker Engine", command: "docker", args: ["info"] } - ]) { + const engineResults = []; + const binary = engine === "podman" ? "podman" : "docker"; + for (const probe of engineProbeSpecs(engine)) { const code = dockerReady ? 0 : await execute(probe, { stdin, stdout: null, stderr: null }); - dockerResults.push({ ...probe, code }); + engineResults.push({ ...probe, code }); } - const [dockerCli, dockerCompose, dockerEngine] = dockerResults; + const [engineCli, engineCompose, engineInfo] = engineResults; + const cliLabel = `${binary === "podman" ? "Podman" : "Docker"} CLI`; + const composeLabel = `${binary === "podman" ? "Podman" : "Docker"} Compose`; + const infoLabel = `${binary === "podman" ? "Podman" : "Docker"} Engine`; checks.push({ - name: dockerCli.name, - ok: dockerCli.code === 0, - detail: dockerCli.code === 0 + name: engineCli.name.replace(`${binary} CLI`, cliLabel), + ok: engineCli.code === 0, + detail: engineCli.code === 0 ? "available" - : dockerCli.code === 127 + : engineCli.code === 127 ? "not found on PATH" - : `unavailable (exit ${dockerCli.code})` + : `unavailable (exit ${engineCli.code})` }); checks.push({ - name: dockerCompose.name, - ok: dockerCompose.code === 0, - detail: dockerCompose.code === 0 + name: engineCompose.name.replace(`${binary} Compose`, composeLabel), + ok: engineCompose.code === 0, + detail: engineCompose.code === 0 ? "available" - : dockerCli.code !== 0 - ? "not available because Docker CLI is missing" - : `plugin unavailable (exit ${dockerCompose.code})` + : engineCli.code !== 0 + ? `not available because ${binary} CLI is missing` + : `plugin unavailable (exit ${engineCompose.code})` }); checks.push({ - name: dockerEngine.name, - ok: dockerEngine.code === 0, - detail: dockerEngine.code === 0 + name: engineInfo.name.replace(`${binary} Engine`, infoLabel), + ok: engineInfo.code === 0, + detail: engineInfo.code === 0 ? "available" - : dockerCli.code === 0 + : engineCli.code === 0 ? "installed but not running or inaccessible" - : "not reachable because Docker CLI is missing" + : `not reachable because ${binary} CLI is missing` }); + if (engine === "podman" && engineInfo.code === 0) { + const details = await inspectEngineDetails("podman", execute).catch(() => null); + if (details) { + checks.push({ + name: "Podman version", + ok: details.versionOk, + detail: details.versionOk ? details.version : `${details.version} (requires >= 5.0.0)` + }); + checks.push({ + name: "Podman mode", + ok: true, + detail: details.rootless ? "rootless" : "rootful" + }); + checks.push({ + name: "Cgroup version", + ok: details.cgroupVersion === "v2" || platform !== "linux", + detail: details.cgroupVersion + }); + checks.push({ + name: "Container limits", + ok: details.limitsDelegated, + detail: details.limitsDelegated + ? "delegated (CPU and memory limits active)" + : "limits not delegated in user cgroup; CPU/memory limits cannot be enforced" + }); + } + } for (const check of checks) { (check.ok ? stdout : stderr).write(`${check.ok ? "PASS" : "FAIL"} ${check.name}: ${check.detail}\n`); } - if (dockerCli.code !== 0 || dockerCompose.code !== 0) { + if (engineCli.code !== 0 || engineCompose.code !== 0) { stderr.write(`${dockerInstallHelp(platform)}\n`); - } else if (dockerEngine.code !== 0) { + } else if (engineInfo.code !== 0) { stderr.write(`${dockerEngineHelp(platform)}\n`); } return checks.every((check) => check.ok) ? 0 : 1; @@ -2657,6 +2775,7 @@ function spawnTrustedCommand(spec, options) { case "docker": return spawn("docker", args, options); case "hdiutil": return spawn("hdiutil", args, options); case "open": return spawn("open", args, options); + case "podman": return spawn("podman", args, options); case "powershell.exe": return spawn("powershell.exe", windowsPowerShellArgs(spec.operation), options); case "sg": return spawn("sg", args, options); diff --git a/packages/run-spaceapp/src/engine.mjs b/packages/run-spaceapp/src/engine.mjs new file mode 100644 index 000000000..78a9c7596 --- /dev/null +++ b/packages/run-spaceapp/src/engine.mjs @@ -0,0 +1,289 @@ +/** + * Container engine abstraction for SpaceApp launcher. + * + * The launcher historically assumed Docker. This module keeps the + * abstraction thin and explicit: supported engines are enumerated, + * engine-aware commands are constructed, and runtime probes are + * factored so callers (doctor, prerequisites, install) can stay + * engine-agnostic. + */ + +export const SUPPORTED_ENGINES = Object.freeze(["docker", "podman"]); + +export function isSupportedEngine(value) { + return SUPPORTED_ENGINES.includes(value); +} + +export function normalizeEngine(value) { + const engine = value?.toString().trim().toLowerCase(); + if (!isSupportedEngine(engine)) { + throw new Error(`Unsupported SpaceApp container engine "${value}". Choose docker or podman.`); + } + return engine; +} + +export function composeBinary(engine) { + return normalizeEngine(engine); +} + +export function engineProbeSpecs(engine) { + const binary = composeBinary(engine); + return [ + { name: `${binary} CLI`, command: binary, args: ["--version"] }, + { name: `${binary} Compose`, command: binary, args: ["compose", "version"] }, + { name: `${binary} Engine`, command: binary, args: ["info"] } + ]; +} + +export function engineVersionFlags(engine) { + const binary = composeBinary(engine); + return { + command: binary, + args: ["--version"] + }; +} + +export function engineComposeVersionFlags(engine) { + const binary = composeBinary(engine); + return { + command: binary, + args: ["compose", "version"] + }; +} + +export function engineInfoFlags(engine) { + const binary = composeBinary(engine); + return { + command: binary, + args: ["info"] + }; +} + +const VERSION_LINE = /^(?:Docker|podman) version\s+(\d+\.\d+\.\d+).*$/im; + +export function parseEngineVersion(output = "") { + const match = output.match(VERSION_LINE); + return match ? match[1] : null; +} + +const MIN_PODMAN_VERSION = "5.0.0"; + +function compareVersions(a, b) { + const parse = (v) => v.split(".").map(Number); + const aa = parse(a); + const bb = parse(b); + for (let i = 0; i < Math.max(aa.length, bb.length); i += 1) { + const diff = (aa[i] || 0) - (bb[i] || 0); + if (diff !== 0) return Math.sign(diff); + } + return 0; +} + +export function isPodmanVersionSupported(version) { + return typeof version === "string" && compareVersions(version, MIN_PODMAN_VERSION) >= 0; +} + +export function minimumPodmanVersion() { + return MIN_PODMAN_VERSION; +} + +/** + * Validate the installed Podman version is at least the documented minimum. + * Returns false when the CLI is unavailable or the version cannot be parsed. + */ +export async function validatePodmanVersion(execute) { + const spec = engineVersionFlags("podman"); + let output = ""; + const { Writable } = await import("node:stream"); + const stdout = new Writable({ + write(chunk, _encoding, done) { + output += chunk.toString(); + done(); + } + }); + const code = await execute(spec, { stdout, stderr: null }); + if (code !== 0) return false; + return isPodmanVersionSupported(parseEngineVersion(output)); +} + +/** + * Detect the first engine that is available on PATH and responsive. + * Docker is preferred when both are present to preserve existing behaviour. + */ +export async function detectAvailableEngine(execute, options = {}) { + for (const engine of SUPPORTED_ENGINES) { + const specs = engineProbeSpecs(engine); + const available = ( + await execute(specs[0], { stdout: null, stderr: null }) === 0 && + await execute(specs[1], { stdout: null, stderr: null }) === 0 && + await execute(specs[2], { stdout: null, stderr: null }) === 0 + ); + if (available) return engine; + } + return null; +} + +export function defaultEngine() { + return "docker"; +} + +/** + * Inspect a Podman machine (macOS/Windows VM). Returns null when the CLI or + * machine is unavailable. An empty name inspects the default machine. + */ +export async function inspectPodmanMachine(execute, name = "") { + const args = ["machine", "inspect", "--format", "{{json .}}"]; + if (name) args.push(name); + const output = await captureProbe(execute, { command: "podman", args }); + if (!output) return null; + try { + return JSON.parse(output); + } catch { + return null; + } +} + +/** + * List Podman machines. Returns an empty array when the CLI is unavailable. + */ +export async function listPodmanMachines(execute) { + const output = await captureProbe(execute, { command: "podman", args: ["machine", "list", "--format", "json"] }); + if (!output) return []; + try { + const parsed = JSON.parse(output); + return Array.isArray(parsed) ? parsed : []; + } catch { + return []; + } +} + +/** + * Extract CPU, memory and disk limits from a Podman machine inspect object. + * Returns null for missing or unexpected structures. + */ +export function podmanMachineResourceInfo(machine) { + if (!machine || typeof machine !== "object") return null; + const resources = machine.Resources && typeof machine.Resources === "object" ? machine.Resources : {}; + const cpus = Number(resources.CPUs ?? machine.CPUs ?? machine.cpus); + const memoryBytes = parseByteSize(resources.Memory ?? machine.Memory ?? machine.memory); + const diskSize = resources.DiskSize ?? machine.DiskSize ?? machine.diskSize ?? machine.ImageVolumeSize ?? machine.imageVolumeSize; + // Podman machine inspect reports numeric DiskSize in GiB (Memory is MiB). + const diskBytes = typeof diskSize === "number" ? Math.floor(diskSize * 1024 ** 3) : parseByteSize(diskSize); + if (!Number.isFinite(cpus) || cpus <= 0) return null; + if (!Number.isFinite(memoryBytes) || memoryBytes <= 0) return null; + if (!Number.isFinite(diskBytes) || diskBytes <= 0) return null; + return { + cpuCount: cpus, + memoryBytes, + diskBytes + }; +} + +function parseByteSize(value) { + // Numeric values follow the podman machine inspect convention for memory: MiB. + if (typeof value === "number") return Math.floor(value * 1024 ** 2); + if (typeof value !== "string") return null; + const trimmed = value.trim().toLowerCase(); + const match = /^([\d.]+)\s*([kmgtp]?i?b?)?$/.exec(trimmed); + if (!match) return null; + const number = Number(match[1]); + if (!Number.isFinite(number)) return null; + const unit = match[2] || "b"; + const multipliers = { + b: 1, + k: 1024, kb: 1000, kib: 1024, + m: 1024 ** 2, mb: 1000 ** 2, mib: 1024 ** 2, + g: 1024 ** 3, gb: 1000 ** 3, gib: 1024 ** 3, + t: 1024 ** 4, tb: 1000 ** 4, tib: 1024 ** 4 + }; + return Math.floor(number * (multipliers[unit] ?? 1)); +} + +async function captureProbe(execute, spec) { + let output = ""; + const { Writable } = await import("node:stream"); + const stdout = new Writable({ + write(chunk, _encoding, done) { + if (output.length < 1024 * 1024) output += chunk.toString(); + done(); + } + }); + try { + const code = await execute(spec, { stdout, stderr: null }); + return code === 0 ? output.trim() : null; + } catch { + return null; + } +} + +/** + * Human-readable resize guidance for a Podman machine. + */ +export function podmanMachineResizeGuidance(name, profile = "small") { + const target = name ? ` ${name}` : ""; + const reqCpu = profile === "large" ? 8 : 4; + const reqMemoryMiB = profile === "large" ? 15360 : (profile === "medium" ? 11264 : 8192); + const reqDiskGiB = profile === "large" ? 40 : (profile === "medium" ? 30 : 25); + return [ + `Resize the Podman machine with: podman machine set${target} --cpus ${reqCpu} --memory ${reqMemoryMiB} --disk-size ${reqDiskGiB}`, + "Stop the machine first if the command reports it is running: podman machine stop" + target, + "Units are MiB for --memory and GiB for --disk-size. Start the machine afterwards: podman machine start" + target + ].join("\n"); +} + +export async function inspectEngineDetails(engine, execute) { + const binary = composeBinary(engine); + const versionSpec = engineVersionFlags(engine); + const infoSpec = { command: binary, args: ["info", "--format", "{{json .}}"] }; + + let versionOutput = ""; + let infoOutput = ""; + const { Writable } = await import("node:stream"); + const vStdout = new Writable({ + write(chunk, _enc, done) { versionOutput += chunk.toString(); done(); } + }); + const iStdout = new Writable({ + write(chunk, _enc, done) { infoOutput += chunk.toString(); done(); } + }); + + const vCode = await execute(versionSpec, { stdout: vStdout, stderr: null }).catch(() => 1); + const iCode = await execute(infoSpec, { stdout: iStdout, stderr: null }).catch(() => 1); + + const version = parseEngineVersion(versionOutput) || "unknown"; + let parsedInfo = null; + try { + parsedInfo = JSON.parse(infoOutput); + } catch {} + + if (engine === "podman") { + const host = parsedInfo?.host || parsedInfo?.Host || {}; + const rootless = host.security?.rootless ?? (typeof process.getuid === "function" ? process.getuid() !== 0 : true); + const cgroupVersion = host.cgroupVersion || host.cgroupsVersion || (process.platform === "linux" ? "v2" : "n/a"); + const controllers = Array.isArray(host.cgroupControllers) ? host.cgroupControllers : []; + const isLinux = process.platform === "linux"; + const limitsDelegated = !isLinux || (cgroupVersion === "v2" && controllers.includes("cpu") && controllers.includes("memory")); + const versionOk = isPodmanVersionSupported(version); + return { + engine: "podman", + version, + versionOk, + rootless, + cgroupVersion, + limitsDelegated, + available: vCode === 0 && iCode === 0 + }; + } else { + const rootless = Array.isArray(parsedInfo?.SecurityOptions) && parsedInfo.SecurityOptions.some((opt) => opt.includes("rootless")); + const cgroupVersion = parsedInfo?.CgroupVersion ? `v${parsedInfo.CgroupVersion}` : (process.platform === "linux" ? "v2" : "n/a"); + const limitsDelegated = parsedInfo?.MemoryLimit !== false && parsedInfo?.CPUShares !== false; + return { + engine: "docker", + version, + versionOk: vCode === 0, + rootless, + cgroupVersion, + limitsDelegated, + available: vCode === 0 && iCode === 0 + }; + } +} diff --git a/packages/run-spaceapp/src/index.mjs b/packages/run-spaceapp/src/index.mjs index e41d6986c..40da645da 100644 --- a/packages/run-spaceapp/src/index.mjs +++ b/packages/run-spaceapp/src/index.mjs @@ -20,6 +20,11 @@ import { 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 { + composeBinary, + defaultEngine, + normalizeEngine +} from "./engine.mjs"; const CONFIG_SCHEMA_VERSION = 4; const CONTAINER_SECRET_MODE = 0o644; @@ -128,6 +133,7 @@ const CONFIG_KEYS = new Set([ "telemetry", "profile", "accessMode", + "engine", "workspaces", "companionsEnabled", "optionalModules", @@ -311,6 +317,7 @@ export function createDefaultConfig({ version, profile = "light", accessMode = "isolated", + engine, companionsEnabled = false }) { assertVersion(version); @@ -327,6 +334,7 @@ export function createDefaultConfig({ telemetry: false, profile, accessMode: resolvedAccessMode, + ...(engine ? { engine: normalizeEngine(engine) } : {}), companionsEnabled, optionalModules: "none", workspaces: [] @@ -367,6 +375,7 @@ export function validateConfig(config) { if (config.accessMode !== "isolated" && config.accessMode !== "host-root") { throw new Error("accessMode must be isolated or host-root."); } + normalizeEngine(config.engine ?? defaultEngine()); if (typeof config.companionsEnabled !== "boolean") { throw new Error("companionsEnabled must be boolean."); } @@ -442,19 +451,19 @@ export async function loadConfig(root) { } function migrateConfig(config) { - if (config?.schemaVersion === 2) { + if (config?.schemaVersion === 3) { return { ...config, schemaVersion: CONFIG_SCHEMA_VERSION, - accessMode: "isolated", - companionsEnabled: false + ...(config.companionsEnabled === undefined ? { companionsEnabled: false } : {}) }; } - if (config?.schemaVersion === 3) { + if (config?.schemaVersion === 2) { return { ...config, schemaVersion: CONFIG_SCHEMA_VERSION, - ...(config.companionsEnabled === undefined ? { companionsEnabled: false } : {}) + accessMode: "isolated", + companionsEnabled: false }; } if (config?.schemaVersion !== 1) { @@ -472,7 +481,6 @@ function migrateConfig(config) { companionsEnabled: false }; } - export function planConfigRepairs(rawConfig) { const actions = []; if (!rawConfig || typeof rawConfig !== "object" || Array.isArray(rawConfig)) { @@ -676,17 +684,31 @@ const CLI_SANDBOX_OPTIONS = [" security_opt:", " - seccomp=./seccomp-cli export function renderWorkspaceCompose(config, { cliSandbox = false } = {}) { validateConfig(config); + const isPodman = config.engine === "podman"; + const bindOpts = isPodman ? [" bind:", ' selinux: "z"'] : []; + const usernsOpt = isPodman ? [' userns_mode: "keep-id:uid=10001,gid=10001"'] : []; 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}`, + ...bindOpts ]); const service = mounts.length > 0 ? [" volumes:", ...mounts] : [" volumes: []"]; if (config.users === undefined) { - return ["services:", " spaceapp-core:", ...service, " spaceapp-cli:", ...service, ...(cliSandbox ? CLI_SANDBOX_OPTIONS : []), ""].join("\n"); + return [ + "services:", + " spaceapp-core:", + ...service, + ...usernsOpt, + " spaceapp-cli:", + ...service, + ...usernsOpt, + ...(cliSandbox ? CLI_SANDBOX_OPTIONS : []), + "" + ].join("\n"); } // "user enable": accounts are on, and the core starts a USER's CLIs only in that user's own container. const users = config.users; @@ -695,7 +717,8 @@ 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}`, + ...bindOpts ]; const userServices = users.flatMap((user) => { const slug = userSlug(user.id); @@ -705,6 +728,7 @@ export function renderWorkspaceCompose(config, { cliSandbox = false } = {}) { " image: ghcr.io/oll4com/spaceapp-cli:${SPACEAPP_IMAGE_TAG}${SPACEAPP_CLI_IMAGE_DIGEST:-}", " restart: unless-stopped", " init: true", + ...usernsOpt, " security_opt:", " - no-new-privileges:true", ...(cliSandbox ? CLI_SANDBOX_OPTIONS.slice(1) : []), @@ -736,8 +760,10 @@ export function renderWorkspaceCompose(config, { cliSandbox = false } = {}) { ...(mounts.length > 0 ? mounts : []), " - ./cli-hosts.json:/etc/spaceapp/cli-hosts.json:ro", ...users.map((user) => ` - spaceapp-cli-socket-${userSlug(user.id)}:/run/spaceapp-cli-users/${userSlug(user.id)}`), + ...usernsOpt, " spaceapp-cli:", ...service, + ...usernsOpt, ...(cliSandbox ? CLI_SANDBOX_OPTIONS : []), ...userServices, ...(users.length > 0 ? ["volumes:"] : []), @@ -789,6 +815,7 @@ export function composeCommand(action, root, options = {}) { const stateRoot = options.stateRoot ?? root; validateHome(stateRoot); const profile = options.profile ?? "standard"; + const engine = options.engine ?? defaultEngine(); const settings = profileRuntimeSettings(profile); const base = [ "compose", @@ -909,7 +936,7 @@ export function composeCommand(action, root, options = {}) { if (!selected) { throw new Error(`Unsupported Compose action "${action}".`); } - return { command: "docker", args: [...base, ...selected] }; + return { command: composeBinary(engine), args: [...base, ...selected] }; } export function composeProjectName(root) { @@ -984,6 +1011,7 @@ export async function prepareInstallation(root, { version, profile, accessMode, + engine, companionsEnabled }) { validateHome(root); @@ -1007,6 +1035,7 @@ export async function prepareInstallation(root, { version, profile: resolvedProfile ?? "light", accessMode: resolveInstallAccessMode(accessMode), + engine: engine ?? defaultEngine(), companionsEnabled }); } @@ -1024,6 +1053,10 @@ export async function prepareInstallation(root, { if (config.accessMode !== resolvedAccessMode) { config = { ...config, accessMode: resolvedAccessMode }; } + const resolvedEngine = engine ?? config.engine ?? defaultEngine(); + if (config.engine !== resolvedEngine) { + config = { ...config, engine: normalizeEngine(resolvedEngine) }; + } if (companionsEnabled !== undefined && config.companionsEnabled !== companionsEnabled) { config = { ...config, companionsEnabled }; } diff --git a/packages/run-spaceapp/src/prerequisites.mjs b/packages/run-spaceapp/src/prerequisites.mjs index 42bb242da..233546b04 100644 --- a/packages/run-spaceapp/src/prerequisites.mjs +++ b/packages/run-spaceapp/src/prerequisites.mjs @@ -10,6 +10,17 @@ import { pipeline } from "node:stream/promises"; import { spawn } from "node:child_process"; import { fileURLToPath } from "node:url"; import { UNIVERSAL_INSTALL_COMMAND } from "./package-info.mjs"; +import { PROFILE_REQUIREMENTS } from "./index.mjs"; +import { + defaultEngine, + engineProbeSpecs, + inspectPodmanMachine, + listPodmanMachines, + normalizeEngine, + podmanMachineResizeGuidance, + podmanMachineResourceInfo, + validatePodmanVersion +} from "./engine.mjs"; const DOCKER_TERMS_URL = "https://www.docker.com/legal/docker-subscription-service-agreement/"; const DOWNLOAD_TIMEOUT_MS = 30 * 60 * 1_000; @@ -133,9 +144,34 @@ export async function ensureDockerAvailable({ sleep = wait, osRelease, installArgs, + engine: requestedEngine, + profile = "small", scheduleResume = scheduleWindowsInstallResume, confirmRestart = confirmWindowsRestart }) { + const engine = normalizeEngine(requestedEngine ?? defaultEngine()); + if (engine === "podman") { + if (await engineIsReady("podman", execute)) { + if (platform === "darwin") { + const machineDiag = await diagnosePodmanMachine(execute, profile); + if (!machineDiag.ok) { + stderr.write(machineDiag.message); + return { code: 1, reexecuted: false }; + } + } + return { code: 0, reexecuted: false }; + } + if (platform === "darwin") { + const machineDiag = await diagnosePodmanMachine(execute, profile); + stderr.write(machineDiag.message); + return { code: 1, reexecuted: false }; + } + stderr.write( + "Podman was selected but is not available. Install Podman (>= 5.0) and the docker-compose v2 provider, " + + "or rerun with --engine docker.\n" + ); + return { code: 1, reexecuted: false }; + } if (await dockerIsReady(execute)) { return { code: 0, reexecuted: false }; } @@ -667,6 +703,79 @@ async function dockerIsReady(execute) { return true; } +async function engineIsReady(engine, execute) { + for (const spec of engineProbeSpecs(engine)) { + if (await execute(spec, { stdout: null, stderr: null }) !== 0) { + return false; + } + } + if (engine === "podman" && !await validatePodmanVersion(execute)) { + return false; + } + return true; +} + +async function diagnosePodmanMachine(execute, profile = "small") { + const machines = await listPodmanMachines(execute); + const running = machines.find((machine) => machine?.Running === true); + if (!machines.length) { + return { + ok: false, + message: + "Podman was selected but no Podman machine exists on macOS. " + + "Create and start one with:\n" + + " podman machine init\n" + + " podman machine start\n" + + "Then rerun this command, or use --engine docker.\n" + }; + } + if (!running) { + return { + ok: false, + message: + "Podman was selected but no Podman machine is running. " + + `Start it with: podman machine start ${machines[0]?.Name ?? ""}\n` + + "Then rerun this command, or use --engine docker.\n" + }; + } + const machine = await inspectPodmanMachine(execute, running.Name); + const resources = podmanMachineResourceInfo(machine); + if (!resources) { + return { + ok: false, + message: + `Podman machine "${running.Name}" is running but its resources could not be read. ` + + "Verify it with: podman machine inspect " + running.Name + "\n" + }; + } + const lines = [ + `Podman machine "${running.Name}" resources: ${resources.cpuCount} CPUs, ` + + `${formatGibibytes(resources.memoryBytes)} GiB memory, ${formatGibibytes(resources.diskBytes)} GiB disk.` + ]; + const req = PROFILE_REQUIREMENTS[profile] || PROFILE_REQUIREMENTS.small; + const issues = []; + if (resources.cpuCount < req.cpuCount) issues.push(`at least ${req.cpuCount} CPUs`); + if (resources.memoryBytes > 0 && resources.memoryBytes < req.memoryBytes) { + issues.push(`at least ${formatGibibytes(req.memoryBytes)} GiB memory`); + } + const minDiskBytes = Math.max(20 * 1024 ** 3, req.freshDiskBytes); + if (resources.diskBytes > 0 && resources.diskBytes < minDiskBytes) { + issues.push(`at least ${formatGibibytes(minDiskBytes)} GiB disk`); + } + if (issues.length) { + lines.push(`The machine is below the recommended size for SpaceApp ${profile} profile: ` + issues.join(", ") + "."); + lines.push(podmanMachineResizeGuidance(running.Name, profile)); + lines.push("Then rerun this command, or use --engine docker."); + return { ok: false, message: lines.join("\n") + "\n" }; + } + return { ok: true, message: lines.join("\n") + "\n" }; +} + +function formatGibibytes(bytes) { + if (!Number.isFinite(bytes) || bytes <= 0) return "unknown"; + return (bytes / (1024 ** 3)).toFixed(2); +} + async function installLinuxDockerEngine({ arch, release, diff --git a/packages/run-spaceapp/src/storage.mjs b/packages/run-spaceapp/src/storage.mjs index c8b4cfadc..fe5847949 100644 --- a/packages/run-spaceapp/src/storage.mjs +++ b/packages/run-spaceapp/src/storage.mjs @@ -2,6 +2,7 @@ import { Writable } from "node:stream"; import { readdir, readFile, stat } from "node:fs/promises"; import { join } from "node:path"; import { composeCommand, profileRuntimeSettings, PROFILE_REQUIREMENTS } from "./index.mjs"; +import { composeBinary } from "./engine.mjs"; import { localImageRefs } from "./image-lock.mjs"; const GiB = 1024 ** 3; @@ -95,16 +96,17 @@ async function installedImageDigests(root, version) { return digests; } -export async function inspectStoragePlan({ root, config, existingConfig, resources, execute, operation, fetch = globalThis.fetch }) { - const dockerInfo = await captureProbe(execute, { command: "docker", args: ["info", "--format", "{{json .}}"] }); +export async function inspectStoragePlan({ root, config, existingConfig, resources, execute, operation, fetch = globalThis.fetch, engine: engineName }) { + const binary = composeBinary(engineName ?? config.engine ?? "docker"); + const engineInfo = await captureProbe(execute, { command: binary, args: ["info", "--format", "{{json .}}"] }); let engine = null; - try { engine = JSON.parse(dockerInfo); } catch { /* Conservative reserve when the engine is unavailable. */ } + try { engine = JSON.parse(engineInfo); } catch { /* Conservative reserve when the engine is unavailable. */ } const cachedLayers = new Set(); if (engine && existingConfig) { const existingDigests = await installedImageDigests(root, existingConfig.version); for (const type of ["core", "cli", "browser"]) { for (const ref of localImageRefs(type, existingConfig.version, existingDigests[type])) { - const output = await captureProbe(execute, { command: "docker", args: ["image", "inspect", "--format", "{{json .RootFS.Layers}}", ref] }); + const output = await captureProbe(execute, { command: binary, args: ["image", "inspect", "--format", "{{json .RootFS.Layers}}", ref] }); try { for (const layer of JSON.parse(output)) cachedLayers.add(layer); break; } catch {} } } @@ -126,10 +128,10 @@ export async function inspectStoragePlan({ root, config, existingConfig, resourc const refs = ["core", "cli", "browser"].includes(type) ? localImageRefs(type, config.version) : [image]; 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; + cached = await captureProbe(execute, { command: binary, args: ["image", "inspect", "--format", "{{.Id}}", ref] }); + 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,10 +156,11 @@ 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") { + // Linux can measure the container filesystem directly even on a fresh install. + const engineRootDir = engine?.DockerRootDir || engine?.store?.graphRoot; + if (engineRootDir && process.platform === "linux") { const { inspectSystemResources } = await import("./index.mjs"); - try { dockerFreeDiskBytes = (await inspectSystemResources(engine.DockerRootDir)).freeDiskBytes; } catch {} + try { dockerFreeDiskBytes = (await inspectSystemResources(engineRootDir)).freeDiskBytes; } catch {} } return { ...plan, missingImages, sharedLayersEstimated, dockerFreeDiskBytes, resources: { ...resources, diff --git a/packages/run-spaceapp/tests/engine.test.mjs b/packages/run-spaceapp/tests/engine.test.mjs new file mode 100644 index 000000000..238372e83 --- /dev/null +++ b/packages/run-spaceapp/tests/engine.test.mjs @@ -0,0 +1,159 @@ +import assert from "node:assert"; +import { describe, test } from "node:test"; +import { + composeBinary, + defaultEngine, + inspectPodmanMachine, + isPodmanVersionSupported, + isSupportedEngine, + listPodmanMachines, + minimumPodmanVersion, + normalizeEngine, + parseEngineVersion, + podmanMachineResizeGuidance, + podmanMachineResourceInfo, + validatePodmanVersion +} from "../src/engine.mjs"; + +describe("engine support", () => { + test("default engine is docker", () => { + assert.equal(defaultEngine(), "docker"); + }); + + test("only docker and podman are supported", () => { + assert.equal(isSupportedEngine("docker"), true); + assert.equal(isSupportedEngine("podman"), true); + assert.equal(isSupportedEngine("PodMan"), false); + assert.equal(isSupportedEngine("nerdctl"), false); + assert.equal(isSupportedEngine(""), false); + }); + + test("normalizeEngine accepts lower-case supported values", () => { + assert.equal(normalizeEngine("docker"), "docker"); + assert.equal(normalizeEngine("Podman"), "podman"); + assert.equal(normalizeEngine(" PODMAN "), "podman"); + }); + + test("normalizeEngine rejects unsupported values", () => { + assert.throws(() => normalizeEngine("nerdctl"), /Unsupported SpaceApp container engine/); + assert.throws(() => normalizeEngine(""), /Unsupported SpaceApp container engine/); + }); + + test("composeBinary mirrors the normalized engine", () => { + assert.equal(composeBinary("docker"), "docker"); + assert.equal(composeBinary("podman"), "podman"); + }); + + test("parseEngineVersion reads Docker and Podman version strings", () => { + assert.equal(parseEngineVersion("Docker version 27.3.1, build ce12230"), "27.3.1"); + assert.equal(parseEngineVersion("podman version 4.9.3\n..."), "4.9.3"); + assert.equal(parseEngineVersion("Podman version 5.2.0"), "5.2.0"); + assert.equal(parseEngineVersion(""), null); + }); + + test("podman version support gate", () => { + assert.equal(isPodmanVersionSupported("5.2.0"), true); + assert.equal(isPodmanVersionSupported("5.0.0"), true); + assert.equal(isPodmanVersionSupported("4.9.3"), false); + assert.equal(isPodmanVersionSupported("4.9.0"), false); + assert.equal(isPodmanVersionSupported("4.8.0"), false); + assert.equal(isPodmanVersionSupported("3.4.2"), false); + assert.equal(isPodmanVersionSupported(null), false); + }); + + test("minimum podman version is documented", () => { + assert.equal(minimumPodmanVersion(), "5.0.0"); + }); + + test("inspectPodmanMachine parses the default machine JSON", async () => { + const execute = async (spec, io) => { + assert.equal(spec.command, "podman"); + assert.deepEqual(spec.args.slice(0, 3), ["machine", "inspect", "--format"]); + io?.stdout?.write(JSON.stringify({ Name: "test-machine", Resources: { CPUs: 4, Memory: 8192, DiskSize: 30 } })); + return 0; + }; + const machine = await inspectPodmanMachine(execute, "test-machine"); + assert.notEqual(machine, null); + assert.equal(machine.Name, "test-machine"); + assert.equal(machine.Resources.CPUs, 4); + }); + + test("listPodmanMachines returns parsed array", async () => { + const execute = async (spec, io) => { + assert.equal(spec.command, "podman"); + assert.deepEqual(spec.args, ["machine", "list", "--format", "json"]); + io.stdout.write(JSON.stringify([{ Name: "test", Running: true }])); + return 0; + }; + const machines = await listPodmanMachines(execute); + assert.deepEqual(machines, [{ Name: "test", Running: true }]); + }); + + test("listPodmanMachines returns empty array on failure", async () => { + const execute = async () => 1; + const machines = await listPodmanMachines(execute); + assert.deepEqual(machines, []); + }); + + test("podmanMachineResourceInfo extracts resources", () => { + // Realistic `podman machine inspect` values: Memory in MiB, DiskSize in GiB. + const info = podmanMachineResourceInfo({ CPUs: 4, Memory: 8192, DiskSize: 100 }); + assert.equal(info.cpuCount, 4); + assert.equal(info.memoryBytes, 8192 * 1024 ** 2); + assert.equal(info.diskBytes, 100 * 1024 ** 3); + }); + + test("podmanMachineResourceInfo prefers Resources object", () => { + const info = podmanMachineResourceInfo({ + CPUs: 2, + Memory: 4096, + DiskSize: 50, + Resources: { CPUs: 8, Memory: 16384, DiskSize: 100 } + }); + assert.equal(info.cpuCount, 8); + assert.equal(info.memoryBytes, 16384 * 1024 ** 2); + assert.equal(info.diskBytes, 100 * 1024 ** 3); + }); + + test("podmanMachineResourceInfo accepts string byte sizes", () => { + const info = podmanMachineResourceInfo({ cpus: 2, memory: "4 GiB", diskSize: "32g" }); + assert.equal(info.cpuCount, 2); + assert.equal(info.memoryBytes, 4 * 1024 ** 3); + assert.equal(info.diskBytes, 32 * 1024 ** 3); + }); + + test("podmanMachineResourceInfo returns null for invalid input", () => { + assert.equal(podmanMachineResourceInfo(null), null); + assert.equal(podmanMachineResourceInfo({}), null); + assert.equal(podmanMachineResourceInfo({ CPUs: 0 }), null); + }); + + test("podmanMachineResizeGuidance includes machine name", () => { + const guidance = podmanMachineResizeGuidance("dev"); + assert.match(guidance, /podman machine set dev/); + assert.match(guidance, /podman machine start dev/); + }); + + test("validatePodmanVersion accepts supported versions", async () => { + const execute = async (spec, io) => { + assert.equal(spec.command, "podman"); + assert.deepEqual(spec.args, ["--version"]); + io.stdout.write("podman version 5.2.0\n"); + return 0; + }; + assert.equal(await validatePodmanVersion(execute), true); + }); + + test("validatePodmanVersion rejects unsupported versions", async () => { + const execute = async (spec, io) => { + io.stdout.write("podman version 4.8.0\n"); + return 0; + }; + assert.equal(await validatePodmanVersion(execute), false); + }); + + test("validatePodmanVersion returns false on probe failure", async () => { + const execute = async () => 1; + assert.equal(await validatePodmanVersion(execute), false); + }); +}); diff --git a/packages/run-spaceapp/tests/prerequisites.test.mjs b/packages/run-spaceapp/tests/prerequisites.test.mjs index 0bb079a22..db3f42755 100644 --- a/packages/run-spaceapp/tests/prerequisites.test.mjs +++ b/packages/run-spaceapp/tests/prerequisites.test.mjs @@ -1016,3 +1016,63 @@ test('Linux waits for a stopped engine to become ready without unnecessarily cha assert.ok(calls.some(s=>s.args.includes('systemctl'))); assert.ok(!calls.some(s=>s.args.includes('usermod'))); }); + +test("macOS Podman engine reports missing Podman machine", async () => { + const stderr = capture(); + const result = await ensureDockerAvailable({ + platform: "darwin", + env: { USER: "tester", PATH: "/usr/bin:/bin" }, + stdout: capture().stream, + stderr: stderr.stream, + engine: "podman", + execute: async (spec, io) => { + if (spec.command === "podman" && spec.args[0] === "machine" && spec.args[1] === "list") { + return 1; + } + if (spec.command === "podman" && spec.args[0] === "--version") { + if (io.stdout) io.stdout.write("podman version 5.2.0\n"); + return 0; + } + if (spec.command === "podman" && spec.args[0] === "info") { + return 0; + } + return 127; + } + }); + assert.deepEqual(result, { code: 1, reexecuted: false }); + assert.match(stderr.value(), /Podman was selected/); + assert.match(stderr.value(), /no Podman machine exists/); +}); + +test("macOS Podman engine reports under-resourced running machine", async () => { + const stderr = capture(); + const result = await ensureDockerAvailable({ + platform: "darwin", + env: { USER: "tester", PATH: "/usr/bin:/bin" }, + stdout: capture().stream, + stderr: stderr.stream, + engine: "podman", + execute: async (spec, io) => { + if (spec.command === "podman" && spec.args[0] === "machine" && spec.args[1] === "list") { + io.stdout.write(JSON.stringify([{ Name: "podman-machine-default", Running: true }])); + return 0; + } + if (spec.command === "podman" && spec.args[0] === "machine" && spec.args[1] === "inspect") { + io.stdout.write(JSON.stringify({ Name: "podman-machine-default", CPUs: 2, Memory: 4096, DiskSize: 100 })); + return 0; + } + if (spec.command === "podman" && spec.args[0] === "--version") { + if (io.stdout) io.stdout.write("podman version 5.2.0\n"); + return 0; + } + if (spec.command === "podman" && spec.args[0] === "info") { + return 0; + } + return 127; + } + }); + assert.deepEqual(result, { code: 1, reexecuted: false }); + assert.match(stderr.value(), /Podman machine/); + assert.match(stderr.value(), /below the recommended size/); + assert.match(stderr.value(), /podman machine set/); +}); diff --git a/packages/run-spaceapp/tests/storage.test.mjs b/packages/run-spaceapp/tests/storage.test.mjs index 06d11a0d7..8088dd662 100644 --- a/packages/run-spaceapp/tests/storage.test.mjs +++ b/packages/run-spaceapp/tests/storage.test.mjs @@ -96,6 +96,35 @@ test("Docker CPU and memory allocations constrain the host's resources",async(t) assert.equal(installResourceChecks({...plan.resources,engineMemoryBytes:2*GiB},"small").find(x=>x.name==="Docker memory").ok,false); }); +test("inspectStoragePlan uses the configured container engine binary", async (t) => { + const f = await fixture(t, true); + const config = { ...JSON.parse(await readFile(join(f.root, "config.json"))), engine: "podman" }; + const commands = []; + const execute = async (spec, io) => { + commands.push(spec.command); + if (spec.args[0] === "info") { io.stdout.write(JSON.stringify({ NCPU: 4, MemTotal: 8 * GiB })); return 0; } + return f.execute(spec, io); + }; + await inspectStoragePlan({ root: f.root, config, resources, execute, operation: "repair" }); + assert.ok(commands.includes("podman")); + assert.equal(commands.includes("docker"), false); +}); + +test("inspectStoragePlan accepts Podman image IDs without sha256 prefix", async (t) => { + const f = await fixture(t, true); + const config = { ...JSON.parse(await readFile(join(f.root, "config.json"))), engine: "podman" }; + const execute = async (spec, io) => { + if (spec.args[0] === "info") { io.stdout.write(JSON.stringify({ NCPU: 4, MemTotal: 8 * GiB })); return 0; } + if (spec.args[0] === "image" && spec.args[1] === "inspect") { + io.stdout.write("a".repeat(64)); + return 0; + } + return f.execute(spec, io); + }; + const plan = await inspectStoragePlan({ root: f.root, config, resources, execute, operation: "repair" }); + assert.equal(plan.missingImages.length, 0); +}); + test("an installation that pulled its images by digest is inspected by digest, since it has no local version tags", async (t) => { const root = await mkdtemp(join(tmpdir(), "spaceapp-storage-digest-")); t.after(() => rm(root, { recursive: true, force: true }));