diff --git a/.github/workflows/platform-matrix.yml b/.github/workflows/platform-matrix.yml index e09ecb53..206e37c8 100644 --- a/.github/workflows/platform-matrix.yml +++ b/.github/workflows/platform-matrix.yml @@ -57,3 +57,41 @@ jobs: - run: npm run check -w run-spaceapp - run: npm run pack:dry-run -w run-spaceapp - run: node packages/run-spaceapp/bin/spaceapp.mjs --version + + podman-rootless: + name: Rootless Podman E2E / 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 dependencies + run: npm ci + + - name: Install SpaceApp with Podman + run: >- + node packages/run-spaceapp/bin/spaceapp.mjs install --engine podman --profile small --non-interactive + --answers '{"confirm":true,"profile":"small","access":"isolated","companions":false,"telemetry":false,"open":false}' + + - name: Run doctor check + run: node packages/run-spaceapp/bin/spaceapp.mjs doctor + + - name: Verify /readyz endpoint + run: curl --fail --retry 15 --retry-delay 2 http://127.0.0.1:4911/readyz + + - name: Test backup + run: node packages/run-spaceapp/bin/spaceapp.mjs backup + + - name: Test uninstall and purge + run: echo DELETE | node packages/run-spaceapp/bin/spaceapp.mjs uninstall --purge-data + + - name: Ensure no containers running + run: test -z "$(podman ps -q)" + diff --git a/README.md b/README.md index 1e314e3d..143de803 100644 --- a/README.md +++ b/README.md @@ -92,7 +92,7 @@ Coordinate multi-step tasks conversationally through the Room Agent or speak nat - **Hardware**: - Minimum (`small` profile): 4 CPU cores, 8 GB-class RAM (at least 7 GiB reported by the operating system), 6.5 GiB free disk. - `large` profile (adds the managed browser): 8 CPU cores, 15 GiB RAM, 11 GiB free disk. -- **Docker**: If Docker is not installed, the installer automatically installs official Docker Desktop (Windows/macOS) or Docker Engine (Linux). +- **Container Engine**: Docker by default, or rootless Podman (Podman ≥ 5 with compose provider) on Linux and macOS (`--engine podman`). If Docker is not installed, the installer automatically installs official Docker Desktop (Windows/macOS) or Docker Engine (Linux). If only Podman is present on Linux, SpaceApp offers Podman instead of installing Docker. ### Run the Installation Command diff --git a/docs/getting-started.md b/docs/getting-started.md index 68ac684c..f5f2222f 100644 --- a/docs/getting-started.md +++ b/docs/getting-started.md @@ -53,6 +53,18 @@ its code signature, installs it, starts it, and waits until it is ready. Docker Desktop uses its managed lightweight Linux environment. Apple Silicon uses the native `linux/arm64` images and Intel Macs use `linux/amd64`. +## Rootless Podman (Linux & macOS) + +SpaceApp supports rootless Podman as an alternative container engine on Linux and macOS: + +```bash +npx --yes run-spaceapp@latest install --engine podman --profile small +``` + +- **Linux**: Requires Podman ≥ 5 with compose provider (`podman-compose` or `docker-compose` v2) and cgroup v2. All workspace bind mounts automatically apply SELinux `:z` volume flags, allowing seamless read-write access on SELinux enforcing distributions like Fedora and RHEL. If Docker is missing and Podman is present on Linux, the launcher offers Podman (or picks it with `--non-interactive`) instead of installing Docker. +- **macOS**: Runs via `podman machine`. SpaceApp verifies that the VM has sufficient CPU cores, memory, and disk space for the chosen profile, and prints resize instructions (`podman machine set`) if resources need adjusting. +- **Limits**: Container engines cannot be switched on an existing installation without reinstalling (Docker volumes are not shared with Podman). `--access host-root` is not supported with Podman. + ## Windows 11 — Command Prompt Enable hardware virtualization and install Node.js 20.11 or newer. Open diff --git a/packages/run-spaceapp/README.md b/packages/run-spaceapp/README.md index c02e6304..32d627d7 100644 --- a/packages/run-spaceapp/README.md +++ b/packages/run-spaceapp/README.md @@ -62,6 +62,19 @@ before pausing writers. Existing data is kept in place; the launcher never delet images, backups or volumes to free space. Separate Docker disk capacity is checked when measurable and its availability is reported. +### Container Engine: Docker and Rootless Podman + +SpaceApp defaults to Docker (`--engine docker`). On Linux and macOS, you can also run SpaceApp on rootless Podman: + +```bash +npx --yes run-spaceapp@latest install --engine podman --profile small +``` + +- **Linux**: Requires Podman ≥ 5 with compose provider (`podman-compose` or `docker-compose` v2) and cgroup v2. Bind mounts for registered workspaces use SELinux `:z` volume flags for compatibility with SELinux enforcing hosts (e.g. Fedora, RHEL). If Docker is missing and Podman is present on Linux, the launcher offers Podman (or picks it with `--non-interactive`) instead of installing Docker. +- **macOS**: Runs via `podman machine`. The launcher checks machine sizing against profile requirements and advises resize commands if resources are insufficient. +- **Data Safety & Limits**: Switching container engines on an existing installation is blocked to protect persistent volumes. `--access host-root` is not supported on Podman. +- **Diagnostics**: `spaceapp doctor` reports engine details, rootless status, cgroup version, and resource limits delegation. `spaceapp support-bundle` includes engine facts without secrets. + ### Telemetry Telemetry is off unless you turn it on. The install wizard lists what is sent diff --git a/packages/run-spaceapp/src/cli.mjs b/packages/run-spaceapp/src/cli.mjs index 8effe0d8..eacefb11 100644 --- a/packages/run-spaceapp/src/cli.mjs +++ b/packages/run-spaceapp/src/cli.mjs @@ -3,6 +3,7 @@ import { createHash, randomBytes, randomUUID } from "node:crypto"; import { createWriteStream } from "node:fs"; import { createInterface } from "node:readline"; import { once } from "node:events"; +import { Writable } from "node:stream"; import { PromptController } from "./prompt-controller.mjs"; import { copyFile, @@ -46,6 +47,8 @@ import { import { inspectStoragePlan, storageChecks } from "./storage.mjs"; import { ensureDockerAvailable, + dockerIsReady, + podmanIsReady, prepareDockerCliPath, windowsPowerShellArgs } from "./prerequisites.mjs"; @@ -74,6 +77,7 @@ const trustedCommands = new Set([ "docker", "hdiutil", "open", + "podman", "powershell.exe", "sg", "shutdown.exe", @@ -101,7 +105,7 @@ export function writeWindowsCredentialHint(platform, stderr) { } export async function withHeadlessDockerConfig(platform, spec, run) { - if (platform !== "win32") { + if (platform !== "win32" || spec.command !== "docker") { return run(spec); } const configDir = await mkdtemp(join(tmpdir(), "spaceapp-docker-config-")); @@ -334,7 +338,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; @@ -423,7 +427,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"); @@ -436,7 +440,7 @@ export async function run(argv, { } if (command === "doctor") { assertNoArgs(args, "doctor"); - return doctor({ root, platform, stdout, stderr, execute, stdin, inspectResources }); + return doctor({ root, platform, stdout, stderr, execute, stdin, inspectResources, engine: config.engine }); } if (command === "support-bundle") { let outPath = null; @@ -505,14 +509,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 ?? config?.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 ?? config?.engine }), { stdin, stdout, stderr }); if (upCode !== 0) { await writeRuntimeFiles(root, config); return upCode; @@ -523,7 +527,7 @@ export async function run(argv, { } if (command === "backup") { assertNoArgs(args, command); - return runtimeExecute(composeCommand("backup", root, { profile: config.profile, companionsEnabled: config.companionsEnabled }), { stdin, stdout, stderr }); + return runtimeExecute(composeCommand("backup", root, { profile: config.profile, companionsEnabled: config.companionsEnabled, engine: config.engine }), { stdin, stdout, stderr }); } if (command === "restore") { assertNoArgs(args, command); @@ -537,61 +541,61 @@ export async function run(argv, { throw new Error("Restore cancelled."); } const backupId = await selectLatestBackupId(root); - const backupCode = await runtimeExecute(composeCommand("backup", root, { profile: config.profile, companionsEnabled: config.companionsEnabled }), { stdin, stdout, stderr }); + const backupCode = await runtimeExecute(composeCommand("backup", root, { profile: config.profile, companionsEnabled: config.companionsEnabled, engine: config.engine }), { stdin, stdout, stderr }); if (backupCode !== 0) return backupCode; - const stopCode = await runtimeExecute(composeCommand("stopForRestore", root, { profile: config.profile, companionsEnabled: config.companionsEnabled }), { stdin, stdout, stderr }); + const stopCode = await runtimeExecute(composeCommand("stopForRestore", root, { profile: config.profile, companionsEnabled: config.companionsEnabled, engine: config.engine }), { stdin, stdout, stderr }); if (stopCode !== 0) return stopCode; 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) return restoreCode; - return runtimeExecute(composeCommand("up", root, { profile: config.profile, companionsEnabled: config.companionsEnabled }), { stdin, stdout, stderr }); + return runtimeExecute(composeCommand("up", root, { profile: config.profile, companionsEnabled: config.companionsEnabled, engine: config.engine }), { stdin, stdout, stderr }); } if (command === "uninstall") { if (args.length === 0) { const approved = await requireChangeApproval(stdin, stdout, stderr, [ "Stop and remove SpaceApp containers and network.", "Data, configuration, secrets, and backups remain at the installation root.", - "Docker volumes are retained; the global SpaceApp CLI remains installed." + "Container volumes are retained; the global SpaceApp CLI remains installed." ]); if (!approved) { return 0; } 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) { stdout.write("SpaceApp runtime removed successfully. It is safe to run this command again.\n"); stdout.write(`Data, configuration, secrets, and backups remain at ${root}.\n`); - stdout.write("Docker volumes are retained. The global SpaceApp CLI remains installed.\n"); + stdout.write(`${config.engine === "podman" ? "Podman" : "Docker"} volumes are retained. The global SpaceApp CLI remains installed.\n`); stdout.write("To remove only the global CLI, run: npm uninstall -g run-spaceapp\n"); } else { - stderr.write(`Uninstall could not remove the runtime (Docker exit ${code}).\n`); + stderr.write(`Uninstall could not remove the runtime (${config.engine === "podman" ? "Podman" : "Docker"} exit ${code}).\n`); stdout.write(`Data, configuration, secrets, and backups remain at ${root}.\n`); stdout.write("The global SpaceApp CLI remains installed.\n"); } return code; } if (args.length === 1 && args[0] === "--purge-data") { - const confirmation = await readSecret(stdin, stdout, "Type DELETE to remove Docker volumes: ", { mask: false }); + const confirmation = await readSecret(stdin, stdout, `Type DELETE to remove ${config.engine === "podman" ? "Podman" : "Docker"} volumes: `, { mask: false }); if (confirmation !== "DELETE") { throw new Error("Purge cancelled."); } - stdout.write("Removing SpaceApp containers, network, and Docker volumes...\n"); + stdout.write(`Removing SpaceApp containers, network, and ${config.engine === "podman" ? "Podman" : "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) { - stdout.write("SpaceApp runtime and Docker volumes removed successfully.\n"); + stdout.write(`SpaceApp runtime and ${config.engine === "podman" ? "Podman" : "Docker"} volumes removed successfully.\n`); stdout.write(`Host configuration and backups remain at ${root} for manual review.\n`); stdout.write("The global SpaceApp CLI remains installed.\n"); stdout.write("To remove only the global CLI, run: npm uninstall -g run-spaceapp\n"); } else { - stderr.write(`SpaceApp Docker volume purge failed (Docker exit ${code}).\n`); + stderr.write(`SpaceApp volume purge failed (${config.engine === "podman" ? "Podman" : "Docker"} exit ${code}).\n`); stdout.write(`Host files and the global SpaceApp CLI remain at ${root}.\n`); } return code; @@ -638,6 +642,7 @@ async function installCommand(args, { const { requestedProfile, requestedAccessMode, + requestedEngine, noOpen, companionsEnabled, telemetryEnabled, @@ -669,6 +674,7 @@ async function installCommand(args, { version: runtimeVersion, profile, accessMode, + engine: requestedEngine, ...(companionsEnabled ? { companionsEnabled } : {}) }); // The stored choice also drives the app's first_answer event after later updates, so an explicit @@ -734,11 +740,23 @@ async function installCommand(args, { stdout, stderr, execute, - installArgs: { root, requestedProfile, requestedAccessMode, noOpen, autoConfirm: true } + engine: result.config.engine, + installArgs: { + root, + requestedProfile, + requestedAccessMode, + requestedEngine: result.config.engine, + noOpen, + autoConfirm: true, + allowPodmanFallback: !wizard.explicitEngine && !existingConfig && result.config.accessMode !== "host-root" + } }); if (prerequisiteResult.reexecuted) { return prerequisiteResult.code; } + if (prerequisiteResult.engine === "podman") { + result.config.engine = "podman"; + } if (prerequisiteResult.code !== 0) { stderr.write( `Installation stopped before downloading images. Fix the failed checks and run "${UNIVERSAL_COMMAND} install" again.\n` @@ -768,7 +786,10 @@ async function installCommand(args, { inspectResources, resources, profile, - dockerReady: true, operation, storagePlan + engine: result.config.engine, + dockerReady: result.config.engine === "docker", + operation, + storagePlan }); if (doctorCode !== 0) { stderr.write( @@ -790,7 +811,8 @@ async function installCommand(args, { stdin, stdout, stderr, - execute + execute, + engine: result.config.engine }); if (upgradeCheckpoint) { await retainFailedUpgrade(root, upgradeCheckpoint, @@ -800,6 +822,7 @@ async function installCommand(args, { stagedStateRoot, existingConfig, attemptedProfile: profile, + attemptedEngine: result.config.engine, companionsEnabled, platform, stdin, @@ -819,6 +842,7 @@ async function installCommand(args, { profile, stateRoot: stagedStateRoot, companionsEnabled, + engine: result.config.engine, ...options }); const pullCode = await withHeadlessDockerConfig( @@ -1011,7 +1035,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; @@ -1054,6 +1078,9 @@ async function planInteractiveSetup({ return { resolved, display: choice === "auto" ? `auto (${resolved})` : resolved }; }; + const answersEngine = stdin?._spaceappPromptController?.answers?.engine; + let resolvedEngine = parsed.requestedEngine || continuation?.actions?.engine || rawExisting?.engine || answersEngine || "docker"; + if (continuation) { await consumeRunOnceContinuation(root); stdout.write("Continuing the approved unattended SpaceApp setup plan.\n"); @@ -1062,6 +1089,8 @@ async function planInteractiveSetup({ return { requestedProfile: actions.profile ?? "auto", requestedAccessMode: actions.accessMode, + requestedEngine: actions.engine ?? "docker", + explicitEngine: Boolean(actions.engine), noOpen: actions.noOpen !== false, companionsEnabled: actions.companionsEnabled ?? false, telemetryEnabled: actions.telemetry ?? false, @@ -1105,6 +1134,7 @@ async function planInteractiveSetup({ `Installation root: ${root}`, `Runtime image version: not installed -> ${runtimeVersion}`, `Profile: ${profileChoice === "auto" ? `auto (${resolved})` : profileChoice}`, + `Container engine: ${resolvedEngine}`, `Access mode: ${accessChoice}`, `Companions: ${companionsChoice ? "enabled" : "disabled"}`, `Telemetry: ${telemetryChoice ? "enabled" : "disabled"}`, @@ -1119,6 +1149,7 @@ async function planInteractiveSetup({ await offerUnattendedContinuation(root, runtimeVersion, stdin, stdout, { profile: profileChoice, accessMode: accessChoice, + engine: resolvedEngine, companionsEnabled: companionsChoice, telemetry: telemetryChoice, installId, @@ -1128,6 +1159,8 @@ async function planInteractiveSetup({ return { requestedProfile: profileChoice, requestedAccessMode: accessChoice, + requestedEngine: resolvedEngine, + explicitEngine: Boolean(parsed.requestedEngine), noOpen: !openBrowserChoice, companionsEnabled: companionsChoice, telemetryEnabled: telemetryChoice, @@ -1205,6 +1238,8 @@ async function planInteractiveSetup({ return { requestedProfile: parsed.requestedProfile, requestedAccessMode: parsed.requestedAccessMode, + requestedEngine: parsed.requestedEngine || rawExisting?.engine || "docker", + explicitEngine: Boolean(parsed.requestedEngine), noOpen: parsed.noOpen, companionsEnabled: parsed.companionsEnabled, telemetryEnabled: false @@ -1227,6 +1262,12 @@ function hasRequestedConfigChange(rawExisting, parsed) { if (parsed.requestedProfile !== "auto" && rawExisting.profile !== parsed.requestedProfile) { return true; } + if ( + parsed.requestedEngine !== undefined && + (rawExisting.engine ?? "docker") !== parsed.requestedEngine + ) { + return true; + } return false; } @@ -1278,7 +1319,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}}); + engine: config?.engine ?? repaired.engine, + installArgs: {root,requestedProfile:repaired.profile,requestedAccessMode:repaired.accessMode,noOpen:true,autoConfirm:true,requestedEngine:config?.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; @@ -1286,7 +1328,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) { @@ -1295,7 +1337,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) { @@ -1360,7 +1402,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 ?? config.engine }), { stdin, stdout, stderr } ); if (upCode !== 0) { @@ -1482,7 +1524,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 { @@ -1568,7 +1610,8 @@ async function reportInstallDiagnostics({ stdin, stdout, stderr, - execute + execute, + engine = "docker" }) { stderr.write("Collecting SpaceApp service status and recent logs before rollback...\n"); for (const action of ["status", "logs"]) { @@ -1578,7 +1621,8 @@ async function reportInstallDiagnostics({ composeCommand(action, root, { profile, stateRoot, - lines: 200 + lines: 200, + engine }), { stdin, stdout, stderr }, { platform, stderr } @@ -1601,6 +1645,7 @@ async function restoreRuntimeAfterFailedInstall({ stagedStateRoot, existingConfig, attemptedProfile, + attemptedEngine = "docker", companionsEnabled, platform, stdin, @@ -1620,7 +1665,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) { @@ -1639,7 +1685,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 ?? attemptedEngine }) ); if (stopCode !== 0) { @@ -1657,6 +1704,7 @@ async function restoreRuntimeAfterFailedInstall({ function parseInstallArgs(args) { let requestedProfile = "auto"; let requestedAccessMode; + let requestedEngine; let noOpen = false; let companionsEnabled = false; for (let index = 0; index < args.length; index += 1) { @@ -1687,8 +1735,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 ( @@ -1696,13 +1753,20 @@ 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 }; + if (requestedEngine === "podman" && requestedAccessMode === "host-root") { + throw new Error("--access host-root is not supported on Podman. Use --access isolated."); + } + return { requestedProfile, requestedAccessMode, requestedEngine, noOpen, companionsEnabled }; } async function loadExistingInstallation(root) { @@ -1764,7 +1828,7 @@ async function credentialsCommand(args, { root, config, stdin, stdout, stderr, e const value = await readSecret(stdin, stdout, `Enter ${provider} credential: `); await writeCredential(root, provider, value); const syncCode = await execute( - composeCommand("syncCredentials", root, { profile: config.profile, companionsEnabled: config.companionsEnabled }), + composeCommand("syncCredentials", root, { profile: config.profile, companionsEnabled: config.companionsEnabled, engine: config.engine }), { stdin, stdout, stderr } ); if (syncCode !== 0) { @@ -1780,7 +1844,7 @@ async function credentialsCommand(args, { root, config, stdin, stdout, stderr, e } const removed = await removeCredential(root, provider); const syncCode = await execute( - composeCommand("syncCredentials", root, { profile: config.profile, companionsEnabled: config.companionsEnabled }), + composeCommand("syncCredentials", root, { profile: config.profile, companionsEnabled: config.companionsEnabled, engine: config.engine }), { stdin, stdout, stderr } ); if (syncCode !== 0) { @@ -1801,13 +1865,13 @@ async function providerCommand(args, { root, config, stdin, stdout, stderr, exec stdout.write(`Installing optional provider ${provider} into 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 }), { stdin, stdout, stderr }); + return execute(composeCommand(action, root, { profile: config.profile, companionsEnabled: config.companionsEnabled, provider, engine: config.engine }), { 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, @@ -1828,7 +1892,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, @@ -1856,7 +1920,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) { @@ -1864,7 +1928,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) { @@ -1921,6 +1985,7 @@ async function supportBundleCommand({ version: config.version, profile: config.profile, accessMode: config.accessMode, + engine: config.engine ?? "docker", companionsEnabled: config.companionsEnabled, bindHost: config.bindHost, port: config.port, @@ -1928,10 +1993,51 @@ async function supportBundleCommand({ } : null }, + engine: { + name: config?.engine ?? "docker", + version: null, + rootless: null, + cgroupVersion: null, + limitsDelegated: null + }, services: {}, health: {} }; + const engineName = config?.engine ?? "docker"; + if (typeof execute === "function") { + try { + let infoOutput = ""; + const infoStdout = new Writable({ + write(chunk, _encoding, done) { + if (infoOutput.length < 1024 * 1024) infoOutput += chunk.toString(); + done(); + } + }); + const infoCode = await execute( + { command: engineName, args: ["info", "--format", "{{json .}}"] }, + { stdin: null, stdout: infoStdout, stderr: null } + ); + if (infoCode === 0) { + const parsed = JSON.parse(infoOutput); + if (engineName === "podman") { + bundle.engine.version = parsed?.version?.Version ?? null; + bundle.engine.rootless = typeof parsed?.host?.security?.rootless === "boolean" + ? parsed.host.security.rootless + : null; + bundle.engine.cgroupVersion = parsed?.host?.cgroupVersion || "v2"; + const controllers = parsed?.host?.cgroupControllers || []; + bundle.engine.limitsDelegated = bundle.engine.cgroupVersion === "v2" && (bundle.engine.rootless === false || (controllers.includes("cpu") && controllers.includes("memory"))); + } else { + bundle.engine.version = parsed?.ServerVersion ?? null; + bundle.engine.rootless = parsed?.SecurityOptions?.some((s) => typeof s === "string" && s.toLowerCase().includes("rootless")) ?? false; + bundle.engine.cgroupVersion = parsed?.CgroupVersion || "v2"; + bundle.engine.limitsDelegated = true; + } + } + } catch {} + } + if (config) { const url = `http://${config.bindHost}:${config.port}`; if (typeof request === "function") { @@ -1971,7 +2077,7 @@ async function factoryResetCommand({ try { stdout.write("Creating safety backup before factory reset...\n"); backupCode = await execute( - composeCommand("backup", root, { profile: config.profile, companionsEnabled: config.companionsEnabled }), + composeCommand("backup", root, { profile: config.profile, companionsEnabled: config.companionsEnabled, engine: config.engine }), { stdin, stdout, stderr } ); } catch (err) { @@ -1999,7 +2105,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) { @@ -2013,7 +2119,7 @@ async function factoryResetCommand({ await rm(join(root, "compose.yml"), { force: true }); } catch {} - const result = await initializeInstallation(root, { version: runtimeVersion }); + const result = await initializeInstallation(root, { version: runtimeVersion, engine: config?.engine }); stdout.write(`SpaceApp factory reset complete. Clean installation initialized at ${root}\n`); if (result.setupToken) { stdout.write(`One-time setup token: ${result.setupToken}\n`); @@ -2096,7 +2202,8 @@ 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 } }); + engine: config.engine, + installArgs: { root, requestedProfile: config.profile, noOpen: true, autoConfirm: true, requestedEngine: 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" }); @@ -2142,19 +2249,26 @@ 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 detectedResources = resources ?? await inspectResources(root); let resolvedProfile = profile; - if (!resolvedProfile) { + let resolvedEngine = engine; + if (!resolvedProfile || !resolvedEngine) { const existingConfig = await loadConfig(root).catch(() => null); - if (existingConfig?.profile) { - resolvedProfile = existingConfig.profile; - } else if (detectedResources?.totalMemoryBytes) { - resolvedProfile = resolveInstallProfile("auto", detectedResources); - } else { - resolvedProfile = "light"; + if (!resolvedProfile) { + if (existingConfig?.profile) { + resolvedProfile = existingConfig.profile; + } else if (detectedResources?.totalMemoryBytes) { + resolvedProfile = resolveInstallProfile("auto", detectedResources); + } else { + resolvedProfile = "light"; + } + } + if (!resolvedEngine) { + resolvedEngine = existingConfig?.engine ?? "docker"; } } operation ??= await loadConfig(root).then(() => "maintenance").catch(() => "fresh"); @@ -2165,8 +2279,95 @@ async function doctor({ { name: "Node.js", ok: Number(process.versions.node.split(".")[0]) >= 20, detail: process.version }, { name: "Configuration", ...configuration }, ...resourceChecks.filter((check) => !storagePlan || check.name !== "Free disk"), - ...(storagePlan ? storageChecks(storagePlan, detectedResources) : []) + ...(storagePlan ? storageChecks(storagePlan, detectedResources, { engine: resolvedEngine }) : []) ]; + if (resolvedEngine === "podman") { + const podmanResults = []; + let podmanInfoJson = null; + for (const probe of [ + { name: "Podman CLI", command: "podman", args: ["--version"] }, + { name: "Podman Compose", command: "podman", args: ["compose", "version"] }, + { name: "Podman Engine", command: "podman", args: ["info", "--format", "{{json .}}"] } + ]) { + let output = ""; + const probeStdout = new Writable({ + write(chunk, _encoding, done) { + if (output.length < 1024 * 1024) output += chunk.toString(); + done(); + } + }); + const code = await execute(probe, { stdin, stdout: probeStdout, stderr: null }); + if (probe.name === "Podman Engine" && code === 0) { + try { podmanInfoJson = JSON.parse(output); } catch {} + } + podmanResults.push({ ...probe, code }); + } + const [podmanCli, podmanCompose, podmanEngine] = podmanResults; + checks.push({ + name: podmanCli.name, + ok: podmanCli.code === 0, + detail: podmanCli.code === 0 + ? "available" + : podmanCli.code === 127 + ? "not found on PATH" + : `unavailable (exit ${podmanCli.code})` + }); + checks.push({ + name: podmanCompose.name, + ok: podmanCompose.code === 0, + detail: podmanCompose.code === 0 + ? "available" + : podmanCli.code !== 0 + ? "not available because Podman CLI is missing" + : `compose provider unavailable (exit ${podmanCompose.code})` + }); + const engineVer = podmanInfoJson?.version?.Version || podmanInfoJson?.Version?.Version; + checks.push({ + name: podmanEngine.name, + ok: podmanEngine.code === 0, + detail: podmanEngine.code === 0 + ? (engineVer ? `available (${engineVer})` : "available") + : podmanCli.code === 0 + ? "installed but not running or inaccessible" + : "not reachable because Podman CLI is missing" + }); + if (podmanEngine.code === 0) { + const rootless = podmanInfoJson?.host?.security?.rootless === true; + const cgroupVersion = podmanInfoJson?.host?.cgroupVersion || "v2"; + const controllers = podmanInfoJson?.host?.cgroupControllers || []; + checks.push({ + name: "Podman rootless mode", + ok: rootless, + detail: rootless + ? "rootless" + : podmanInfoJson?.host?.security?.rootless === false + ? "rootful (SpaceApp requires rootless Podman)" + : "unverified (host.security.rootless missing)" + }); + checks.push({ + name: "cgroup version", + ok: cgroupVersion === "v2", + detail: cgroupVersion === "v2" ? "v2" : "v1 (CPU and memory limits require cgroup v2)" + }); + const limitsOk = cgroupVersion === "v2" && (!rootless || (controllers.includes("cpu") && controllers.includes("memory"))); + checks.push({ + name: "Resource limits delegation", + ok: true, + detail: limitsOk + ? "delegated (CPU and memory limits enforced)" + : "not delegated; CPU and memory limits will not be enforced by the kernel" + }); + } + for (const check of checks) { + (check.ok ? stdout : stderr).write(`${check.ok ? "PASS" : "FAIL"} ${check.name}: ${check.detail}\n`); + } + if (podmanCli.code !== 0 || podmanCompose.code !== 0) { + stderr.write("Podman or podman-compose is missing. Install Podman >= 5 with compose provider.\n"); + } else if (podmanEngine.code !== 0) { + stderr.write("Podman engine is not ready. Start the Podman service or machine.\n"); + } + return checks.every((check) => check.ok) ? 0 : 1; + } const dockerResults = []; for (const probe of [ { name: "Docker CLI", command: "docker", args: ["--version"] }, @@ -2391,6 +2592,7 @@ function spawnTrustedCommand(spec, options) { case "docker": return spawn("docker", args, options); case "hdiutil": return spawn("hdiutil", args, options); case "open": return spawn("open", args, options); + case "podman": return spawn("podman", args, options); case "powershell.exe": return spawn("powershell.exe", windowsPowerShellArgs(spec.operation), options); case "sg": return spawn("sg", args, options); @@ -2494,9 +2696,9 @@ Usage: ${UNIVERSAL_COMMAND} spaceapp Optional global launcher init Create a local SpaceApp installation - install [--profile auto|small|medium|large|light|standard] [--access isolated|host-root] [--with-companions] [--no-open] + install [--engine docker|podman] [--profile auto|small|medium|large|light|standard] [--access isolated|host-root] [--with-companions] [--no-open] Install prerequisites, initialize, and start - reinstall [--keep-data] [--profile auto|small|medium|large|light|standard] [--access isolated|host-root] [--with-companions] [--no-open] + reinstall [--engine docker|podman] [--keep-data] [--profile auto|small|medium|large|light|standard] [--access isolated|host-root] [--with-companions] [--no-open] Clean reinstallation preserving data and credentials repair [--dry-run] Verify and repair configuration and runtime files up | down | status | logs Manage the Docker application diff --git a/packages/run-spaceapp/src/index.mjs b/packages/run-spaceapp/src/index.mjs index a30c1db8..147f7d2c 100644 --- a/packages/run-spaceapp/src/index.mjs +++ b/packages/run-spaceapp/src/index.mjs @@ -111,6 +111,7 @@ const CONFIG_KEYS = new Set([ "telemetry", "profile", "accessMode", + "engine", "workspaces", "companionsEnabled", "optionalModules", @@ -288,14 +289,15 @@ 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); - return { + const config = { schemaVersion: CONFIG_SCHEMA_VERSION, version, previousVersion: null, @@ -308,6 +310,10 @@ export function createDefaultConfig({ optionalModules: "none", workspaces: [] }; + if (engine !== undefined) { + config.engine = engine; + } + return config; } export function validateConfig(config) { @@ -344,6 +350,9 @@ 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 && config.engine !== "docker" && config.engine !== "podman") { + throw new Error("engine must be docker or podman."); + } if (typeof config.companionsEnabled !== "boolean") { throw new Error("companionsEnabled must be boolean."); } @@ -557,6 +566,7 @@ export function renderRuntimeEnv(config) { `SPACEAPP_TELEMETRY=${config.telemetry}`, `SPACEAPP_INSTALL_ID=${config.installId ?? ""}`, `SPACEAPP_PROFILE=${safeEnv(config.profile)}`, + `SPACEAPP_CONTAINER_ENGINE=${config.engine ?? "docker"}`, `SPACEAPP_BROWSER_ENABLED=${settings.browserEnabled}`, `SPACEAPP_WORKFLOWS_ENABLED=${settings.temporalEnabled}`, `SPACEAPP_CORE_MEMORY_LIMIT=${settings.coreMemoryLimit}`, @@ -581,7 +591,11 @@ export function renderWorkspaceCompose(config) { " - type: bind", ` source: ${JSON.stringify(workspace.hostPath)}`, ` target: ${JSON.stringify(workspace.containerPath)}`, - ` read_only: ${workspace.readOnly}` + ` read_only: ${workspace.readOnly}`, + ...(config.engine === "podman" ? [ + " bind:", + " selinux: z" + ] : []) ]); const service = mounts.length > 0 ? [" volumes:", ...mounts] @@ -631,6 +645,7 @@ export function composeCommand(action, root, options = {}) { validateHome(stateRoot); const profile = options.profile ?? "standard"; const settings = profileRuntimeSettings(profile); + const engine = options.engine ?? "docker"; const base = [ "compose", "--project-name", composeProjectName(root), @@ -731,7 +746,7 @@ export function composeCommand(action, root, options = {}) { if (!selected) { throw new Error(`Unsupported Compose action "${action}".`); } - return { command: "docker", args: [...base, ...selected] }; + return { command: engine === "podman" ? "podman" : "docker", args: [...base, ...selected] }; } export function composeProjectName(root) { @@ -768,7 +783,8 @@ export async function prepareInstallation(root, { version, profile, accessMode, - companionsEnabled + companionsEnabled, + engine }) { validateHome(root); await mkdir(root, { recursive: true, mode: 0o700 }); @@ -790,9 +806,16 @@ export async function prepareInstallation(root, { version, profile: resolvedProfile ?? "light", accessMode: resolveInstallAccessMode(accessMode), - companionsEnabled + companionsEnabled, + engine: engine ?? "docker" }); } + const existingEngine = config.engine ?? "docker"; + if (engine && existingEngine !== engine) { + throw new Error( + `Cannot switch container engine from "${existingEngine}" to "${engine}" on an existing installation. Docker volumes are not visible to Podman. To migrate, run "${UNIVERSAL_COMMAND} backup", reinstall with --engine ${engine}, and run "${UNIVERSAL_COMMAND} restore".` + ); + } if (config.version !== version) { config = { ...config, @@ -810,6 +833,12 @@ export async function prepareInstallation(root, { if (companionsEnabled !== undefined && config.companionsEnabled !== companionsEnabled) { config = { ...config, companionsEnabled }; } + if (engine !== undefined && config.engine !== engine) { + config = { ...config, engine }; + } + if (config.engine === "podman" && config.accessMode === "host-root") { + throw new Error("--access host-root is not supported on Podman. Use --access isolated."); + } validateConfig(config); const postgresPasswordPath = join(root, "secrets", "postgres-password"); diff --git a/packages/run-spaceapp/src/prerequisites.mjs b/packages/run-spaceapp/src/prerequisites.mjs index c22d4b5f..90bbfa47 100644 --- a/packages/run-spaceapp/src/prerequisites.mjs +++ b/packages/run-spaceapp/src/prerequisites.mjs @@ -4,11 +4,13 @@ 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 { UNIVERSAL_INSTALL_COMMAND } from "./package-info.mjs"; +import { PROFILE_REQUIREMENTS } from "./index.mjs"; +import { PromptController } from "./prompt-controller.mjs"; const DOCKER_TERMS_URL = "https://www.docker.com/legal/docker-subscription-service-agreement/"; const DOWNLOAD_TIMEOUT_MS = 30 * 60 * 1_000; @@ -119,6 +121,7 @@ export async function prepareDockerCliPath({ } export async function ensureDockerAvailable({ + engine = "docker", platform = process.platform, arch = process.arch, env = process.env, @@ -135,9 +138,56 @@ export async function ensureDockerAvailable({ scheduleResume = scheduleWindowsInstallResume, confirmRestart = confirmWindowsRestart }) { + if (engine === "podman") { + return ensurePodmanAvailable({ + platform, + arch, + env, + stdin, + stdout, + stderr, + execute, + profile: installArgs?.requestedProfile ?? "small", + sleep + }); + } if (await dockerIsReady(execute)) { return { code: 0, reexecuted: false }; } + if (installArgs?.allowPodmanFallback && platform === "linux") { + if (await podmanIsReady(execute)) { + const interactive = Boolean(stdin && !installArgs?.autoConfirm); + let chosenEngine = "podman"; + if (interactive) { + const controller = stdin?._spaceappPromptController || new PromptController({ stdin, stdout }); + chosenEngine = await controller.promptChoice( + "Docker was not detected, but Podman is ready. Which container engine?", + [ + { label: "Podman (rootless container engine)", value: "podman" }, + { label: "Docker (attempt install or start)", value: "docker" } + ], + { defaultIndex: 0, answerKey: "engine" } + ); + } else { + stdout.write("Docker was not detected, but Podman is ready. Using Podman container engine.\n"); + } + if (chosenEngine === "podman") { + if (installArgs) installArgs.requestedEngine = "podman"; + const podmanResult = await ensurePodmanAvailable({ + platform, + arch, + env, + stdin, + stdout, + stderr, + execute, + profile: installArgs?.requestedProfile ?? "small", + sleep + }); + return { ...podmanResult, engine: "podman" }; + } + } + } if (platform === "win32") { return ensureWindowsDocker({ arch, @@ -653,7 +703,7 @@ async function dockerCliAndComposeAreAvailable(execute) { return true; } -async function dockerIsReady(execute) { +export async function dockerIsReady(execute) { for (const spec of [ { command: "docker", args: ["--version"] }, { command: "docker", args: ["compose", "version"] }, @@ -666,6 +716,165 @@ async function dockerIsReady(execute) { return true; } +export async function podmanCliAndComposeAreAvailable(execute) { + for (const spec of [ + { command: "podman", args: ["--version"] }, + { command: "podman", args: ["compose", "version"] } + ]) { + if (await execute(spec, { stdout: null, stderr: null }) !== 0) { + return false; + } + } + return true; +} + +export async function podmanIsReady(execute) { + for (const spec of [ + { command: "podman", args: ["--version"] }, + { command: "podman", args: ["compose", "version"] }, + { command: "podman", args: ["info"] } + ]) { + if (await execute(spec, { stdout: null, stderr: null }) !== 0) { + return false; + } + } + return true; +} + +async function captureProbeOutput(execute, spec) { + let output = ""; + const stdout = new Writable({ + write(chunk, _encoding, done) { + if (output.length < 1024 * 1024) output += chunk.toString(); + done(); + } + }); + try { + const code = await execute({ ...spec, timeoutMs: 15000 }, { stdout, stderr: null }); + return code === 0 ? output.trim() : null; + } catch { + return null; + } +} + +export async function ensurePodmanAvailable({ + platform = process.platform, + arch = process.arch, + env = process.env, + stdin = process.stdin, + stdout = process.stdout, + stderr = process.stderr, + execute, + profile = "small", + sleep = wait +}) { + if (platform === "win32") { + stderr.write("Rootless Podman is not supported on Windows. Please use Docker Desktop or run on Linux/macOS.\n"); + return { code: 1, reexecuted: false }; + } + if (platform === "darwin") { + return ensureMacPodman({ arch, env, stdin, stdout, stderr, execute, profile, sleep }); + } + return ensureLinuxPodman({ arch, env, stdin, stdout, stderr, execute, sleep }); +} + +async function ensureLinuxPodman({ stdin, stdout, stderr, execute, sleep }) { + if (await podmanIsReady(execute)) { + return { code: 0, reexecuted: false }; + } + stderr.write( + "Rootless Podman is required and is not ready.\n" + + "SpaceApp requires Podman >= 5 and podman-compose (or docker-compose v2 as compose provider).\n" + + "To install on Fedora / RHEL: sudo dnf install -y podman podman-compose\n" + + "To install on Ubuntu / Debian: sudo apt-get install -y podman podman-compose\n" + + "To install on Arch Linux: sudo pacman -S podman podman-compose\n" + ); + return { code: 1, reexecuted: false }; +} + +async function ensureMacPodman({ arch, env, stdin, stdout, stderr, execute, profile, sleep }) { + const cliAvailable = await execute({ command: "podman", args: ["--version"] }, { stdout: null, stderr: null }) === 0; + if (!cliAvailable) { + stderr.write( + "Podman CLI is not installed on macOS.\n" + + "Install Podman via Homebrew: brew install podman podman-compose\n" + ); + return { code: 1, reexecuted: false }; + } + const inspectOutput = await captureProbeOutput(execute, { command: "podman", args: ["machine", "inspect", "--format", "{{json .}}"] }); + let machine = null; + try { + const parsed = JSON.parse(inspectOutput); + machine = Array.isArray(parsed) ? parsed[0] : parsed; + } catch {} + if (!machine) { + const listOutput = await captureProbeOutput(execute, { command: "podman", args: ["machine", "list", "--format", "{{json .}}"] }); + try { + const parsed = JSON.parse(listOutput); + machine = Array.isArray(parsed) ? parsed[0] : parsed; + } catch {} + } + const req = PROFILE_REQUIREMENTS[profile] || PROFILE_REQUIREMENTS.small; + const reqCpus = req.cpuCount; + const reqMemMb = Math.ceil(req.memoryBytes / (1024 ** 2)); + const reqDiskGb = Math.ceil(req.freshDiskBytes / (1024 ** 3)); + if (!machine) { + stdout.write( + `No Podman machine found. Creating a machine for profile "${profile}"...\n` + ); + const initCode = await execute({ + command: "podman", + args: ["machine", "init", "--cpus", String(reqCpus), "--memory", String(reqMemMb), "--disk-size", String(reqDiskGb)] + }, { stdin, stdout, stderr }); + if (initCode !== 0) { + stderr.write("Failed to initialize Podman machine.\n"); + return { code: initCode, reexecuted: false }; + } + } + if (await execute({ command: "podman", args: ["info"] }, { stdout: null, stderr: null }) !== 0) { + stdout.write("Starting Podman machine...\n"); + const startCode = await execute({ command: "podman", args: ["machine", "start"] }, { stdin, stdout, stderr }); + if (startCode !== 0) { + stderr.write("Failed to start Podman machine.\n"); + return { code: startCode, reexecuted: false }; + } + } + if (machine) { + const res = machine.Resources ?? {}; + const cpus = Number(res.CPUs ?? machine.CPUs ?? machine.cpus) || 0; + let memoryMb = 0; + if (res.Memory != null) { + memoryMb = Number(res.Memory) || 0; + } else if (machine.Memory) { + memoryMb = machine.Memory > 1000000 ? Math.round(machine.Memory / (1024 ** 2)) : machine.Memory; + } else if (machine.memory) { + memoryMb = machine.memory; + } + let diskGb = 0; + if (res.DiskSize != null) { + diskGb = Number(res.DiskSize) || 0; + } else if (machine.DiskSize) { + diskGb = machine.DiskSize > 1000000 ? Math.round(machine.DiskSize / (1024 ** 3)) : machine.DiskSize; + } else if (machine.diskSize) { + diskGb = machine.diskSize; + } + if ((cpus > 0 && cpus < reqCpus) || (memoryMb > 0 && memoryMb < reqMemMb) || (diskGb > 0 && diskGb < reqDiskGb)) { + stdout.write( + `Warning: Podman machine resources (${cpus} CPUs, ${memoryMb} MiB RAM, ${diskGb} GiB disk) may be below requirements for profile "${profile}" (${reqCpus} CPUs, ${reqMemMb} MiB RAM, ${reqDiskGb} GiB disk).\n` + + "To resize your Podman machine, run:\n" + + " podman machine stop\n" + + ` podman machine set --cpus ${reqCpus} --memory ${reqMemMb} --disk-size ${reqDiskGb}\n` + + " podman machine start\n" + ); + } + } + if (await podmanIsReady(execute)) { + return { code: 0, reexecuted: false }; + } + stderr.write("Podman machine started, but Podman is not ready.\n"); + return { code: 1, reexecuted: false }; +} + async function installLinuxDockerEngine({ arch, release, @@ -873,6 +1082,7 @@ export function windowsResumeScript({ entrypoint = fileURLToPath(new URL("../bin/spaceapp.mjs", import.meta.url)), requestedProfile = "auto", requestedAccessMode, + requestedEngine, noOpen = false } = {}) { if (!["auto", "small", "medium", "large", "light", "standard"].includes(requestedProfile)) { @@ -897,6 +1107,7 @@ export function windowsResumeScript({ "--profile", requestedProfile, ...installAccessArgs(requestedAccessMode, "Windows resume"), + ...(requestedEngine ? ["--engine", requestedEngine] : []), ...(noOpen ? ["--no-open"] : []) ].join(" "); return [ diff --git a/packages/run-spaceapp/src/prompt-controller.mjs b/packages/run-spaceapp/src/prompt-controller.mjs index cb8a0b8d..0c28ba55 100644 --- a/packages/run-spaceapp/src/prompt-controller.mjs +++ b/packages/run-spaceapp/src/prompt-controller.mjs @@ -33,7 +33,7 @@ export class PromptController { } } - if (!stdin._spaceappBuffer) { + if (stdin && !stdin._spaceappBuffer) { stdin._spaceappBuffer = ""; } } diff --git a/packages/run-spaceapp/src/storage.mjs b/packages/run-spaceapp/src/storage.mjs index f755dbc9..48dfa9d7 100644 --- a/packages/run-spaceapp/src/storage.mjs +++ b/packages/run-spaceapp/src/storage.mjs @@ -85,13 +85,14 @@ async function registryLayers(image, arch, fetch) { } 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 = config?.engine === "podman" ? "podman" : "docker"; + 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 cachedLayers = new Set(); if (engine && existingConfig) { for (const type of ["core", "cli", "browser"]) { - const output = await captureProbe(execute, { command: "docker", args: ["image", "inspect", "--format", "{{json .RootFS.Layers}}", `ghcr.io/oll4com/spaceapp-${type}:${existingConfig.version}`] }); + const output = await captureProbe(execute, { command: engineCommand, args: ["image", "inspect", "--format", "{{json .RootFS.Layers}}", `ghcr.io/oll4com/spaceapp-${type}:${existingConfig.version}`] }); try { for (const layer of JSON.parse(output)) cachedLayers.add(layer); } catch {} } } @@ -108,7 +109,7 @@ export async function inspectStoragePlan({ root, config, existingConfig, resourc const missingImages = []; let sharedLayersEstimated = false; for (const [type, image] of images) { - const cached = await captureProbe(execute, { command: "docker", args: ["image", "inspect", "--format", "{{.Id}}", image] }); + const cached = await captureProbe(execute, { command: engineCommand, args: ["image", "inspect", "--format", "{{.Id}}", image] }); if (!/^sha256:[a-f0-9]{64}$/.test(cached ?? "")) { missingImages.push(image); let reserve = imageReserve[type] ?? GiB; @@ -134,10 +135,11 @@ export async function inspectStoragePlan({ root, config, existingConfig, resourc const free = await captureProbe(execute, composeCommand("storageFree", root, existingConfig)); if (/^\d+$/.test(free ?? "")) dockerFreeDiskBytes = Number(free); } - // Linux can measure the Docker filesystem directly even on a fresh install. - if (engine?.DockerRootDir && process.platform === "linux") { + // Linux can measure the engine filesystem directly even on a fresh install. + const rootDir = engine?.DockerRootDir || engine?.store?.graphRoot; + if (rootDir && process.platform === "linux") { const { inspectSystemResources } = await import("./index.mjs"); - try { dockerFreeDiskBytes = (await inspectSystemResources(engine.DockerRootDir)).freeDiskBytes; } catch {} + try { dockerFreeDiskBytes = (await inspectSystemResources(rootDir)).freeDiskBytes; } catch {} } return { ...plan, missingImages, sharedLayersEstimated, dockerFreeDiskBytes, resources: { ...resources, @@ -147,12 +149,13 @@ export async function inspectStoragePlan({ root, config, existingConfig, resourc } }; } -export function storageChecks(plan, resources) { +export function storageChecks(plan, resources, { engine = "docker" } = {}) { + const engineName = engine === "podman" ? "Podman" : "Docker"; const checks = [{ name: "Free disk", ok: resources.freeDiskBytes >= plan.requiredDiskBytes, detail: `${gib(resources.freeDiskBytes)} GiB available; ${gib(plan.requiredDiskBytes)} GiB required additionally for ${plan.operation} (images ${gib(plan.missingImageBytes)}, checkpoint ${gib(plan.checkpointBytes)}, reserve 0.5)` }]; - if (plan.dockerFreeDiskBytes !== null) checks.push({ name: "Docker storage", ok: plan.dockerFreeDiskBytes >= plan.requiredDiskBytes, + if (plan.dockerFreeDiskBytes !== null) checks.push({ name: `${engineName} storage`, ok: plan.dockerFreeDiskBytes >= plan.requiredDiskBytes, detail: `${gib(plan.dockerFreeDiskBytes)} GiB available; ${gib(plan.requiredDiskBytes)} GiB required additionally` }); - else checks.push({ name: "Docker storage", ok: true, detail: "Separate engine disk capacity unavailable; host estimate used. Docker ENOSPC will stop the operation before cutover." }); + else checks.push({ name: `${engineName} storage`, ok: true, detail: `Separate engine disk capacity unavailable; host estimate used. ${engineName} ENOSPC will stop the operation before cutover.` }); return checks; } diff --git a/packages/run-spaceapp/tests/podman.test.mjs b/packages/run-spaceapp/tests/podman.test.mjs new file mode 100644 index 00000000..c135fa88 --- /dev/null +++ b/packages/run-spaceapp/tests/podman.test.mjs @@ -0,0 +1,576 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { mkdtemp, readFile } from "node:fs/promises"; +import { join, resolve } from "node:path"; +import { tmpdir } from "node:os"; +import { Readable, Writable } from "node:stream"; +import { + composeCommand, + createDefaultConfig, + renderWorkspaceCompose, + prepareInstallation, + initializeInstallation, + loadConfig, + validateConfig, + saveConfig +} from "../src/index.mjs"; +import { + podmanCliAndComposeAreAvailable, + podmanIsReady, + ensurePodmanAvailable, + ensureDockerAvailable +} from "../src/prerequisites.mjs"; +import { run } from "../src/cli.mjs"; + +function captureStream() { + let buf = ""; + const stream = new Writable({ + write(chunk, _encoding, done) { + buf += chunk.toString(); + done(); + } + }); + return { + stream, + value: () => buf + }; +} + +function dummyStdin() { + const stream = Readable.from([]); + stream._spaceappBuffer = ""; + return stream; +} + +function ttyStdin(text) { + const stream = Readable.from([`${text}\n`]); + stream.isTTY = true; + stream._spaceappBuffer = ""; + return stream; +} + +test("composeCommand with engine: podman emits command podman with compose subcommands", () => { + const root = join(tmpdir(), "spaceapp-test-podman-root"); + const cmd = composeCommand("up", root, { + engine: "podman", + profile: "small" + }); + assert.equal(cmd.command, "podman"); + assert.equal(cmd.args[0], "compose"); + assert.ok(cmd.args.includes("up")); + assert.ok(cmd.args.includes("-d")); + assert.ok(cmd.args.includes("--remove-orphans")); +}); + +test("composeCommand with default engine emits command docker", () => { + const root = join(tmpdir(), "spaceapp-test-docker-root"); + const cmd = composeCommand("up", root, { + profile: "small" + }); + assert.equal(cmd.command, "docker"); + assert.equal(cmd.args[0], "compose"); +}); + +test("renderWorkspaceCompose emits SELinux z bind option for podman engine", () => { + const config = createDefaultConfig({ version: "1.0.0", engine: "podman" }); + config.workspaces = [ + { + id: "workspace-12345678", + hostPath: resolve(tmpdir(), "workspace1"), + containerPath: "/workspaces/workspace1", + readOnly: false + } + ]; + const yaml = renderWorkspaceCompose(config); + assert.match(yaml, /bind:\s+selinux:\s+z/); +}); + +test("renderWorkspaceCompose does not emit SELinux z bind option for docker engine", () => { + const config = createDefaultConfig({ version: "1.0.0", engine: "docker" }); + config.workspaces = [ + { + id: "workspace-12345678", + hostPath: resolve(tmpdir(), "workspace1"), + containerPath: "/workspaces/workspace1", + readOnly: false + } + ]; + const yaml = renderWorkspaceCompose(config); + assert.doesNotMatch(yaml, /selinux/i); +}); + +test("validateConfig validates engine field (only docker or podman)", () => { + const validDocker = createDefaultConfig({ version: "1.0.0", engine: "docker" }); + assert.doesNotThrow(() => validateConfig(validDocker)); + + const validPodman = createDefaultConfig({ version: "1.0.0", engine: "podman" }); + assert.doesNotThrow(() => validateConfig(validPodman)); + + const invalid = { ...validDocker, engine: "nerdctl" }; + assert.throws(() => validateConfig(invalid), /engine must be docker or podman/); +}); + +test("prepareInstallation rejects switching engines on existing installation", async () => { + const root = await mkdtemp(join(tmpdir(), "spaceapp-engine-switch-")); + const prepared = await prepareInstallation(root, { + version: "1.0.0", + profile: "small", + accessMode: "isolated", + engine: "docker" + }); + await saveConfig(root, prepared.config); + + await assert.rejects( + () => prepareInstallation(root, { + version: "1.0.0", + profile: "small", + accessMode: "isolated", + engine: "podman" + }), + /Cannot switch container engine from "docker" to "podman"/ + ); +}); + +test("prepareInstallation rejects switching engines on legacy installation without engine field", async () => { + const root = await mkdtemp(join(tmpdir(), "spaceapp-engine-switch-legacy-")); + const prepared = await prepareInstallation(root, { + version: "1.0.0", + profile: "small", + accessMode: "isolated" + }); + const legacyConfig = { ...prepared.config }; + delete legacyConfig.engine; + await saveConfig(root, legacyConfig); + + await assert.rejects( + () => prepareInstallation(root, { + version: "1.0.0", + profile: "small", + accessMode: "isolated", + engine: "podman" + }), + /Cannot switch container engine from "docker" to "podman"/ + ); +}); + +test("prepareInstallation rejects --access host-root on Podman", async () => { + const root = await mkdtemp(join(tmpdir(), "spaceapp-podman-host-root-")); + await assert.rejects( + () => prepareInstallation(root, { + version: "1.0.0", + profile: "small", + accessMode: "host-root", + engine: "podman" + }), + /--access host-root is not supported on Podman/ + ); +}); + +test("podmanCliAndComposeAreAvailable checks podman CLI and podman compose", async () => { + const executed = []; + const execute = async (spec) => { + executed.push(spec); + return 0; + }; + const ok = await podmanCliAndComposeAreAvailable(execute); + assert.equal(ok, true); + assert.deepEqual(executed.map(e => [e.command, ...e.args]), [ + ["podman", "--version"], + ["podman", "compose", "version"] + ]); +}); + +test("podmanIsReady checks CLI, compose, and info", async () => { + const executed = []; + const execute = async (spec) => { + executed.push(spec); + return 0; + }; + const ok = await podmanIsReady(execute); + assert.equal(ok, true); + assert.deepEqual(executed.map(e => [e.command, ...e.args]), [ + ["podman", "--version"], + ["podman", "compose", "version"], + ["podman", "info"] + ]); +}); + +test("doctor with engine: podman probes podman and reports rootless, cgroup and limits", async () => { + const root = await mkdtemp(join(tmpdir(), "spaceapp-doctor-podman-")); + const config = createDefaultConfig({ version: "1.0.0", engine: "podman" }); + await saveConfig(root, config); + + const stdout = captureStream(); + const stderr = captureStream(); + + const code = await run(["doctor"], { + env: { SPACEAPP_HOME: root }, + platform: "linux", + stdout: stdout.stream, + stderr: stderr.stream, + stdin: dummyStdin(), + inspectResources: async () => ({ + cpuCount: 8, + totalMemoryBytes: 16 * 1024 ** 3, + freeDiskBytes: 50 * 1024 ** 3 + }), + execute: async (spec, io) => { + if (spec.command === "podman" && spec.args?.includes("info")) { + io?.stdout?.write(JSON.stringify({ + version: { Version: "5.1.0" }, + host: { + security: { rootless: true }, + cgroupVersion: "v2", + cgroupControllers: ["cpu", "memory", "pids"] + } + })); + return 0; + } + return 0; + } + }); + + assert.equal(code, 0); + assert.match(stdout.value(), /PASS Podman CLI: available/); + assert.match(stdout.value(), /PASS Podman Compose: available/); + assert.match(stdout.value(), /PASS Podman Engine: available \(5\.1\.0\)/); + assert.match(stdout.value(), /PASS Podman rootless mode: rootless/); + assert.match(stdout.value(), /PASS cgroup version: v2/); + assert.match(stdout.value(), /PASS Resource limits delegation: delegated/); +}); + +test("doctor reports non-delegated cgroup limits clearly without failing", async () => { + const root = await mkdtemp(join(tmpdir(), "spaceapp-doctor-podman-limits-")); + const config = createDefaultConfig({ version: "1.0.0", engine: "podman" }); + await saveConfig(root, config); + + const stdout = captureStream(); + const stderr = captureStream(); + + const code = await run(["doctor"], { + env: { SPACEAPP_HOME: root }, + platform: "linux", + stdout: stdout.stream, + stderr: stderr.stream, + stdin: dummyStdin(), + inspectResources: async () => ({ + cpuCount: 8, + totalMemoryBytes: 16 * 1024 ** 3, + freeDiskBytes: 50 * 1024 ** 3 + }), + execute: async (spec, io) => { + if (spec.command === "podman" && spec.args?.includes("info")) { + io?.stdout?.write(JSON.stringify({ + version: { Version: "5.0.0" }, + host: { + security: { rootless: true }, + cgroupVersion: "v2", + cgroupControllers: [] // Missing cpu and memory delegation + } + })); + return 0; + } + return 0; + } + }); + + assert.equal(code, 0); + assert.match(stdout.value(), /Resource limits delegation: not delegated/); +}); + +test("doctor fails when podman is running in rootful mode", async () => { + const root = await mkdtemp(join(tmpdir(), "spaceapp-doctor-podman-rootful-")); + const config = createDefaultConfig({ version: "1.0.0", engine: "podman" }); + await saveConfig(root, config); + + const stdout = captureStream(); + const stderr = captureStream(); + + const code = await run(["doctor"], { + env: { SPACEAPP_HOME: root }, + platform: "linux", + stdout: stdout.stream, + stderr: stderr.stream, + stdin: dummyStdin(), + inspectResources: async () => ({ + cpuCount: 8, + totalMemoryBytes: 16 * 1024 ** 3, + freeDiskBytes: 50 * 1024 ** 3 + }), + execute: async (spec, io) => { + if (spec.command === "podman" && spec.args?.includes("info")) { + io?.stdout?.write(JSON.stringify({ + version: { Version: "5.1.0" }, + host: { + security: { rootless: false }, + cgroupVersion: "v2", + cgroupControllers: ["cpu", "memory"] + } + })); + return 0; + } + return 0; + } + }); + + assert.equal(code, 1); + assert.match(stderr.value(), /FAIL Podman rootless mode: rootful/); +}); + +test("support-bundle includes engine facts for podman", async () => { + const root = await mkdtemp(join(tmpdir(), "spaceapp-bundle-podman-")); + const config = createDefaultConfig({ version: "1.0.0", engine: "podman" }); + await saveConfig(root, config); + + const stdout = captureStream(); + const stderr = captureStream(); + const bundlePath = join(root, "bundle.json"); + + const code = await run(["support-bundle", "--out", bundlePath], { + env: { SPACEAPP_HOME: root }, + platform: "linux", + stdout: stdout.stream, + stderr: stderr.stream, + stdin: dummyStdin(), + request: async () => ({ ok: true, status: 200 }), + execute: async (spec, io) => { + if (spec.command === "podman" && spec.args?.includes("info")) { + io?.stdout?.write(JSON.stringify({ + version: { Version: "5.1.0" }, + host: { + security: { rootless: true }, + cgroupVersion: "v2", + cgroupControllers: ["cpu", "memory"] + } + })); + } + return 0; + } + }); + + assert.equal(code, 0); + const bundle = JSON.parse(await readFile(bundlePath, "utf8")); + assert.equal(bundle.installation.config.engine, "podman"); + assert.equal(bundle.engine.name, "podman"); + assert.equal(bundle.engine.version, "5.1.0"); + assert.equal(bundle.engine.rootless, true); + assert.equal(bundle.engine.cgroupVersion, "v2"); + assert.equal(bundle.engine.limitsDelegated, true); +}); + +test("support-bundle reports rootless: null when host.security.rootless is missing", async () => { + const root = await mkdtemp(join(tmpdir(), "spaceapp-bundle-podman-null-")); + const config = createDefaultConfig({ version: "1.0.0", engine: "podman" }); + await saveConfig(root, config); + + const stdout = captureStream(); + const stderr = captureStream(); + const bundlePath = join(root, "bundle.json"); + + const code = await run(["support-bundle", "--out", bundlePath], { + env: { SPACEAPP_HOME: root }, + platform: "linux", + stdout: stdout.stream, + stderr: stderr.stream, + stdin: dummyStdin(), + request: async () => ({ ok: true, status: 200 }), + execute: async (spec, io) => { + if (spec.command === "podman" && spec.args?.includes("info")) { + io?.stdout?.write(JSON.stringify({ + version: { Version: "5.1.0" }, + host: { + cgroupVersion: "v2", + cgroupControllers: ["cpu", "memory"] + } + })); + } + return 0; + } + }); + + assert.equal(code, 0); + const bundle = JSON.parse(await readFile(bundlePath, "utf8")); + assert.equal(bundle.engine.name, "podman"); + assert.equal(bundle.engine.rootless, null); + assert.equal(bundle.engine.limitsDelegated, true); +}); + +test("ensurePodmanAvailable on macOS checks podman machine sizing and warns if insufficient", async () => { + const stdout = captureStream(); + const stderr = captureStream(); + + const execute = async (spec, io) => { + if (spec.command === "podman" && spec.args?.[0] === "machine" && spec.args?.[1] === "inspect") { + io?.stdout?.write(JSON.stringify([{ + Running: true, + CPUs: 2, // Required: 4 for small profile + Memory: 4096 * 1024 * 1024, // 4 GiB, Required: 7GiB + DiskSize: 20 * 1024 * 1024 * 1024 + }])); + return 0; + } + return 0; + }; + + const res = await ensurePodmanAvailable({ + platform: "darwin", + arch: "arm64", + env: {}, + stdin: null, + stdout: stdout.stream, + stderr: stderr.stream, + execute, + profile: "small", + sleep: async () => {} + }); + + assert.equal(res.code, 0); + assert.match(stdout.value(), /podman machine set --cpus 4 --memory 7168/); +}); + +test("ensurePodmanAvailable on macOS checks Podman 5 Resources shape", async () => { + const stdout = captureStream(); + const stderr = captureStream(); + + const execute = async (spec, io) => { + if (spec.command === "podman" && spec.args?.[0] === "machine" && spec.args?.[1] === "inspect") { + io?.stdout?.write(JSON.stringify([{ + Running: true, + Resources: { + CPUs: 2, + Memory: 4096, + DiskSize: 20 + } + }])); + return 0; + } + return 0; + }; + + const res = await ensurePodmanAvailable({ + platform: "darwin", + arch: "arm64", + env: {}, + stdin: null, + stdout: stdout.stream, + stderr: stderr.stream, + execute, + profile: "small", + sleep: async () => {} + }); + + assert.equal(res.code, 0); + assert.match(stdout.value(), /podman machine set --cpus 4 --memory 7168/); +}); + +test("ensureDockerAvailable falls back to Podman when Docker is missing and Podman is ready", async () => { + const stdout = captureStream(); + const stderr = captureStream(); + + const execute = async (spec) => { + if (spec.command === "docker") return 127; + if (spec.command === "podman") return 0; + return 0; + }; + + const installArgs = { requestedProfile: "small", autoConfirm: true, allowPodmanFallback: true }; + const res = await ensureDockerAvailable({ + platform: "linux", + arch: "x64", + env: {}, + stdin: null, + stdout: stdout.stream, + stderr: stderr.stream, + execute, + engine: "docker", + installArgs + }); + + assert.equal(res.code, 0); + assert.equal(res.engine, "podman"); + assert.equal(installArgs.requestedEngine, "podman"); + assert.match(stdout.value(), /Using Podman container engine/); +}); + +test("local image mode with podman engine skips pull and passes --pull never on up", async () => { + const root = await mkdtemp(join(tmpdir(), "spaceapp-local-images-podman-")); + const config = createDefaultConfig({ version: "0.1.30", engine: "podman" }); + await saveConfig(root, config); + + const stdout = captureStream(); + const stderr = captureStream(); + const calls = []; + + const exitCode = await run(["up", "--local-images"], { + env: { SPACEAPP_HOME: root }, + platform: "linux", + stdout: stdout.stream, + stderr: stderr.stream, + stdin: dummyStdin(), + execute: async (spec) => { + calls.push(spec); + return 0; + } + }); + + assert.equal(exitCode, 0); + const up = calls.find((x) => x.command === "podman" && x.args?.includes("up")); + assert.ok(up, "podman compose up must be called"); + assert.deepEqual(up.args.slice(up.args.indexOf("up"), up.args.indexOf("up") + 3), ["up", "--pull", "never"]); +}); + +test("factory-reset preserves configured podman engine in newly initialized installation", async () => { + const root = await mkdtemp(join(tmpdir(), "spaceapp-factory-reset-podman-")); + await initializeInstallation(root, { version: "0.1.30", engine: "podman" }); + + const stdout = captureStream(); + const stderr = captureStream(); + const calls = []; + + const exitCode = await run(["factory-reset"], { + env: { SPACEAPP_HOME: root }, + platform: "linux", + stdout: stdout.stream, + stderr: stderr.stream, + stdin: ttyStdin("FACTORY-RESET"), + execute: async (spec, io) => { + if (spec.args?.includes("pg_dump")) { + io?.stdout?.write("-- PostgreSQL database dump\nSELECT 1;\n"); + return 0; + } + calls.push(spec); + return 0; + } + }); + + assert.equal(exitCode, 0); + assert.match(stdout.value(), /SpaceApp factory reset complete/); + const reloaded = await loadConfig(root); + assert.equal(reloaded.engine, "podman"); +}); + +test("same-version install with explicit engine switch rejects instead of repairing", async () => { + const root = await mkdtemp(join(tmpdir(), "spaceapp-engine-switch-")); + await initializeInstallation(root, { version: "0.1.30", engine: "docker" }); + + const stdout = captureStream(); + const stderr = captureStream(); + const calls = []; + + await assert.rejects( + run(["install", "--engine", "podman", "--non-interactive", "--answers", '{"confirm":true,"open":false}'], { + env: { SPACEAPP_HOME: root }, + platform: "linux", + stdout: stdout.stream, + stderr: stderr.stream, + stdin: dummyStdin(), + execute: async (spec) => { + calls.push(spec); + return 0; + }, + request: async () => ({ ok: true, status: 200 }) + }), + /Cannot switch container engine from "docker" to "podman"/ + ); +}); +