diff --git a/.github/workflows/platform-matrix.yml b/.github/workflows/platform-matrix.yml index e09ecb53c..1661668fb 100644 --- a/.github/workflows/platform-matrix.yml +++ b/.github/workflows/platform-matrix.yml @@ -57,3 +57,37 @@ 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 + + rootless-podman: + name: Rootless Podman (ubuntu-latest) + runs-on: ubuntu-latest + timeout-minutes: 20 + 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 workspace dependencies on supported hosts + if: runner.arch != 'ARM64' + run: npm ci + + - name: Install source workspace for the intentional x64 package rejection test + if: runner.arch == 'ARM64' + run: npm ci --force + + - name: Probe the runner Podman and run the launcher suite + run: | + if ! command -v podman >/dev/null 2>&1; then + sudo apt-get update + sudo apt-get install -y podman + fi + podman --version + podman info --format 'rootless={{.Host.Security.Rootless}} version={{.Version.Version}} cgroup={{.Host.CgroupVersion}}' || true + node --test packages/run-spaceapp/tests/podman.test.mjs + npm test -w run-spaceapp + npm run check -w run-spaceapp diff --git a/README.md b/README.md index fd303d486..96dfb09c3 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). +- **Docker or Rootless Podman 5+**: If Docker is not installed, the installer automatically installs official Docker Desktop (Windows/macOS) or Docker Engine (Linux), or you can use rootless **Podman 5+** via `npx --yes run-spaceapp@latest install --engine podman` (note: `--access host-root` is only supported on Linux with Docker and is refused under `--engine podman`). ### Run the Installation Command diff --git a/docs/getting-started.md b/docs/getting-started.md index f892f4439..a56f4a83f 100644 --- a/docs/getting-started.md +++ b/docs/getting-started.md @@ -146,8 +146,26 @@ data, credentials, workspaces, secrets, and persistent volumes, run: npx --yes run-spaceapp@latest install --access isolated ``` -Host-root is supported only on Linux; the launcher refuses it on macOS and -Windows. +Host-root is supported only on Linux with Docker (`--engine docker`); the launcher refuses it on macOS, Windows, and rootless Podman (`--engine podman`). + +## Rootless Podman (`--engine podman`) + +SpaceApp supports rootless **Podman 5+** as a first-class container engine: + +```bash +npx --yes run-spaceapp@latest install --engine podman +``` + +If Docker is not installed and a working Podman 5+ runtime with `podman compose` is already on `PATH`, `run-spaceapp install` offers to use Podman automatically. Passing `--engine podman` skips the Docker installation flow, verifies `podman --version` (5+), `podman compose version`, and `podman info`, and records `"engine": "podman"` in `config.json`. + +- **Podman 5+ requirement**: Podman 4.x is rejected with upgrade instructions. The supported Compose provider is `podman compose` from Podman 5 or newer, backed by Docker Compose v2 or `podman-compose`. Podman 4.x is not supported. +- **Workspaces & SELinux**: Registered workspace bind mounts include `:z` SELinux shared labels. `spaceapp-cli` uses `userns_mode: "keep-id:uid=10001,gid=10001"` so container UID `10001` maps to your host user. The published images start as root and `gosu` down to that user, so the Podman CLI service is written without `no-new-privileges` (that option makes the switch fail with "operation not permitted"). Other services keep `no-new-privileges`. +- **macOS**: `install --engine podman` checks the running Podman machine's CPU, memory, and disk against the selected profile and prints a `podman machine set` command when the machine is too small. Disk size is only increased. +- **Windows**: Podman is not supported. Use Docker Desktop. +- **`--answers`**: `"engine": "podman"` in the answers file selects Podman the same way `--engine podman` does. When Docker is missing, an interactive install asks before using Podman; `--non-interactive` selects it. +- **`--access host-root` limitation**: `--access host-root` is refused when `--engine podman` is selected because rootless user namespaces remap host UIDs. Use `--access isolated` and `spaceapp workspace add `. +- **Engine migration**: Switching engines (`docker` ↔ `podman`) on an existing installation is refused so named-volume data is never silently orphaned. Run `spaceapp backup`, uninstall with `--purge-data` (or choose a new `SPACEAPP_HOME`), install with the other `--engine`, then run `spaceapp restore`. +- **doctor**: Reports the engine, Podman version, rootless or rootful mode, cgroup version, and whether the `cpu` and `memory` controllers are delegated. `support-bundle` records the same facts and no secrets. The `npx` command is the universal one-command installer. To retain a global launcher for later operations, optionally run `npm install -g run-spaceapp`; diff --git a/packages/run-spaceapp/README.md b/packages/run-spaceapp/README.md index 0e8b1ab3b..a2b4cd014 100644 --- a/packages/run-spaceapp/README.md +++ b/packages/run-spaceapp/README.md @@ -114,10 +114,28 @@ before enabling it: npx --yes run-spaceapp@latest install --access host-root ``` -Host-root is supported only on Linux. Run +Host-root is supported only on Linux with Docker (`--engine docker`). Rootless Podman (`--engine podman`) refuses `--access host-root` because rootless user namespaces remap host UIDs. Run `npx --yes run-spaceapp@latest install --access isolated` to remove the host mount while keeping SpaceApp data and volumes. +### Rootless Podman (`--engine podman`) + +On Fedora, RHEL, CentOS, Silverblue/Kinoite, Debian, Ubuntu, and macOS Podman machine, you can run SpaceApp on **Podman 5+** without installing Docker or joining the root-equivalent `docker` group. Windows Podman is not supported. + +```bash +npx --yes run-spaceapp@latest install --engine podman +``` + +When Docker is absent and a working `podman` (version 5+) with `podman compose` is detected on `PATH`, the launcher offers to use Podman automatically. Passing `--engine podman` selects Podman explicitly, persists `"engine": "podman"` in `config.json`, and runs all lifecycle commands (`up`, `down`, `update`, `reinstall`, `uninstall`, `backup`, `restore`, `logs`, `ps`, `doctor`, `workspace`, `provider`, and `owner`) through `podman compose`. + +- **Requirement**: Podman **5.0 or newer** with `podman compose` (Docker Compose v2 or `podman-compose`) and `slirp4netns` or `pasta` for rootless networking. Podman 4.x is rejected. +- **Rootless Workspace Permissions & SELinux**: Under `--engine podman`, registered workspaces receive `:z` SELinux relabeling (`bind.selinux: "z"`) and `spaceapp-cli` sets `userns_mode: "keep-id:uid=10001,gid=10001"` so container UID `10001` maps to the host user without `sudo setfacl`. That service omits `no-new-privileges` because the image entrypoint uses `gosu` to drop from root to UID 10001, which `no-new-privileges` blocks under `keep-id`. +- **macOS Podman machine**: The installer compares `podman machine inspect` CPU, memory, and disk with the profile and tells you how to grow the machine when it is too small. +- **Answers**: `--answers '{"engine":"podman","confirm":true,...}'` selects Podman without a separate `--engine` flag. If Docker is absent, `--non-interactive` selects a ready Podman 5; an interactive install asks first. +- **Limitation (`--access host-root`)**: `--access host-root` is not supported with `--engine podman` because rootless user namespaces cannot read/write arbitrary root-owned host paths. Use `--access isolated` and `spaceapp workspace add ` instead. +- **Diagnostics**: `doctor` and `support-bundle` report the engine, version, rootless or rootful mode, cgroup version, and `cpu`/`memory` controller delegation. A missing controller is a warning, not a late resource-limit failure. +- **Switching Engines**: Switching container engines (`--engine docker` ↔ `--engine podman`) on an existing installation is refused because Docker and Podman do not share named volumes. To migrate, run `spaceapp backup`, uninstall with `--purge-data` (or choose a new `SPACEAPP_HOME`), install with the target `--engine`, and run `spaceapp restore`. + Provider API keys are read from masked standard input and written to restrictive files outside Git. OAuth and device-code credentials remain in isolated Docker provider state. Neither credential source is included in diff --git a/packages/run-spaceapp/src/cli.mjs b/packages/run-spaceapp/src/cli.mjs index e0e35fc03..1f3bd5867 100644 --- a/packages/run-spaceapp/src/cli.mjs +++ b/packages/run-spaceapp/src/cli.mjs @@ -34,6 +34,7 @@ import { removeCredential, removeUser, removeWorkspace, + resolveEngine, resolveInstallAccessMode, resolveInstallProfile, resolveSpaceAppHome, @@ -50,7 +51,9 @@ import { } from "./index.mjs"; import { inspectStoragePlan, storageChecks } from "./storage.mjs"; import { + detectPodmanStatus, ensureDockerAvailable, + podmanInstallInstructions, prepareDockerCliPath, windowsPowerShellArgs } from "./prerequisites.mjs"; @@ -82,6 +85,7 @@ const trustedCommands = new Set([ "codesign", "docker", "hdiutil", + "podman", "open", "powershell.exe", "sg", @@ -110,7 +114,7 @@ export function writeWindowsCredentialHint(platform, stderr) { } export async function withHeadlessDockerConfig(platform, spec, run) { - if (platform !== "win32") { + if (platform !== "win32" || spec?.command === "podman") { return run(spec); } const configDir = await mkdtemp(join(tmpdir(), "spaceapp-docker-config-")); @@ -344,7 +348,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 +437,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"); @@ -444,15 +448,20 @@ export async function run(argv, { } return 0; } - if (command === "doctor") { - assertNoArgs(args, "doctor"); - return doctor({ root, platform, stdout, stderr, execute, stdin, inspectResources }); - } if (command === "support-bundle") { let outPath = null; - const outIdx = args.indexOf("--out"); - if (outIdx !== -1 && args[outIdx + 1]) { - outPath = args[outIdx + 1]; + for (let index = 0; index < args.length; index += 1) { + const argument = args[index]; + if ((argument === "--out" || argument === "--output") && args[index + 1]) { + outPath = args[index + 1]; + index += 1; + continue; + } + if (argument.startsWith("--out=") || argument.startsWith("--output=")) { + outPath = argument.slice(argument.indexOf("=") + 1); + continue; + } + throw new Error(`Usage: ${UNIVERSAL_COMMAND} support-bundle [--out ]`); } return supportBundleCommand({ root, config, platform, arch, stdout, stderr, execute: runtimeExecute, request, outPath }); } @@ -461,6 +470,16 @@ export async function run(argv, { return repairRuntime({ root, config, platform, stdin, stdout, stderr, execute: runtimeExecute, request, sleep, dryRun }); } if (command === "reinstall") { + const requestedEngine = parseReinstallEngineArg(args); + if (requestedEngine !== undefined && resolveEngine(requestedEngine) !== resolveEngine(config)) { + const currentEngine = resolveEngine(config); + const targetEngine = resolveEngine(requestedEngine); + stderr.write( + `Switching container engines on an existing installation (${currentEngine} -> ${targetEngine}) is refused because ${currentEngine} volumes are not visible to ${targetEngine}. ` + + `Run "${UNIVERSAL_COMMAND} backup", then "${UNIVERSAL_COMMAND} uninstall --purge-data" (or set a new SPACEAPP_HOME), install with "--engine ${targetEngine}", and run "${UNIVERSAL_COMMAND} restore".\n` + ); + return 1; + } const keepData = !args.includes("--purge-data"); return reinstallCommand({ root, config, platform, stdin, stdout, stderr, execute: runtimeExecute, request, sleep, keepData }); } @@ -523,14 +542,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 +581,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 +601,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 +619,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 +641,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,16 +697,32 @@ async function installCommand(args, { const { requestedProfile, requestedAccessMode, + requestedEngine, noOpen, companionsEnabled, telemetryEnabled, installId } = wizard; const existingConfig = await loadExistingInstallation(root); + if (existingConfig && requestedEngine !== undefined && resolveEngine(requestedEngine) !== resolveEngine(existingConfig)) { + const currentEngine = resolveEngine(existingConfig); + const targetEngine = resolveEngine(requestedEngine); + stderr.write( + `Switching container engines on an existing installation (${currentEngine} -> ${targetEngine}) is refused because ${currentEngine} volumes are not visible to ${targetEngine}. ` + + `Run "${UNIVERSAL_COMMAND} backup", then "${UNIVERSAL_COMMAND} uninstall --purge-data" (or set a new SPACEAPP_HOME), install with "--engine ${targetEngine}", and run "${UNIVERSAL_COMMAND} restore".\n` + ); + return 1; + } + const effectiveEngine = existingConfig ? resolveEngine(existingConfig) : requestedEngine; const accessMode = resolveInstallAccessMode( requestedAccessMode, existingConfig?.accessMode ?? "isolated" ); + if (effectiveEngine === "podman" && accessMode === "host-root") { + throw new Error( + `Host-root access (--access host-root) is not supported on Podman because rootless user namespaces remap host UIDs. Use --access isolated or register specific folders with "${UNIVERSAL_COMMAND} workspace add ".` + ); + } if (accessMode === "host-root" && platform !== "linux") { throw new Error("Host-root access is supported only on Linux."); } @@ -709,7 +744,8 @@ async function installCommand(args, { version: runtimeVersion, profile, accessMode, - ...(companionsEnabled ? { companionsEnabled } : {}) + ...(companionsEnabled ? { companionsEnabled } : {}), + ...(effectiveEngine ? { engine: effectiveEngine } : {}) }); // The stored choice also drives the app's first_answer event after later updates, so an explicit // SPACEAPP_TELEMETRY at install time is stored like the answer it overrides (as for the install ping). @@ -774,7 +810,16 @@ async function installCommand(args, { stdout, stderr, execute, - installArgs: { root, requestedProfile, requestedAccessMode, noOpen, autoConfirm: true } + installArgs: { + root, + requestedProfile, + requestedAccessMode, + requestedEngine: effectiveEngine, + noOpen, + autoConfirm: true, + podmanAutoSelect: Boolean(stdin?._spaceappPromptController?.nonInteractive) + }, + ...(effectiveEngine ? { engine: effectiveEngine } : {}) }); if (prerequisiteResult.reexecuted) { return prerequisiteResult.code; @@ -785,6 +830,14 @@ async function installCommand(args, { ); return failInstall(prerequisiteResult.code); } + if (!existingConfig && prerequisiteResult.engine === "podman") { + if (accessMode === "host-root") { + throw new Error( + `Host-root access (--access host-root) is not supported on Podman because rootless user namespaces remap host UIDs. Use --access isolated or register specific folders with "${UNIVERSAL_COMMAND} workspace add ".` + ); + } + result.config = { ...result.config, engine: "podman" }; + } stage = "doctor"; @@ -808,7 +861,8 @@ async function installCommand(args, { inspectResources, resources, profile, - dockerReady: true, operation, storagePlan + dockerReady: true, operation, storagePlan, + engine: result.config.engine }); if (doctorCode !== 0) { stderr.write( @@ -826,6 +880,7 @@ async function installCommand(args, { root, stateRoot: stagedStateRoot, profile, + engine: result.config.engine, platform, stdin, stdout, @@ -841,6 +896,7 @@ async function installCommand(args, { existingConfig, attemptedProfile: profile, companionsEnabled, + engine: result.config.engine, platform, stdin, stdout, @@ -859,6 +915,7 @@ async function installCommand(args, { profile, stateRoot: stagedStateRoot, companionsEnabled, + engine: result.config.engine, ...options }); const pullCode = await withHeadlessDockerConfig( @@ -1051,7 +1108,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; @@ -1072,8 +1129,28 @@ async function planInteractiveSetup({ inspectResources, request, sleep, ensureDocker, env, arch }) { - const parsed = parseInstallArgs(args); + const parsedArgs = parseInstallArgs(args); + const parsed = { + ...parsedArgs, + requestedEngine: engineFromAnswers(stdin, parsedArgs.requestedEngine) + }; const rawExisting = await readRawConfig(root); + if (rawExisting && parsed.requestedEngine !== undefined && resolveEngine(parsed.requestedEngine) !== resolveEngine(rawExisting)) { + const currentEngine = resolveEngine(rawExisting); + const targetEngine = resolveEngine(parsed.requestedEngine); + stderr.write( + `Switching container engines on an existing installation (${currentEngine} -> ${targetEngine}) is refused because ${currentEngine} volumes are not visible to ${targetEngine}. ` + + `Run "${UNIVERSAL_COMMAND} backup", then "${UNIVERSAL_COMMAND} uninstall --purge-data" (or set a new SPACEAPP_HOME), install with "--engine ${targetEngine}", and run "${UNIVERSAL_COMMAND} restore".\n` + ); + return { exit: 1 }; + } + const effectiveEngine = rawExisting ? resolveEngine(rawExisting) : parsed.requestedEngine; + if (effectiveEngine === "podman" && (parsed.requestedAccessMode ?? rawExisting?.accessMode) === "host-root") { + stderr.write( + `Host-root access (--access host-root) is not supported on Podman because rootless user namespaces remap host UIDs. Use --access isolated or register specific folders with "${UNIVERSAL_COMMAND} workspace add ".\n` + ); + return { exit: 1 }; + } const repairs = planConfigRepairs(rawExisting); const path = upgradePath(rawExisting?.version ?? null, runtimeVersion); const interactive = interactiveAvailable(stdin); @@ -1102,6 +1179,7 @@ async function planInteractiveSetup({ return { requestedProfile: actions.profile ?? "auto", requestedAccessMode: actions.accessMode, + requestedEngine: actions.engine ?? parsed.requestedEngine, noOpen: actions.noOpen !== false, companionsEnabled: actions.companionsEnabled ?? false, telemetryEnabled: actions.telemetry ?? false, @@ -1168,6 +1246,7 @@ async function planInteractiveSetup({ return { requestedProfile: profileChoice, requestedAccessMode: accessChoice, + requestedEngine: parsed.requestedEngine, noOpen: !openBrowserChoice, companionsEnabled: companionsChoice, telemetryEnabled: telemetryChoice, @@ -1245,6 +1324,7 @@ async function planInteractiveSetup({ return { requestedProfile: parsed.requestedProfile, requestedAccessMode: parsed.requestedAccessMode, + requestedEngine: parsed.requestedEngine, noOpen: parsed.noOpen, companionsEnabled: parsed.companionsEnabled, telemetryEnabled: false @@ -1264,6 +1344,9 @@ function hasRequestedConfigChange(rawExisting, parsed) { if (parsed.companionsEnabled && rawExisting.companionsEnabled !== true) { return true; } + if (parsed.requestedEngine !== undefined && resolveEngine(rawExisting) !== parsed.requestedEngine) { + return true; + } if (parsed.requestedProfile !== "auto" && rawExisting.profile !== parsed.requestedProfile) { return true; } @@ -1318,7 +1401,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, - installArgs: {root,requestedProfile:repaired.profile,requestedAccessMode:repaired.accessMode,noOpen:true,autoConfirm:true}}); + installArgs: {root,requestedProfile:repaired.profile,requestedAccessMode:repaired.accessMode,requestedEngine:repaired.engine,noOpen:true,autoConfirm:true}, + ...(repaired.engine ? { engine: repaired.engine } : {})}); 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; @@ -1326,7 +1410,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 +1419,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 +1484,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 +1562,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 +1625,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 +1707,7 @@ async function reportInstallDiagnostics({ root, stateRoot, profile, + engine, platform, stdin, stdout, @@ -1637,7 +1722,8 @@ async function reportInstallDiagnostics({ composeCommand(action, root, { profile, stateRoot, - lines: 200 + lines: 200, + engine }), { stdin, stdout, stderr }, { platform, stderr } @@ -1661,6 +1747,7 @@ async function restoreRuntimeAfterFailedInstall({ existingConfig, attemptedProfile, companionsEnabled, + engine, platform, stdin, stdout, @@ -1679,7 +1766,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 +1786,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 ?? engine }) ); if (stopCode !== 0) { @@ -1713,9 +1802,30 @@ async function restoreRuntimeAfterFailedInstall({ } } +function engineFromAnswers(stdin, requestedEngine) { + if (requestedEngine !== undefined) return requestedEngine; + const raw = stdin?._spaceappPromptController?.answers?.engine; + if (raw === undefined || raw === null || raw === "") return undefined; + return resolveEngine(String(raw)); +} + +function parseReinstallEngineArg(args) { + for (let index = 0; index < args.length; index += 1) { + const argument = args[index]; + if (argument === "--engine" && index + 1 < args.length) { + return resolveEngine(args[index + 1]); + } + if (argument.startsWith("--engine=")) { + return resolveEngine(argument.slice("--engine=".length)); + } + } + return undefined; +} + 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 +1856,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 +1874,17 @@ function parseInstallArgs(args) { ( requestedAccessMode !== undefined && !["isolated", "host-root"].includes(requestedAccessMode) + ) || + ( + requestedEngine !== undefined && + !["docker", "podman"].includes(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 +1919,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; }; @@ -1811,7 +1934,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" && !readOnly && resolveEngine(updated) !== "podman") await writeAccessHint(identity, stdout); return applyWorkspaces(updated); } if (action === "remove") { @@ -1852,7 +1975,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 +2015,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 +2031,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 +2078,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 +2094,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 +2120,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 +2139,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 +2193,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 +2220,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 +2248,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 +2256,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) { @@ -2190,6 +2313,7 @@ async function supportBundleCommand({ version: config.version, profile: config.profile, accessMode: config.accessMode, + engine: resolveEngine(config), companionsEnabled: config.companionsEnabled, bindHost: config.bindHost, port: config.port, @@ -2197,6 +2321,9 @@ async function supportBundleCommand({ } : null }, + engine: resolveEngine(config) === "podman" + ? { type: "podman", ...(await detectPodmanStatus({ execute })) } + : { type: "docker" }, services: {}, health: {} }; @@ -2265,7 +2392,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 +2489,13 @@ async function updateCommand(args, { root, config, version, platform, stdin, std } } const prerequisites = await ensureDocker({ platform, arch, env, stdin, stdout, stderr, execute, - installArgs: { root, requestedProfile: config.profile, noOpen: true, autoConfirm: true } }); + installArgs: { root, requestedProfile: config.profile, requestedEngine: config.engine, noOpen: true, autoConfirm: true }, + ...(config.engine ? { engine: config.engine } : {}) }); 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" }); 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, dockerReady: true, operation: "upgrade", storagePlan, engine: config.engine }) !== 0) return 1; return performUpdate({ root, config, @@ -2408,13 +2536,15 @@ async function doctor({ profile, operation, storagePlan, - dockerReady = false + dockerReady = false, + engine }) { - 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 existingConfig = await loadConfig(root).catch(() => null); + const effectiveEngine = resolveEngine(engine ?? existingConfig); + const configuration = profile ? { ok: true, detail: "Prepared installation configuration" } : existingConfig ? { ok: true, detail: root } : 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; if (!resolvedProfile) { - const existingConfig = await loadConfig(root).catch(() => null); if (existingConfig?.profile) { resolvedProfile = existingConfig.profile; } else if (detectedResources?.totalMemoryBytes) { @@ -2423,7 +2553,7 @@ async function doctor({ resolvedProfile = "light"; } } - operation ??= await loadConfig(root).then(() => "maintenance").catch(() => "fresh"); + operation ??= existingConfig ? "maintenance" : "fresh"; const resourceChecks = installResourceChecks(storagePlan?.resources ?? detectedResources, resolvedProfile, { operation, ...(storagePlan ? { requiredDiskBytes: storagePlan.requiredDiskBytes } : {}) }); @@ -2433,6 +2563,50 @@ async function doctor({ ...resourceChecks.filter((check) => !storagePlan || check.name !== "Free disk"), ...(storagePlan ? storageChecks(storagePlan, detectedResources) : []) ]; + if (effectiveEngine === "podman") { + const podmanStatus = await detectPodmanStatus({ execute }); + const cliOk = dockerReady || podmanStatus.cliInstalled; + const composeOk = dockerReady || podmanStatus.composeAvailable; + const engineOk = dockerReady || podmanStatus.daemonRunning; + const modeLabel = podmanStatus.rootless === true ? "rootless" : podmanStatus.rootless === false ? "rootful" : "ready"; + const cgroupDetail = podmanStatus.cgroupVersion === "v2" + ? (podmanStatus.cgroupDelegated ? ", cgroup v2 (cpu/memory delegated)" : ", warning: cgroup v2 cpu/memory controllers not delegated") + : ""; + checks.push({ + name: "Podman CLI", + ok: cliOk && (podmanStatus.majorVersion === null || podmanStatus.majorVersion >= 5), + detail: cliOk + ? (podmanStatus.majorVersion !== null && podmanStatus.majorVersion < 5 + ? `unsupported version (${podmanStatus.versionText || podmanStatus.version}); Podman 5+ required` + : `available${podmanStatus.version ? ` (${podmanStatus.version})` : ""}`) + : "not found on PATH" + }); + checks.push({ + name: "Podman Compose", + ok: composeOk, + detail: composeOk + ? "available" + : !cliOk + ? "not available because Podman CLI is missing" + : "provider unavailable (install podman-compose or docker-compose v2)" + }); + checks.push({ + name: "Podman Engine", + ok: engineOk, + detail: engineOk + ? `available (${modeLabel}${cgroupDetail})` + : cliOk + ? "installed but not running or inaccessible" + : "not reachable because Podman CLI is missing" + }); + for (const check of checks) { + (check.ok ? stdout : stderr).write(`${check.ok ? "PASS" : "FAIL"} ${check.name}: ${check.detail}\n`); + } + if (!cliOk || !composeOk || !engineOk || (podmanStatus.majorVersion !== null && podmanStatus.majorVersion < 5)) { + stderr.write(`${podmanInstallInstructions(platform)}\n`); + } + return checks.every((check) => check.ok) ? 0 : 1; + } const dockerResults = []; for (const probe of [ { name: "Docker CLI", command: "docker", args: ["--version"] }, @@ -2586,6 +2760,10 @@ async function executeWithDockerDiagnostics(execute, spec, io, { platform, stder stderr.write( `SpaceApp could not find the Docker CLI. ${dockerInstallHelp(platform)}\n` ); + } else if (spec.command === "podman" && code === 127) { + stderr.write( + `SpaceApp could not find the Podman CLI. ${podmanInstallInstructions(platform)}\n` + ); } return code; } @@ -2655,6 +2833,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,7 +2939,7 @@ 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 [--profile auto|small|medium|large|light|standard] [--access isolated|host-root] [--engine docker|podman] [--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 diff --git a/packages/run-spaceapp/src/index.mjs b/packages/run-spaceapp/src/index.mjs index e41d6986c..9c89677c3 100644 --- a/packages/run-spaceapp/src/index.mjs +++ b/packages/run-spaceapp/src/index.mjs @@ -119,6 +119,7 @@ const ALL_PROVIDERS = new Set(Object.values(PROVIDERS).flat()); export const CLI_SETTINGS_FILES = Object.freeze([ "claude/settings.json", "claude/.claude.json", "codex/config.toml", "opencode/config/opencode/opencode.json" ]); +export const SUPPORTED_ENGINES = Object.freeze(["docker", "podman"]); const CONFIG_KEYS = new Set([ "schemaVersion", "version", @@ -128,6 +129,7 @@ const CONFIG_KEYS = new Set([ "telemetry", "profile", "accessMode", + "engine", "workspaces", "companionsEnabled", "optionalModules", @@ -197,6 +199,38 @@ export function resolveInstallAccessMode(requestedMode, existingMode = "isolated throw new Error("Install access mode must be isolated or host-root."); } +export function resolveEngine(configOrEngine = "docker") { + const candidate = typeof configOrEngine === "string" + ? configOrEngine + : configOrEngine?.engine ?? "docker"; + if (!SUPPORTED_ENGINES.includes(candidate)) { + throw new Error("Container engine must be docker or podman."); + } + return candidate; +} + +// The published images start as root and `gosu` to uid 10001. Rootless `keep-id:uid=10001` +// maps that uid to the host user, but `no-new-privileges` then makes the switch fail with +// "operation not permitted". Drop that option only on spaceapp-cli for Podman. +const PODMAN_CLI_NNP_BLOCK = [ + " # compose.workspaces.yml adds the user-namespace profile where the host allows user namespaces (F17).", + " security_opt:", + " - no-new-privileges:true", + "" +].join("\n"); + +export function composeFileForEngine(template, engine = "docker") { + if (typeof template !== "string") throw new Error("Compose template must be text."); + if (resolveEngine(engine) !== "podman") return template; + if (!template.includes(PODMAN_CLI_NNP_BLOCK)) { + throw new Error("Podman compose template is missing the spaceapp-cli no-new-privileges block."); + } + return template.replace( + PODMAN_CLI_NNP_BLOCK, + " # Podman keep-id maps container uid 10001 to the host user. no-new-privileges blocks gosu from switching to that uid.\n" + ); +} + export function versionTuple(version) { if (typeof version !== "string") { return null; @@ -311,13 +345,20 @@ export function createDefaultConfig({ version, profile = "light", accessMode = "isolated", - companionsEnabled = false + companionsEnabled = false, + engine }) { assertVersion(version); if (!INSTALL_PROFILES.includes(profile)) { throw new Error("Default config requires a resolved installation profile."); } const resolvedAccessMode = resolveInstallAccessMode(accessMode); + const resolvedEngine = engine === undefined ? undefined : resolveEngine(engine); + if (resolvedEngine === "podman" && resolvedAccessMode === "host-root") { + throw new Error( + `Host-root access (--access host-root) is not supported on Podman because rootless user namespaces remap host UIDs. Use --access isolated or register specific folders with "${UNIVERSAL_COMMAND} workspace add ".` + ); + } return { schemaVersion: CONFIG_SCHEMA_VERSION, version, @@ -327,6 +368,7 @@ export function createDefaultConfig({ telemetry: false, profile, accessMode: resolvedAccessMode, + ...(resolvedEngine === "podman" ? { engine: "podman" } : {}), companionsEnabled, optionalModules: "none", workspaces: [] @@ -367,6 +409,14 @@ export function validateConfig(config) { if (config.accessMode !== "isolated" && config.accessMode !== "host-root") { throw new Error("accessMode must be isolated or host-root."); } + if (config.engine !== undefined && !SUPPORTED_ENGINES.includes(config.engine)) { + throw new Error("engine must be docker or podman."); + } + if (config.engine === "podman" && config.accessMode === "host-root") { + throw new Error( + `Host-root access (--access host-root) is not supported on Podman because rootless user namespaces remap host UIDs. Use --access isolated or register specific folders with "${UNIVERSAL_COMMAND} workspace add ".` + ); + } if (typeof config.companionsEnabled !== "boolean") { throw new Error("companionsEnabled must be boolean."); } @@ -676,17 +726,30 @@ const CLI_SANDBOX_OPTIONS = [" security_opt:", " - seccomp=./seccomp-cli export function renderWorkspaceCompose(config, { cliSandbox = false } = {}) { validateConfig(config); + const isPodman = resolveEngine(config) === "podman"; + const podmanBindOpts = isPodman ? [" bind:", " selinux: z"] : []; + const podmanCliUserns = 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}`, + ...podmanBindOpts ]); 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, + " spaceapp-cli:", + ...podmanCliUserns, + ...service, + ...(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,19 +758,24 @@ 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}`, + ...podmanBindOpts ]; const userServices = users.flatMap((user) => { const slug = userSlug(user.id); + // keep-id needs gosu to switch from root to uid 10001; no-new-privileges refuses that switch. + const securityOpt = [ + ...(isPodman ? [] : [" - no-new-privileges:true"]), + ...(cliSandbox ? CLI_SANDBOX_OPTIONS.slice(1) : []) + ]; return [ ` spaceapp-cli-${slug}:`, // 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", " init: true", - " security_opt:", - " - no-new-privileges:true", - ...(cliSandbox ? CLI_SANDBOX_OPTIONS.slice(1) : []), + ...podmanCliUserns, + ...(securityOpt.length > 0 ? [" security_opt:", ...securityOpt] : []), ` mem_limit: "${user.memoryLimit ?? settings.cliMemoryLimit}"`, " cpus: \"${SPACEAPP_CLI_CPU_LIMIT}\"", " environment:", @@ -737,6 +805,7 @@ export function renderWorkspaceCompose(config, { cliSandbox = false } = {}) { " - ./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)}`), " spaceapp-cli:", + ...podmanCliUserns, ...service, ...(cliSandbox ? CLI_SANDBOX_OPTIONS : []), ...userServices, @@ -909,7 +978,8 @@ export function composeCommand(action, root, options = {}) { if (!selected) { throw new Error(`Unsupported Compose action "${action}".`); } - return { command: "docker", args: [...base, ...selected] }; + const engine = resolveEngine(options); + return { command: engine, args: [...base, ...selected] }; } export function composeProjectName(root) { @@ -984,7 +1054,8 @@ export async function prepareInstallation(root, { version, profile, accessMode, - companionsEnabled + companionsEnabled, + engine }) { validateHome(root); await mkdir(root, { recursive: true, mode: 0o700 }); @@ -999,6 +1070,13 @@ export async function prepareInstallation(root, { let config; try { config = await loadConfig(root); + if (engine !== undefined && resolveEngine(engine) !== resolveEngine(config)) { + const currentEngine = resolveEngine(config); + const targetEngine = resolveEngine(engine); + throw new Error( + `Switching container engines on an existing installation (${currentEngine} -> ${targetEngine}) is refused because ${currentEngine} volumes are not visible to ${targetEngine}. Run "${UNIVERSAL_COMMAND} backup", then "${UNIVERSAL_COMMAND} uninstall --purge-data" (or set a new SPACEAPP_HOME), install with "--engine ${targetEngine}", and run "${UNIVERSAL_COMMAND} restore".` + ); + } } catch (error) { if (error?.code !== "ENOENT") { throw error; @@ -1007,7 +1085,8 @@ export async function prepareInstallation(root, { version, profile: resolvedProfile ?? "light", accessMode: resolveInstallAccessMode(accessMode), - companionsEnabled + companionsEnabled, + engine }); } if (config.version !== version) { @@ -1091,7 +1170,10 @@ export async function commitInstallation(root, config, { } = {}) { validateHome(root); validateConfig(config); - const composeTemplate = await readFile(join(templateDir, "compose.yml"), "utf8"); + const composeTemplate = composeFileForEngine( + await readFile(join(templateDir, "compose.yml"), "utf8"), + config + ); await mkdir(root, { recursive: true, mode: 0o700 }); await atomicWrite(join(root, "compose.yml"), composeTemplate); await mkdir(projectRoot, { recursive: true, mode: 0o700 }); diff --git a/packages/run-spaceapp/src/prerequisites.mjs b/packages/run-spaceapp/src/prerequisites.mjs index 42bb242da..13732647a 100644 --- a/packages/run-spaceapp/src/prerequisites.mjs +++ b/packages/run-spaceapp/src/prerequisites.mjs @@ -5,10 +5,11 @@ import { tmpdir } from "node:os"; import { join, posix, win32 } from "node:path"; import process from "node:process"; import { createInterface } from "node:readline/promises"; -import { Readable } from "node:stream"; +import { Readable, Writable } from "node:stream"; import { pipeline } from "node:stream/promises"; import { spawn } from "node:child_process"; import { fileURLToPath } from "node:url"; +import { INSTALL_PROFILES, PROFILE_REQUIREMENTS } from "./index.mjs"; import { UNIVERSAL_INSTALL_COMMAND } from "./package-info.mjs"; const DOCKER_TERMS_URL = "https://www.docker.com/legal/docker-subscription-service-agreement/"; @@ -134,11 +135,36 @@ export async function ensureDockerAvailable({ osRelease, installArgs, scheduleResume = scheduleWindowsInstallResume, - confirmRestart = confirmWindowsRestart + confirmRestart = confirmWindowsRestart, + engine }) { + const requestedEngine = engine ?? installArgs?.requestedEngine; + if (requestedEngine === "podman") { + return ensurePodmanAvailable({ platform, env, stdin, stdout, stderr, execute, installArgs }); + } if (await dockerIsReady(execute)) { return { code: 0, reexecuted: false }; } + if (requestedEngine === undefined && platform !== "win32" && !(await dockerCliAndComposeAreAvailable(execute))) { + const podmanStatus = await detectPodmanStatus({ execute }); + if (podmanStatus.ready) { + // autoConfirm stays reserved for the Docker Desktop prompt the wizard already accepted. + // Interactive installs ask; --non-interactive (podmanAutoSelect) accepts Podman. + const autoPickPodman = installArgs?.podmanAutoSelect ?? Boolean(installArgs?.autoConfirm); + const usePodman = autoPickPodman + ? true + : await confirmQuestion({ + stdin, + stdout, + question: "Docker is not installed, but rootless Podman is ready on this machine. Use Podman instead of installing Docker? [Y/n] ", + defaultYes: true + }); + if (usePodman) { + stdout?.write("Using Podman as the container engine.\n"); + return ensurePodmanAvailable({ platform, env, stdin, stdout, stderr, execute, installArgs }); + } + } + } if (platform === "win32") { return ensureWindowsDocker({ arch, @@ -630,11 +656,12 @@ async function confirmWindowsRestart({ stdin, stdout, question }) { return confirmQuestion({ stdin, stdout, question }); } -async function confirmQuestion({ stdin, stdout, question }) { - const readline = createInterface({ input: stdin, output: stdout, terminal: Boolean(stdin.isTTY) }); +async function confirmQuestion({ stdin, stdout, question, defaultYes = false }) { + const readline = createInterface({ input: stdin, output: stdout, terminal: Boolean(stdin?.isTTY) }); try { - const answer = await readline.question(question); - return /^(?:y|yes)$/i.test(answer.trim()); + const answer = (await readline.question(question)).trim(); + if (!answer && defaultYes) return true; + return /^(?:y|yes)$/i.test(answer); } catch { return false; } finally { @@ -667,6 +694,249 @@ async function dockerIsReady(execute) { return true; } +async function captureExecuteOutput(execute, spec) { + let streamText = ""; + let callbackText = ""; + let stderrText = ""; + const outStream = new Writable({ + write(chunk, _encoding, callback) { + if (streamText.length < 1024 * 1024) streamText += String(chunk); + callback(); + } + }); + const errStream = new Writable({ + write(chunk, _encoding, callback) { + if (stderrText.length < 1024 * 1024) stderrText += String(chunk); + callback(); + } + }); + try { + const code = await execute( + { + ...spec, + onStdout(chunk) { + if (callbackText.length < 1024 * 1024) callbackText += String(chunk); + }, + onStderr(chunk) { + if (stderrText.length < 1024 * 1024) stderrText += String(chunk); + } + }, + { stdout: outStream, stderr: errStream } + ); + const stdoutText = streamText || callbackText; + return { code, stdout: stdoutText.trim(), stderr: stderrText.trim() }; + } catch { + return { code: 1, stdout: "", stderr: "" }; + } +} + +export function parsePodmanMajorVersion(versionText, infoVersion = null) { + const raw = String(infoVersion || versionText || "").trim(); + const match = /(?:podman\s+version\s+|^)(\d+)\.(\d+)\.(\d+)/i.exec(raw); + return match ? Number(match[1]) : null; +} + +// `podman machine set` takes memory in MiB and disk in GiB. `machine inspect` reports those +// small units on some versions and raw bytes on others. A value below 1 MiB is the set-command unit. +function podmanQuantityToBytes(value, smallUnit) { + const amount = Number(value); + if (!Number.isFinite(amount) || amount <= 0) return null; + if (amount >= 2 ** 20) return Math.round(amount); + const unit = smallUnit === "mib" ? 1024 ** 2 : 1024 ** 3; + return Math.round(amount * unit); +} + +export function podmanMachineResources(inspectValue) { + const machine = Array.isArray(inspectValue) ? inspectValue[0] : inspectValue; + const resources = machine?.Resources ?? machine?.resources ?? {}; + const cpuCount = Number(resources.CPUs ?? resources.cpus); + return { + cpuCount: Number.isFinite(cpuCount) && cpuCount > 0 ? cpuCount : null, + memoryBytes: podmanQuantityToBytes(resources.Memory ?? resources.memory, "mib"), + diskBytes: podmanQuantityToBytes(resources.DiskSize ?? resources.diskSize, "gib"), + state: typeof (machine?.State ?? machine?.state) === "string" ? (machine.State ?? machine.state) : null + }; +} + +function formatMachineGib(bytes) { + return (Math.round((bytes / 1024 ** 3) * 10) / 10).toFixed(1); +} + +/** Null when the machine meets the profile. Otherwise a resize command that never shrinks the disk. */ +export function podmanMachineResizeAdvice(resources, profile = "small") { + const required = PROFILE_REQUIREMENTS[INSTALL_PROFILES.includes(profile) ? profile : "small"]; + const resolvedProfile = INSTALL_PROFILES.includes(profile) ? profile : "small"; + const gaps = []; + const setArgs = []; + if (resources?.cpuCount != null && resources.cpuCount < required.cpuCount) { + gaps.push(`${resources.cpuCount} CPUs (${required.cpuCount} required)`); + setArgs.push("--cpus", String(required.cpuCount)); + } + if (resources?.memoryBytes != null && resources.memoryBytes < required.memoryBytes) { + gaps.push(`${formatMachineGib(resources.memoryBytes)} GiB memory (${formatMachineGib(required.memoryBytes)} GiB required)`); + setArgs.push("--memory", String(Math.ceil(required.memoryBytes / 1024 ** 2))); + } + if (resources?.diskBytes != null && resources.diskBytes < required.freshDiskBytes) { + gaps.push(`${formatMachineGib(resources.diskBytes)} GiB disk (${formatMachineGib(required.freshDiskBytes)} GiB required)`); + setArgs.push("--disk-size", String(Math.ceil(required.freshDiskBytes / 1024 ** 3))); + } + if (gaps.length === 0) return null; + const resize = setArgs.length > 0 + ? `podman machine stop && podman machine set ${setArgs.join(" ")} && podman machine start` + : "podman machine inspect"; + return `Podman machine is too small for the ${resolvedProfile} profile (${gaps.join("; ")}). Resize it without shrinking the disk: ${resize}`; +} + +export function podmanInstallInstructions(platform = process.platform) { + if (platform === "darwin") { + return "Install Podman 5+ and Compose on macOS with: brew install podman podman-compose && podman machine init --cpus 4 --memory 8192 --disk-size 30 && podman machine start"; + } + if (platform === "win32") { + return "Install Podman 5+ on Windows with: winget install -e --id RedHat.Podman && podman machine init --cpus 4 --memory 8192 --disk-size 30 && podman machine start"; + } + return "Install Podman 5+ and its Compose provider on Linux (for example: sudo dnf install -y podman podman-compose, or sudo apt-get install -y podman podman-compose)."; +} + +export async function detectPodmanStatus({ execute }) { + const versionRes = await captureExecuteOutput(execute, { command: "podman", args: ["--version"] }); + if (versionRes.code !== 0) { + return { + cliInstalled: false, + version: null, + majorVersion: null, + composeAvailable: false, + daemonRunning: false, + rootless: null, + cgroupVersion: null, + cgroupControllers: [], + cgroupDelegated: false, + ready: false + }; + } + const composeRes = await captureExecuteOutput(execute, { command: "podman", args: ["compose", "version"] }); + const infoRes = await captureExecuteOutput(execute, { command: "podman", args: ["info", "--format", "{{json .}}"] }); + let parsedInfo = null; + if (infoRes.code === 0 && infoRes.stdout) { + try { + parsedInfo = JSON.parse(infoRes.stdout); + } catch { + parsedInfo = null; + } + } + const versionString = parsedInfo?.version?.Version ?? (versionRes.stdout || null); + const majorVersion = parsePodmanMajorVersion(versionRes.stdout, parsedInfo?.version?.Version); + const cgroupControllers = Array.isArray(parsedInfo?.host?.cgroupControllers) + ? parsedInfo.host.cgroupControllers + : []; + const cgroupVersion = typeof parsedInfo?.host?.cgroupVersion === "string" + ? parsedInfo.host.cgroupVersion + : null; + const rootless = typeof parsedInfo?.host?.security?.rootless === "boolean" + ? parsedInfo.host.security.rootless + : null; + const cpuCount = Number(parsedInfo?.host?.cpus); + const memoryBytes = Number(parsedInfo?.host?.memTotal); + const ready = Boolean( + versionRes.code === 0 && + majorVersion !== null && + majorVersion >= 5 && + composeRes.code === 0 && + infoRes.code === 0 + ); + return { + cliInstalled: true, + versionText: versionRes.stdout, + version: versionString, + majorVersion, + composeAvailable: composeRes.code === 0, + composeOutput: `${composeRes.stdout}\n${composeRes.stderr}`.trim(), + daemonRunning: infoRes.code === 0, + rootless, + cgroupVersion, + cgroupControllers, + cgroupDelegated: cgroupControllers.includes("cpu") && cgroupControllers.includes("memory"), + cpuCount: Number.isFinite(cpuCount) && cpuCount > 0 ? cpuCount : null, + memoryBytes: Number.isFinite(memoryBytes) && memoryBytes > 0 ? memoryBytes : null, + ready + }; +} + +export async function ensurePodmanAvailable({ + platform = process.platform, + stdout = process.stdout, + stderr = process.stderr, + execute, + installArgs +}) { + if (platform === "win32") { + stderr?.write( + "Podman is not supported on Windows. Install Docker Desktop, or run SpaceApp with rootless Podman on Linux or a macOS Podman machine.\n" + ); + return { code: 1, reexecuted: false }; + } + const status = await detectPodmanStatus({ execute }); + if (!status.cliInstalled) { + stderr?.write(`Podman is not installed or not on PATH. ${podmanInstallInstructions(platform)}\n`); + return { code: 1, reexecuted: false }; + } + if (status.majorVersion !== null && status.majorVersion < 5) { + stderr?.write( + `SpaceApp requires Podman 5.0 or newer for rootless Compose and Pasta networking (found "${status.versionText || status.version}"). ${podmanInstallInstructions(platform)}\n` + ); + return { code: 1, reexecuted: false }; + } + if (!status.composeAvailable) { + stderr?.write( + `Podman Compose is not available ("podman compose version" failed). Install podman-compose or docker-compose v2 so "podman compose" has an external Compose provider. ${podmanInstallInstructions(platform)}\n` + ); + return { code: 1, reexecuted: false }; + } + if (!status.daemonRunning) { + if (platform === "darwin" || platform === "win32") { + stderr?.write( + 'Podman is installed, but the Podman machine is not running. Run "podman machine init --cpus 4 --memory 8192 --disk-size 30 && podman machine start", then run SpaceApp again.\n' + ); + return { code: 1, reexecuted: false }; + } + stderr?.write( + 'Podman is installed, but "podman info" failed. Ensure rootless subuids/subgids are configured (sudo usermod --add-subuids 100000-165535 --add-subgids 100000-165535 $USER && podman system migrate), then run SpaceApp again.\n' + ); + return { code: 1, reexecuted: false }; + } + if (platform === "darwin") { + const requestedProfile = installArgs?.requestedProfile; + const profile = INSTALL_PROFILES.includes(requestedProfile) ? requestedProfile : "small"; + const inspected = await captureExecuteOutput(execute, { command: "podman", args: ["machine", "inspect", "--format", "json"] }); + let resources = null; + if (inspected.code === 0 && inspected.stdout) { + try { + resources = podmanMachineResources(JSON.parse(inspected.stdout)); + } catch { + resources = null; + } + } + const measured = resources ?? { + cpuCount: status.cpuCount, + memoryBytes: status.memoryBytes, + diskBytes: null + }; + const advice = podmanMachineResizeAdvice(measured, profile); + if (advice) { + stderr?.write(`${advice}\n`); + return { code: 1, reexecuted: false }; + } + if (!resources) { + stdout?.write("Podman machine disk size could not be read from `podman machine inspect`. CPU and memory were taken from `podman info`.\n"); + } + } + if (platform === "linux" && status.rootless === true && status.cgroupVersion === "v2" && !status.cgroupDelegated) { + stdout?.write( + "Warning: rootless Podman cgroup v2 cpu/memory controllers are not delegated to this user session; Compose CPU and memory limits may not be enforced.\n" + ); + } + return { code: 0, reexecuted: false, engine: "podman", podmanStatus: status }; +} + async function installLinuxDockerEngine({ arch, release, diff --git a/packages/run-spaceapp/src/storage.mjs b/packages/run-spaceapp/src/storage.mjs index c8b4cfadc..fe00aede7 100644 --- a/packages/run-spaceapp/src/storage.mjs +++ b/packages/run-spaceapp/src/storage.mjs @@ -1,7 +1,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 { composeCommand, profileRuntimeSettings, PROFILE_REQUIREMENTS, resolveEngine } from "./index.mjs"; import { localImageRefs } from "./image-lock.mjs"; const GiB = 1024 ** 3; @@ -96,15 +96,20 @@ async function installedImageDigests(root, version) { } export async function inspectStoragePlan({ root, config, existingConfig, resources, execute, operation, fetch = globalThis.fetch }) { - const dockerInfo = await captureProbe(execute, { command: "docker", args: ["info", "--format", "{{json .}}"] }); + const engineCommand = resolveEngine(existingConfig ?? config); + const dockerInfo = await captureProbe(execute, { command: engineCommand, args: ["info", "--format", "{{json .}}"] }); let engine = null; try { engine = JSON.parse(dockerInfo); } catch { /* Conservative reserve when the engine is unavailable. */ } + const engineArch = engine?.Architecture ?? engine?.host?.arch ?? ""; + const engineRootDir = engine?.DockerRootDir ?? engine?.store?.graphRoot ?? null; + const engineCpuCount = engine?.NCPU ?? engine?.host?.cpus; + const engineMemoryBytes = engine?.MemTotal ?? engine?.host?.memTotal; 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: engineCommand, args: ["image", "inspect", "--format", "{{json .RootFS.Layers}}", ref] }); try { for (const layer of JSON.parse(output)) cachedLayers.add(layer); break; } catch {} } } @@ -126,15 +131,15 @@ 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: engineCommand, 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") { try { - const metadata = await registryLayers(image, /arm|aarch64/.test(engine.Architecture ?? "") ? "arm64" : "amd64", fetch); + const metadata = await registryLayers(image, /arm|aarch64/.test(engineArch) ? "arm64" : "amd64", fetch); if (metadata) { reserve = estimateMissingLayers(metadata.layers, metadata.diffIds, cachedLayers); sharedLayersEstimated = true; } } catch { /* Offline/private registry: retain the full conservative reserve. */ } } @@ -154,16 +159,16 @@ 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 Docker or Podman filesystem directly even on a fresh install. + 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, freeDiskBytes: dockerFreeDiskBytes === null ? resources.freeDiskBytes : Math.min(resources.freeDiskBytes, dockerFreeDiskBytes), - engineCpuCount: engine?.NCPU, - engineMemoryBytes: engine?.MemTotal + engineCpuCount, + engineMemoryBytes } }; } diff --git a/packages/run-spaceapp/tests/podman.test.mjs b/packages/run-spaceapp/tests/podman.test.mjs new file mode 100644 index 000000000..5a96be6a5 --- /dev/null +++ b/packages/run-spaceapp/tests/podman.test.mjs @@ -0,0 +1,503 @@ +import assert from "node:assert/strict"; +import { mkdtemp, mkdir, readFile, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { PassThrough, Writable } from "node:stream"; +import test from "node:test"; +import { run } from "../src/cli.mjs"; +import { + composeCommand, + composeFileForEngine, + createDefaultConfig, + loadConfig, + prepareInstallation, + renderWorkspaceCompose, + validateConfig +} from "../src/index.mjs"; +import { + ensureDockerAvailable, + ensurePodmanAvailable, + parsePodmanMajorVersion, + podmanMachineResizeAdvice, + podmanMachineResources +} from "../src/prerequisites.mjs"; +import { inspectStoragePlan } from "../src/storage.mjs"; + +const GIB = 1024 ** 3; +const abundantResources = async () => ({ + cpuCount: 8, + totalMemoryBytes: 16 * GIB, + freeDiskBytes: 64 * GIB +}); + +function createIo(input = "") { + const stdin = new PassThrough(); + stdin.end(input); + let out = ""; + let err = ""; + const stdout = new Writable({ + write(chunk, _enc, cb) { + out += String(chunk); + cb(); + } + }); + const stderr = new Writable({ + write(chunk, _enc, cb) { + err += String(chunk); + cb(); + } + }); + return { + stdin, + stdout, + stderr, + get out() { return out; }, + get err() { return err; } + }; +} + +function createPodmanInfoJson({ + rootless = true, + cgroupVersion = "v2", + cgroupControllers = ["cpu", "memory", "pids"], + cpus = 8, + memTotal = 16 * GIB, + graphRoot = "/home/user/.local/share/containers/storage", + arch = "amd64", + version = "5.3.1" +} = {}) { + return JSON.stringify({ + host: { + arch, + cpus, + memTotal, + cgroupVersion, + cgroupControllers, + security: { rootless } + }, + store: { + graphRoot + }, + version: { + Version: version + } + }); +} + +test("parsePodmanMajorVersion extracts major version and ensurePodmanAvailable rejects Podman 4.x", async () => { + assert.equal(parsePodmanMajorVersion("podman version 5.3.1"), 5); + assert.equal(parsePodmanMajorVersion("podman version 4.9.4-rhel"), 4); + assert.equal(parsePodmanMajorVersion(""), null); + + const io = createIo(); + const result = await ensurePodmanAvailable({ + platform: "linux", + stdin: io.stdin, + stdout: io.stdout, + stderr: io.stderr, + execute: async (spec) => { + if (spec.command === "podman" && spec.args[0] === "--version") { + spec.onStdout?.("podman version 4.9.3\n"); + return 0; + } + if (spec.command === "podman" && spec.args[0] === "compose") return 0; + if (spec.command === "podman" && spec.args[0] === "info") { + spec.onStdout?.(createPodmanInfoJson({ version: "4.9.3" })); + return 0; + } + return 0; + } + }); + assert.equal(result.code, 1); + assert.match(io.err, /requires Podman 5\.0 or newer/i); +}); + +test("ensureDockerAvailable auto-detects and offers Podman 5+ when Docker CLI is absent", async () => { + const io = createIo("y\n"); + Object.defineProperty(io.stdin, "isTTY", { value: true }); + const result = await ensureDockerAvailable({ + platform: "linux", + stdin: io.stdin, + stdout: io.stdout, + stderr: io.stderr, + execute: async (spec) => { + if (spec.command === "docker") return 127; + if (spec.command === "podman" && spec.args[0] === "--version") { + spec.onStdout?.("podman version 5.2.0\n"); + return 0; + } + if (spec.command === "podman" && spec.args[0] === "compose") return 0; + if (spec.command === "podman" && spec.args[0] === "info") { + spec.onStdout?.(createPodmanInfoJson({ version: "5.2.0", rootless: true })); + return 0; + } + return 0; + } + }); + assert.equal(result.code, 0); + assert.equal(result.engine, "podman"); + assert.match(io.out, /Using Podman as the container engine/i); +}); + +test("renderWorkspaceCompose adds SELinux :z label and keep-id userns_mode for Podman", () => { + const config = { + ...createDefaultConfig({ + version: "1.1.7", + engine: "podman" + }), + users: [ + { + id: "user:Alice", + workspaces: [ + { id: "team-a-abcdef12", hostPath: "/home/dev/team-a", containerPath: "/workspaces/team-a-abcdef12", readOnly: true } + ] + } + ], + workspaces: [ + { id: "repo-12345678", hostPath: "/home/dev/repo", containerPath: "/workspaces/repo-12345678", readOnly: false } + ] + }; + validateConfig(config); + assert.equal(config.engine, "podman"); + + const rendered = renderWorkspaceCompose(config); + assert.match(rendered, /selinux: z/); + assert.match(rendered, /userns_mode: "keep-id:uid=10001,gid=10001"/); + assert.match(rendered, /spaceapp-cli-alice-[a-f0-9]+:/); + assert.doesNotMatch(rendered, /no-new-privileges/); +}); + +test("validateConfig and prepareInstallation refuse --access host-root when engine is podman", async () => { + assert.throws( + () => validateConfig(createDefaultConfig({ version: "1.1.7", engine: "podman", accessMode: "host-root" })), + /Host-root access \(--access host-root\) is not supported on Podman/i + ); + + const root = await mkdtemp(join(tmpdir(), "spaceapp-podman-hostroot-")); + try { + await assert.rejects( + () => prepareInstallation(root, { version: "1.1.7", engine: "podman", accessMode: "host-root" }), + /Host-root access \(--access host-root\) is not supported on Podman/i + ); + } finally { + await rm(root, { recursive: true, force: true }); + } +}); + +test("composeCommand uses podman compose when engine is podman across lifecycle actions", () => { + const root = "/tmp/spaceapp-podman"; + for (const action of ["pull", "up", "down", "status", "logs", "backup", "restore", "purge", "syncWorkspaces"]) { + const cmd = composeCommand(action, root, { + profile: "small", + engine: "podman", + backupId: "spaceapp-backup-20261027T000000000Z" + }); + assert.equal(cmd.command, "podman", `Expected podman command for action ${action}`); + assert.equal(cmd.args[0], "compose"); + } +}); + +test("inspectStoragePlan queries podman info and podman image inspect when engine is podman", async () => { + const root = await mkdtemp(join(tmpdir(), "spaceapp-podman-storage-")); + try { + const config = createDefaultConfig({ version: "1.1.7", engine: "podman", profile: "small" }); + const seen = []; + const plan = await inspectStoragePlan({ + root, + config, + operation: "fresh", + resources: { cpuCount: 8, totalMemoryBytes: 16 * GIB, freeDiskBytes: 64 * GIB }, + execute: async (spec, io) => { + seen.push(`${spec.command} ${spec.args.join(" ")}`); + if (spec.command === "podman" && spec.args[0] === "info") { + const json = createPodmanInfoJson(); + spec.onStdout?.(json); + io?.stdout?.write?.(json); + return 0; + } + if (spec.command === "podman" && spec.args[0] === "image" && spec.args[1] === "inspect") { + const id = "a".repeat(64) + "\n"; + spec.onStdout?.(id); + io?.stdout?.write?.(id); + return 0; + } + if (spec.command === "podman" && spec.args[0] === "system" && spec.args[1] === "df") { + const df = '{"Type":"Images","Size":"2.1GB"}\n'; + spec.onStdout?.(df); + io?.stdout?.write?.(df); + return 0; + } + return 0; + } + }); + assert.equal(plan.resources.engineCpuCount, 8); + assert.ok(seen.some((cmd) => cmd.startsWith("podman info"))); + assert.ok(seen.some((cmd) => cmd.startsWith("podman image inspect"))); + assert.ok(!seen.some((cmd) => cmd.startsWith("docker "))); + } finally { + await rm(root, { recursive: true, force: true }); + } +}); + +test("CLI install --engine podman, workspace add, lifecycle commands, doctor, and support-bundle use Podman end-to-end", async () => { + const root = await mkdtemp(join(tmpdir(), "spaceapp-podman-e2e-")); + const wsDir = join(root, "my-project"); + const answersPath = join(root, "answers.json"); + await mkdir(wsDir, { recursive: true }); + await writeFile(answersPath, JSON.stringify({ + profile: "small", + access: "isolated", + companions: false, + telemetry: false, + open: false, + confirm: true + }), "utf8"); + const testEnv = { ...process.env, SPACEAPP_HOME: root, SPACEAPP_IMAGE_VERIFY: "off" }; + try { + const commands = []; + const podmanExecute = async (spec, io) => { + commands.push(`${spec.command} ${spec.args.join(" ")}`); + if (spec.command === "podman" && spec.args[0] === "--version") { + spec.onStdout?.("podman version 5.3.1\n"); + io?.stdout?.write?.("podman version 5.3.1\n"); + return 0; + } + if (spec.command === "podman" && spec.args[0] === "compose" && spec.args[1] === "version") { + spec.onStdout?.("podman-compose version 1.2.0\n"); + io?.stdout?.write?.("podman-compose version 1.2.0\n"); + return 0; + } + if (spec.command === "podman" && spec.args[0] === "info") { + const json = createPodmanInfoJson({ + rootless: true, + cgroupVersion: "v2", + cgroupControllers: ["cpu", "pids"] // memory omitted to test cgroup delegation warning + }); + spec.onStdout?.(json); + io?.stdout?.write?.(json); + return 0; + } + if (spec.command === "podman" && spec.args.includes("owner-setup-token")) { + const tok = "setup-token-podman-1234567890abcdef123456\n"; + spec.onStdout?.(tok); + io?.stdout?.write?.(tok); + return 0; + } + return 0; + }; + + const installIo = createIo(); + const installCode = await run(["--non-interactive", "--answers", answersPath, "install", "--engine", "podman", "--profile", "small", "--no-open"], { + env: testEnv, + platform: "linux", + stdin: installIo.stdin, + stdout: installIo.stdout, + stderr: installIo.stderr, + inspectResources: abundantResources, + execute: podmanExecute, + request: async () => new Response(JSON.stringify({ ok: true, setupRequired: true, expiresAt: "2099-07-23T12:15:00.000Z" }), { status: 200, headers: { "content-type": "application/json" } }), + sleep: async () => {} + }); + assert.equal(installCode, 0, installIo.err); + const savedConfig = await loadConfig(root); + assert.equal(savedConfig.engine, "podman"); + assert.ok(commands.every((cmd) => !cmd.startsWith("docker ")), `Unexpected docker command: ${commands.join(", ")}`); + assert.ok(commands.some((cmd) => cmd.startsWith("podman compose "))); + assert.match(installIo.out, /cgroup v2.*memory/i); + + // Workspace add should render SELinux :z and keep-id and skip sudo setfacl hint on rootless Podman + const wsIo = createIo(); + const wsCode = await run(["workspace", "add", wsDir], { + env: testEnv, + platform: "linux", + stdin: wsIo.stdin, + stdout: wsIo.stdout, + stderr: wsIo.stderr, + execute: podmanExecute + }); + assert.equal(wsCode, 0, wsIo.err); + assert.doesNotMatch(wsIo.out, /sudo setfacl/); + + // Doctor should report Podman CLI, Podman Compose, and Podman Engine (rootless + cgroup v2 warning) + const doctorIo = createIo(); + const doctorCode = await run(["doctor"], { + env: testEnv, + platform: "linux", + stdin: doctorIo.stdin, + stdout: doctorIo.stdout, + stderr: doctorIo.stderr, + inspectResources: abundantResources, + execute: podmanExecute + }); + assert.equal(doctorCode, 0, doctorIo.err); + assert.match(doctorIo.out, /PASS Podman CLI: available \(5\.3\.1\)/); + assert.match(doctorIo.out, /PASS Podman Compose: available/); + assert.match(doctorIo.out, /PASS Podman Engine: available \(rootless, warning: cgroup v2 cpu\/memory controllers not delegated\)/); + + // Support bundle should record engine: "podman" and rootless status + const bundlePath = join(root, "bundle.json"); + const bundleIo = createIo(); + const bundleCode = await run(["support-bundle", "--output", bundlePath], { + env: testEnv, + platform: "linux", + stdin: bundleIo.stdin, + stdout: bundleIo.stdout, + stderr: bundleIo.stderr, + inspectResources: abundantResources, + execute: podmanExecute, + request: async () => new Response(JSON.stringify({ ok: true, setupRequired: false, expiresAt: "2099-07-23T12:15:00.000Z" }), { status: 200, headers: { "content-type": "application/json" } }) + }); + assert.equal(bundleCode, 0, bundleIo.err); + const bundle = JSON.parse(await readFile(bundlePath, "utf8")); + assert.equal(bundle.installation.config.engine, "podman"); + assert.equal(bundle.engine.type, "podman"); + assert.equal(bundle.engine.rootless, true); + assert.equal(bundle.engine.cgroupVersion, "v2"); + assert.equal(bundle.engine.cgroupDelegated, false); + assert.equal(bundle.engine.majorVersion, 5); + + // Switching engines on an existing installation via install --engine docker or reinstall --engine docker is refused + const switchInstallIo = createIo(); + const switchInstallCode = await run(["install", "--engine", "docker", "--no-open"], { + env: testEnv, + platform: "linux", + stdin: switchInstallIo.stdin, + stdout: switchInstallIo.stdout, + stderr: switchInstallIo.stderr, + inspectResources: abundantResources, + execute: podmanExecute + }); + assert.equal(switchInstallCode, 1); + assert.match(switchInstallIo.err, /Switching container engines on an existing installation \(podman -> docker\) is refused/i); + + const switchReinstallIo = createIo(); + const switchReinstallCode = await run(["reinstall", "--engine", "docker"], { + env: testEnv, + platform: "linux", + stdin: switchReinstallIo.stdin, + stdout: switchReinstallIo.stdout, + stderr: switchReinstallIo.stderr, + inspectResources: abundantResources, + execute: podmanExecute + }); + assert.equal(switchReinstallCode, 1); + assert.match(switchReinstallIo.err, /Switching container engines on an existing installation \(podman -> docker\) is refused/i); + } finally { + await rm(root, { recursive: true, force: true }); + } +}); + +test("composeFileForEngine drops no-new-privileges only on the Podman CLI service", async () => { + const template = await readFile(new URL("../templates/compose.yml", import.meta.url), "utf8"); + assert.equal(composeFileForEngine(template, "docker"), template); + const podmanTemplate = composeFileForEngine(template, "podman"); + const cliSection = podmanTemplate.split(" codex-sessions-init:")[0].split(" spaceapp-cli:")[1]; + assert.doesNotMatch(cliSection, /no-new-privileges/); + assert.match(podmanTemplate, /no-new-privileges:true/); + assert.match(podmanTemplate, /gosu/); +}); + +test("podman machine inspect values and profile resize advice", () => { + const bytes = podmanMachineResources({ Resources: { CPUs: 2, Memory: 4 * GIB, DiskSize: 20 * GIB, }, State: "running" }); + assert.equal(bytes.cpuCount, 2); + assert.equal(bytes.memoryBytes, 4 * GIB); + assert.equal(bytes.diskBytes, 20 * GIB); + const units = podmanMachineResources([{ Resources: { CPUs: 8, Memory: 8192, DiskSize: 30 } }]); + assert.equal(units.memoryBytes, 8192 * 1024 ** 2); + assert.equal(units.diskBytes, 30 * GIB); + const advice = podmanMachineResizeAdvice(bytes, "small"); + assert.match(advice, /too small for the small profile/); + assert.match(advice, /podman machine set --cpus 4 --memory \d+/); + assert.doesNotMatch(advice, /--disk-size/); + assert.equal(podmanMachineResizeAdvice(units, "small"), null); +}); + +test("ensurePodmanAvailable refuses Windows and an undersized macOS Podman machine", async () => { + const windows = createIo(); + const refused = await ensurePodmanAvailable({ + platform: "win32", + stdout: windows.stdout, + stderr: windows.stderr, + execute: async () => 0 + }); + assert.equal(refused.code, 1); + assert.match(windows.err, /not supported on Windows/i); + + const mac = createIo(); + const result = await ensurePodmanAvailable({ + platform: "darwin", + stdout: mac.stdout, + stderr: mac.stderr, + installArgs: { requestedProfile: "large" }, + execute: async (spec) => { + if (spec.args?.[0] === "--version") { + spec.onStdout?.("podman version 5.4.0\n"); + return 0; + } + if (spec.args?.[0] === "compose") return 0; + if (spec.args?.[0] === "info") { + spec.onStdout?.(createPodmanInfoJson({ cpus: 8, memTotal: 16 * GIB, version: "5.4.0" })); + return 0; + } + if (spec.args?.[0] === "machine") { + spec.onStdout?.(JSON.stringify([{ Resources: { CPUs: 4, Memory: 8192, DiskSize: 10 } }])); + return 0; + } + return 0; + } + }); + assert.equal(result.code, 1); + assert.match(mac.err, /too small for the large profile/); + assert.match(mac.err, /--cpus 8/); + assert.match(mac.err, /--disk-size/); +}); + +test("install accepts engine podman from --answers when Docker is also available", async () => { + const root = await mkdtemp(join(tmpdir(), "spaceapp-podman-answers-")); + const answersPath = join(root, "answers.json"); + await writeFile(answersPath, JSON.stringify({ + engine: "podman", + profile: "small", + access: "isolated", + companions: false, + telemetry: false, + open: false, + confirm: true + }), "utf8"); + const commands = []; + try { + const io = createIo(); + const code = await run(["--non-interactive", "--answers", answersPath, "install", "--profile", "small", "--no-open"], { + env: { ...process.env, SPACEAPP_HOME: root, SPACEAPP_IMAGE_VERIFY: "off" }, + platform: "linux", + stdin: io.stdin, + stdout: io.stdout, + stderr: io.stderr, + inspectResources: abundantResources, + execute: async (spec, execIo) => { + commands.push(spec.command); + if (spec.command === "docker") return 0; + if (spec.command === "podman" && spec.args[0] === "--version") { + spec.onStdout?.("podman version 5.3.1\n"); + execIo?.stdout?.write?.("podman version 5.3.1\n"); + return 0; + } + if (spec.command === "podman" && spec.args[0] === "info") { + const json = createPodmanInfoJson(); + spec.onStdout?.(json); + execIo?.stdout?.write?.(json); + return 0; + } + return 0; + }, + request: async () => new Response(JSON.stringify({ ok: true, setupRequired: true, expiresAt: "2099-07-23T12:15:00.000Z" }), { status: 200, headers: { "content-type": "application/json" } }), + sleep: async () => {} + }); + assert.equal(code, 0, io.err); + assert.equal((await loadConfig(root)).engine, "podman"); + assert.equal(commands.includes("docker"), false); + assert.equal(commands.includes("podman"), true); + } finally { + await rm(root, { 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,