From a611c250f590eab508270777688aede30ca1b308 Mon Sep 17 00:00:00 2001 From: big-chungus <346867131+bigchungusai@users.noreply.github.com> Date: Fri, 23 Oct 2026 11:16:34 +0000 Subject: [PATCH 1/3] feat(run-spaceapp): support rootless Podman and Podman machine container engine (closes #63) Signed-off-by: big-chungus <346867131+bigchungusai@users.noreply.github.com> --- .github/workflows/platform-matrix.yml | 16 ++ docs/getting-started.md | 11 ++ packages/run-spaceapp/README.md | 11 ++ packages/run-spaceapp/src/cli.mjs | 158 ++++++++++++------ packages/run-spaceapp/src/index.mjs | 67 +++++++- packages/run-spaceapp/src/prerequisites.mjs | 36 ++++- packages/run-spaceapp/src/storage.mjs | 19 ++- packages/run-spaceapp/tests/podman.test.mjs | 168 ++++++++++++++++++++ 8 files changed, 422 insertions(+), 64 deletions(-) create mode 100644 packages/run-spaceapp/tests/podman.test.mjs diff --git a/.github/workflows/platform-matrix.yml b/.github/workflows/platform-matrix.yml index e09ecb53c..5b30707f9 100644 --- a/.github/workflows/platform-matrix.yml +++ b/.github/workflows/platform-matrix.yml @@ -57,3 +57,19 @@ 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-linux: + name: Launcher / Podman rootless (ubuntu-latest) + runs-on: ubuntu-latest + timeout-minutes: 25 + 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 + - run: npm ci + - run: npm test -w run-spaceapp + - run: node packages/run-spaceapp/bin/spaceapp.mjs --plan install --engine podman --profile small --non-interactive --answers '{"confirm":true}' diff --git a/docs/getting-started.md b/docs/getting-started.md index c56d99ce9..f9bb01ded 100644 --- a/docs/getting-started.md +++ b/docs/getting-started.md @@ -334,3 +334,14 @@ Then set a new `SPACEAPP_HOME` and follow `init → doctor → workspace add → pull. This proves a source build; it does **not** prove the public npm tarball or GHCR manifests. Follow [Clean-room testing](clean-room-testing.md) for the complete acceptance checklist. + + +## Rootless Podman Support + +SpaceApp also supports **rootless Podman** on Linux (Podman >= 5 with cgroup v2 and `docker-compose` v2 as the `podman compose` provider) and **Podman machine** on macOS: + +```bash +npx --yes run-spaceapp@latest install --engine podman --profile small +``` + +The selected engine (`docker` or `podman`) is stored in `config.json` and used automatically by `up`, `down`, `status`, `logs`, `doctor`, `backup`, `restore`, `update`, `rollback`, and `uninstall`. Switching engines on an existing installation is refused to protect existing volumes; use `backup` and `restore` when migrating between engines. `--access host-root` is not supported on Podman. diff --git a/packages/run-spaceapp/README.md b/packages/run-spaceapp/README.md index 0e8b1ab3b..7790e5973 100644 --- a/packages/run-spaceapp/README.md +++ b/packages/run-spaceapp/README.md @@ -183,3 +183,14 @@ npx --yes run-spaceapp@latest uninstall # Stop and remove containers (prese Uninstall retains data by default and prints the separate `npm uninstall -g run-spaceapp` command for removing an optional global launcher. + + +## Rootless Podman Support + +SpaceApp also supports **rootless Podman** on Linux (Podman >= 5 with cgroup v2 and `docker-compose` v2 as the `podman compose` provider) and **Podman machine** on macOS: + +```bash +npx --yes run-spaceapp@latest install --engine podman --profile small +``` + +The selected engine (`docker` or `podman`) is stored in `config.json` and used automatically by `up`, `down`, `status`, `logs`, `doctor`, `backup`, `restore`, `update`, `rollback`, and `uninstall`. Switching engines on an existing installation is refused to protect existing volumes; use `backup` and `restore` when migrating between engines. `--access host-root` is not supported on Podman. diff --git a/packages/run-spaceapp/src/cli.mjs b/packages/run-spaceapp/src/cli.mjs index e0c16bdb4..da54dc51e 100644 --- a/packages/run-spaceapp/src/cli.mjs +++ b/packages/run-spaceapp/src/cli.mjs @@ -35,6 +35,7 @@ import { removeUser, removeWorkspace, resolveInstallAccessMode, + resolveInstallEngine, resolveInstallProfile, resolveSpaceAppHome, saveConfig, @@ -49,7 +50,10 @@ import { } from "./index.mjs"; import { inspectStoragePlan, storageChecks } from "./storage.mjs"; import { + dockerIsReady, ensureDockerAvailable, + ensurePodmanAvailable, + podmanIsReady, prepareDockerCliPath, windowsPowerShellArgs } from "./prerequisites.mjs"; @@ -76,6 +80,7 @@ const SETUP_STATUS_TIMEOUT_MS = 10_000; const trustedCommands = new Set([ "codesign", "docker", + "podman", "hdiutil", "open", "powershell.exe", @@ -338,7 +343,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; @@ -427,7 +432,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"); @@ -512,14 +517,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; @@ -551,14 +556,14 @@ 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; 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; - 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`)); @@ -576,7 +581,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) { @@ -598,7 +603,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) { @@ -654,16 +659,27 @@ async function installCommand(args, { const { requestedProfile, requestedAccessMode, + requestedEngine, noOpen, companionsEnabled, telemetryEnabled, installId } = wizard; const existingConfig = await loadExistingInstallation(root); + const existingEngine = existingConfig?.engine ?? "docker"; + if (existingConfig && requestedEngine !== undefined && requestedEngine !== existingEngine) { + throw new Error( + `Cannot switch container engine from ${existingEngine} to ${requestedEngine} on an existing installation (${existingEngine} volumes are not visible to ${requestedEngine}). Run "${UNIVERSAL_COMMAND} backup", uninstall, and restore on the new engine.` + ); + } + const engine = resolveInstallEngine(requestedEngine, existingEngine); const accessMode = resolveInstallAccessMode( requestedAccessMode, existingConfig?.accessMode ?? "isolated" ); + if (engine === "podman" && accessMode === "host-root") { + throw new Error("Host-root access (--access host-root) is not supported on Podman. Use --access isolated."); + } if (accessMode === "host-root" && platform !== "linux") { throw new Error("Host-root access is supported only on Linux."); } @@ -685,7 +701,8 @@ async function installCommand(args, { version: runtimeVersion, profile, accessMode, - ...(companionsEnabled ? { companionsEnabled } : {}) + ...(companionsEnabled ? { companionsEnabled } : {}), + ...(requestedEngine !== undefined || existingConfig?.engine ? { engine } : {}) }); // 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). @@ -742,16 +759,18 @@ async function installCommand(args, { } stage = "docker"; - const prerequisiteResult = await ensureDocker({ - platform, - arch, - env, - stdin, - stdout, - stderr, - execute, - installArgs: { root, requestedProfile, requestedAccessMode, noOpen, autoConfirm: true } - }); + const prerequisiteResult = engine === "podman" + ? await ensurePodmanAvailable({ platform, stdout, stderr, execute }) + : await ensureDocker({ + platform, + arch, + env, + stdin, + stdout, + stderr, + execute, + installArgs: { root, requestedProfile, requestedAccessMode, noOpen, autoConfirm: true } + }); if (prerequisiteResult.reexecuted) { return prerequisiteResult.code; } @@ -784,6 +803,7 @@ async function installCommand(args, { inspectResources, resources, profile, + engine, dockerReady: true, operation, storagePlan }); if (doctorCode !== 0) { @@ -835,6 +855,7 @@ async function installCommand(args, { profile, stateRoot: stagedStateRoot, companionsEnabled, + engine, ...options }); const pullCode = await withHeadlessDockerConfig( @@ -1027,7 +1048,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; @@ -1049,7 +1070,19 @@ async function planInteractiveSetup({ request, sleep, ensureDocker, env, arch }) { const parsed = parseInstallArgs(args); + const controllerAnswers = stdin?._spaceappPromptController?.answers ?? {}; + if (parsed.requestedEngine === undefined && typeof controllerAnswers.engine === "string") { + parsed.requestedEngine = resolveInstallEngine(controllerAnswers.engine); + } const rawExisting = await readRawConfig(root); + if (rawExisting && parsed.requestedEngine !== undefined && parsed.requestedEngine !== (rawExisting.engine ?? "docker")) { + throw new Error( + `Cannot switch container engine from ${rawExisting.engine ?? "docker"} to ${parsed.requestedEngine} on an existing installation (${rawExisting.engine ?? "docker"} volumes are not visible to ${parsed.requestedEngine}). Run "${UNIVERSAL_COMMAND} backup", uninstall, and restore on the new engine.` + ); + } + if ((parsed.requestedEngine ?? rawExisting?.engine) === "podman" && parsed.requestedAccessMode === "host-root") { + throw new Error("Host-root access (--access host-root) is not supported on Podman. Use --access isolated."); + } const repairs = planConfigRepairs(rawExisting); const path = upgradePath(rawExisting?.version ?? null, runtimeVersion); const interactive = interactiveAvailable(stdin); @@ -1141,9 +1174,19 @@ async function planInteractiveSetup({ noOpen: !openBrowserChoice }); } + let resolvedEngineChoice = parsed.requestedEngine; + if (resolvedEngineChoice === undefined && platform !== "win32") { + const dockerReadyNow = await dockerIsReady(execute); + if (!dockerReadyNow && await podmanIsReady(execute)) { + resolvedEngineChoice = stdin?._spaceappPromptController?.nonInteractive + ? "podman" + : (await promptYesNo(stdin, stdout, "Docker is not available, but Podman is ready. Use Podman instead of installing Docker?", { defaultYes: true, answerKey: "usePodman" }) ? "podman" : "docker"); + } + } return { requestedProfile: profileChoice, requestedAccessMode: accessChoice, + requestedEngine: resolvedEngineChoice, noOpen: !openBrowserChoice, companionsEnabled: companionsChoice, telemetryEnabled: telemetryChoice, @@ -1221,6 +1264,7 @@ async function planInteractiveSetup({ return { requestedProfile: parsed.requestedProfile, requestedAccessMode: parsed.requestedAccessMode, + requestedEngine: parsed.requestedEngine, noOpen: parsed.noOpen, companionsEnabled: parsed.companionsEnabled, telemetryEnabled: false @@ -1302,7 +1346,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) { @@ -1311,7 +1355,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) { @@ -1376,7 +1420,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) { @@ -1454,7 +1498,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; @@ -1517,7 +1561,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 { @@ -1692,6 +1736,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) { @@ -1722,8 +1767,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 [--engine docker|podman] [--profile auto|small|medium|large|light|standard] [--access isolated|host-root] [--with-companions] [--no-open]` ); } if ( @@ -1731,13 +1785,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 [--engine docker|podman] [--profile auto|small|medium|large|light|standard] [--access isolated|host-root] [--with-companions] [--no-open]` ); } - return { requestedProfile, requestedAccessMode, noOpen, companionsEnabled }; + return { requestedProfile, requestedAccessMode, requestedEngine, noOpen, companionsEnabled }; } async function loadExistingInstallation(root) { @@ -1772,7 +1830,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; }; @@ -1828,7 +1886,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, service, engine: config.engine }), { stdin, stdout: out, stderr } ).catch(() => 1); out.end(); @@ -1868,7 +1926,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, service, engine: config.engine }), { stdin, stdout, stderr, input } ).catch(() => 1); if (code === 0) restored.push(label); @@ -1884,7 +1942,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"); @@ -1931,7 +1989,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, service, engine: config.engine }), { stdin, stdout, stderr } ); if (syncCode !== 0) { @@ -1947,7 +2005,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, service, engine: config.engine }), { stdin, stdout, stderr } ); if (syncCode !== 0) { @@ -1973,7 +2031,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, service, engine: config.engine }), { stdin, stdout: null, stderr: null } ).catch(() => 1); if (code === 0) { @@ -1992,7 +2050,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; }; @@ -2046,13 +2104,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, provider, service, 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, @@ -2073,7 +2131,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, @@ -2101,7 +2159,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) { @@ -2109,7 +2167,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) { @@ -2167,6 +2225,7 @@ async function supportBundleCommand({ profile: config.profile, accessMode: config.accessMode, companionsEnabled: config.companionsEnabled, + engine: config.engine ?? "docker", bindHost: config.bindHost, port: config.port, previousVersion: config.previousVersion ?? null @@ -2241,7 +2300,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) { @@ -2382,6 +2441,7 @@ async function doctor({ inspectResources, resources, profile, + engine, operation, storagePlan, dockerReady = false @@ -2409,11 +2469,14 @@ async function doctor({ ...resourceChecks.filter((check) => !storagePlan || check.name !== "Free disk"), ...(storagePlan ? storageChecks(storagePlan, detectedResources) : []) ]; + const existingCfg = await loadConfig(root).catch(() => null); + const activeEngine = engine ?? existingCfg?.engine ?? "docker"; + const engineLabel = activeEngine === "podman" ? "Podman" : "Docker"; const dockerResults = []; for (const probe of [ - { name: "Docker CLI", command: "docker", args: ["--version"] }, - { name: "Docker Compose", command: "docker", args: ["compose", "version"] }, - { name: "Docker Engine", command: "docker", args: ["info"] } + { name: `${engineLabel} CLI`, command: activeEngine, args: ["--version"] }, + { name: `${engineLabel} Compose`, command: activeEngine, args: ["compose", "version"] }, + { name: `${engineLabel} Engine`, command: activeEngine, args: ["info"] } ]) { const code = dockerReady ? 0 @@ -2631,6 +2694,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": @@ -2736,7 +2800,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 [--engine docker|podman] [--profile auto|small|medium|large|light|standard] [--access isolated|host-root] [--with-companions] [--no-open] Install prerequisites, initialize, and start reinstall [--keep-data] [--profile auto|small|medium|large|light|standard] [--access isolated|host-root] [--with-companions] [--no-open] Clean reinstallation preserving data and credentials diff --git a/packages/run-spaceapp/src/index.mjs b/packages/run-spaceapp/src/index.mjs index fad1eb208..f0a03b8b7 100644 --- a/packages/run-spaceapp/src/index.mjs +++ b/packages/run-spaceapp/src/index.mjs @@ -129,8 +129,10 @@ const CONFIG_KEYS = new Set([ "companionsEnabled", "optionalModules", "installId", - "users" + "users", + "engine" ]); +export const SUPPORTED_ENGINES = Object.freeze(["docker", "podman"]); // Multi-user (SpaceApp accounts with SPACE_MULTI_USER_ENABLED): every user's CLIs run in their own container, with // their own keys, logins, memory and workspaces; the core starts each pane in its owner's container. @@ -184,6 +186,16 @@ export function resolveInstallProfile(requestedProfile, resources) { return "small"; } +export function resolveInstallEngine(requestedEngine, existingEngine = "docker") { + if (requestedEngine === undefined) { + return existingEngine; + } + if (requestedEngine === "docker" || requestedEngine === "podman") { + return requestedEngine; + } + throw new Error("Container engine must be docker or podman."); +} + export function resolveInstallAccessMode(requestedMode, existingMode = "isolated") { if (requestedMode === undefined) { return existingMode; @@ -308,13 +320,18 @@ 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 = resolveInstallEngine(engine); + if (resolvedEngine === "podman" && resolvedAccessMode === "host-root") { + throw new Error("Host-root access (--access host-root) is not supported on Podman. Use --access isolated."); + } return { schemaVersion: CONFIG_SCHEMA_VERSION, version, @@ -326,7 +343,8 @@ export function createDefaultConfig({ accessMode: resolvedAccessMode, companionsEnabled, optionalModules: "none", - workspaces: [] + workspaces: [], + ...(engine !== undefined ? { engine: resolvedEngine } : {}) }; } @@ -364,6 +382,12 @@ 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. Use --access isolated."); + } if (typeof config.companionsEnabled !== "boolean") { throw new Error("companionsEnabled must be boolean."); } @@ -673,17 +697,30 @@ const CLI_SANDBOX_OPTIONS = [" security_opt:", " - seccomp=./seccomp-cli export function renderWorkspaceCompose(config, { cliSandbox = false } = {}) { validateConfig(config); + const isPodman = config.engine === "podman"; const mounts = config.workspaces.flatMap((workspace) => [ " - type: bind", ` source: ${JSON.stringify(workspace.hostPath)}`, ` target: ${JSON.stringify(workspace.containerPath)}`, - ` read_only: ${workspace.readOnly}` + ` read_only: ${workspace.readOnly}`, + ...(isPodman ? [" bind:", " selinux: z"] : []) ]); + const podmanUserns = isPodman ? [' userns_mode: "keep-id:uid=10001,gid=10001"'] : []; 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:", + ...podmanUserns, + ...service, + " spaceapp-cli:", + ...podmanUserns, + ...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; @@ -905,7 +942,8 @@ export function composeCommand(action, root, options = {}) { if (!selected) { throw new Error(`Unsupported Compose action "${action}".`); } - return { command: "docker", args: [...base, ...selected] }; + const engine = resolveInstallEngine(options.engine); + return { command: engine, args: [...base, ...selected] }; } export function composeProjectName(root) { @@ -956,7 +994,8 @@ export async function prepareInstallation(root, { version, profile, accessMode, - companionsEnabled + companionsEnabled, + engine }) { validateHome(root); await mkdir(root, { recursive: true, mode: 0o700 }); @@ -979,8 +1018,20 @@ export async function prepareInstallation(root, { version, profile: resolvedProfile ?? "light", accessMode: resolveInstallAccessMode(accessMode), - companionsEnabled + companionsEnabled, + ...(engine !== undefined ? { engine } : {}) }); + } else if (engine !== undefined) { + const existingEngine = config.engine ?? "docker"; + const targetEngine = resolveInstallEngine(engine, existingEngine); + if (existingEngine !== targetEngine) { + throw new Error( + `Cannot switch container engine from ${existingEngine} to ${targetEngine} on an existing installation (${existingEngine} volumes are not visible to ${targetEngine}). Run "${UNIVERSAL_COMMAND} backup", uninstall, and restore on the new engine.` + ); + } + if (targetEngine === "podman" && config.engine !== "podman") { + config = { ...config, engine: "podman" }; + } } if (config.version !== version) { config = { diff --git a/packages/run-spaceapp/src/prerequisites.mjs b/packages/run-spaceapp/src/prerequisites.mjs index 42bb242da..d294fe843 100644 --- a/packages/run-spaceapp/src/prerequisites.mjs +++ b/packages/run-spaceapp/src/prerequisites.mjs @@ -654,7 +654,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"] }, @@ -667,6 +667,40 @@ async function dockerIsReady(execute) { 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; +} + +export async function ensurePodmanAvailable({ + platform = process.platform, + stdout = process.stdout, + stderr = process.stderr, + execute +}) { + if (platform === "win32") { + stderr.write("Podman engine support is available on Linux and macOS only.\n"); + return { code: 1, reexecuted: false }; + } + if (await podmanIsReady(execute)) { + return { code: 0, reexecuted: false }; + } + stderr.write( + platform === "darwin" + ? "Podman and `podman compose` (Podman >= 5 with docker-compose v2 provider) must be installed and your Podman machine must be running (`podman machine init --cpus 4 --memory 8192 --disk-size 25 && podman machine start`).\n" + : "Rootless Podman (>= 5) and `podman compose` (with docker-compose v2 provider) must be installed and ready (for example `sudo dnf install -y podman podman-compose docker-compose` on Fedora/RHEL or `sudo apt-get install -y podman podman-compose docker-compose-v2` on Ubuntu/Debian).\n" + ); + return { code: 1, reexecuted: false }; +} + async function installLinuxDockerEngine({ arch, release, diff --git a/packages/run-spaceapp/src/storage.mjs b/packages/run-spaceapp/src/storage.mjs index c8b4cfadc..af026240f 100644 --- a/packages/run-spaceapp/src/storage.mjs +++ b/packages/run-spaceapp/src/storage.mjs @@ -96,7 +96,8 @@ 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 engineCmd = (config?.engine ?? existingConfig?.engine) === "podman" ? "podman" : "docker"; + const dockerInfo = await captureProbe(execute, { command: engineCmd, args: ["info", "--format", "{{json .}}"] }); let engine = null; try { engine = JSON.parse(dockerInfo); } catch { /* Conservative reserve when the engine is unavailable. */ } const cachedLayers = new Set(); @@ -104,7 +105,7 @@ export async function inspectStoragePlan({ root, config, existingConfig, resourc 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: engineCmd, args: ["image", "inspect", "--format", "{{json .RootFS.Layers}}", ref] }); try { for (const layer of JSON.parse(output)) cachedLayers.add(layer); break; } catch {} } } @@ -126,7 +127,7 @@ 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] }); + cached = await captureProbe(execute, { command: engineCmd, args: ["image", "inspect", "--format", "{{.Id}}", ref] }); if (/^sha256:[a-f0-9]{64}$/.test(cached ?? "")) break; } if (!/^sha256:[a-f0-9]{64}$/.test(cached ?? "")) { @@ -134,7 +135,8 @@ export async function inspectStoragePlan({ root, config, existingConfig, resourc 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 archStr = engine.Architecture ?? engine.host?.arch ?? ""; + const metadata = await registryLayers(image, /arm|aarch64/.test(archStr) ? "arm64" : "amd64", fetch); if (metadata) { reserve = estimateMissingLayers(metadata.layers, metadata.diffIds, cachedLayers); sharedLayersEstimated = true; } } catch { /* Offline/private registry: retain the full conservative reserve. */ } } @@ -155,15 +157,16 @@ export async function inspectStoragePlan({ root, config, existingConfig, resourc 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") { + 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, freeDiskBytes: dockerFreeDiskBytes === null ? resources.freeDiskBytes : Math.min(resources.freeDiskBytes, dockerFreeDiskBytes), - engineCpuCount: engine?.NCPU, - engineMemoryBytes: engine?.MemTotal + engineCpuCount: engine?.NCPU ?? engine?.host?.cpus, + engineMemoryBytes: engine?.MemTotal ?? engine?.host?.memTotal } }; } diff --git a/packages/run-spaceapp/tests/podman.test.mjs b/packages/run-spaceapp/tests/podman.test.mjs new file mode 100644 index 000000000..ca90eb09d --- /dev/null +++ b/packages/run-spaceapp/tests/podman.test.mjs @@ -0,0 +1,168 @@ +import assert from "node:assert/strict"; +import { mkdir, mkdtemp, readFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { Readable, Writable } from "node:stream"; +import test from "node:test"; +import { + addWorkspace, + composeCommand, + createDefaultConfig, + prepareInstallation, + renderWorkspaceCompose, + resolveInstallEngine, + validateConfig +} from "../src/index.mjs"; +import { inspectStoragePlan } from "../src/storage.mjs"; +import { run } from "../src/cli.mjs"; + +function capture() { + let value = ""; + const stream = new Writable({ + write(chunk, _encoding, callback) { + value += chunk.toString(); + callback(); + } + }); + return { stream, value: () => value }; +} + +test("resolveInstallEngine defaults to docker and validates docker|podman", () => { + assert.equal(resolveInstallEngine(undefined), "docker"); + assert.equal(resolveInstallEngine(undefined, "podman"), "podman"); + assert.equal(resolveInstallEngine("docker", "podman"), "docker"); + assert.equal(resolveInstallEngine("podman", "docker"), "podman"); + assert.throws(() => resolveInstallEngine("nerdctl"), /docker or podman/i); +}); + +test("composeCommand dispatches to podman when engine is podman and stays docker by default", () => { + const root = tmpdir(); + const dockerSpec = composeCommand("up", root, { profile: "small" }); + assert.equal(dockerSpec.command, "docker"); + assert.equal(dockerSpec.args[0], "compose"); + + const podmanSpec = composeCommand("up", root, { profile: "small", engine: "podman" }); + assert.equal(podmanSpec.command, "podman"); + assert.equal(podmanSpec.args[0], "compose"); + assert.ok(podmanSpec.args.includes("up")); + + const backupSpec = composeCommand("backup", root, { profile: "medium", engine: "podman" }); + assert.equal(backupSpec.command, "podman"); + assert.ok(backupSpec.args.includes("scripts/portable-backup.mjs")); +}); + +test("Podman workspace compose adds SELinux relabeling and rootless keep-id userns_mode", async () => { + const root = await mkdtemp(join(tmpdir(), "spaceapp-podman-ws-")); + const wsDir = join(root, "repo"); + await mkdir(wsDir, { recursive: true }); + + const podmanConfig = await addWorkspace( + createDefaultConfig({ version: "1.1.7", profile: "small", engine: "podman" }), + wsDir + ); + const compose = renderWorkspaceCompose(podmanConfig); + assert.match(compose, /userns_mode: "keep-id:uid=10001,gid=10001"/); + assert.match(compose, /selinux: z/); +}); + +test("host-root access is refused on Podman with a clear error", () => { + assert.throws( + () => createDefaultConfig({ version: "1.1.7", accessMode: "host-root", engine: "podman" }), + /not supported on Podman/i + ); + const invalid = { + ...createDefaultConfig({ version: "1.1.7", engine: "podman" }), + accessMode: "host-root" + }; + assert.throws(() => validateConfig(invalid), /not supported on Podman/i); +}); + +test("switching engines on an existing installation is refused to protect existing volumes", async () => { + const root = await mkdtemp(join(tmpdir(), "spaceapp-engine-switch-")); + await prepareInstallation(root, { version: "1.1.7", profile: "small", engine: "docker" }); + await assert.rejects( + () => prepareInstallation(root, { version: "1.1.7", profile: "small", engine: "podman" }), + /Cannot switch container engine from docker to podman/i + ); +}); + +test("inspectStoragePlan probes podman info and podman image inspect when engine is podman", async () => { + const root = await mkdtemp(join(tmpdir(), "spaceapp-podman-storage-")); + const commands = []; + const execute = async (spec, io) => { + commands.push(spec.command); + if (spec.args[0] === "info") { + io.stdout.write(JSON.stringify({ + host: { arch: "amd64", cpus: 8, memTotal: 16 * 1024 ** 3 }, + store: { graphRoot: root } + })); + return 0; + } + if (spec.args[0] === "image" && spec.args[1] === "inspect") { + io.stdout.write("sha256:" + "a".repeat(64)); + return 0; + } + return 0; + }; + const config = createDefaultConfig({ version: "1.1.7", profile: "small", engine: "podman" }); + const plan = await inspectStoragePlan({ + root, + config, + resources: { cpuCount: 8, totalMemoryBytes: 16 * 1024 ** 3, freeDiskBytes: 20 * 1024 ** 3 }, + execute, + operation: "fresh", + fetch: null + }); + assert.ok(commands.every((cmd) => cmd === "podman")); + assert.equal(plan.resources.engineCpuCount, 8); + assert.equal(plan.resources.engineMemoryBytes, 16 * 1024 ** 3); +}); + +test("install --engine podman saves engine in config and subsequent lifecycle commands use podman", async () => { + const root = await mkdtemp(join(tmpdir(), "spaceapp-podman-cli-")); + const executed = []; + const stdout = capture(); + const stderr = capture(); + + const code = await run( + ["install", "--engine", "podman", "--profile", "small", "--no-open", "--non-interactive", "--answers", '{"confirm":true}'], + { + env: { SPACEAPP_HOME: root }, + platform: "linux", + stdin: Readable.from([]), + stdout: stdout.stream, + stderr: stderr.stream, + inspectResources: async () => ({ cpuCount: 8, totalMemoryBytes: 16 * 1024 ** 3, freeDiskBytes: 25 * 1024 ** 3 }), + prepareDockerPath: async () => null, + request: async (url) => ({ + ok: true, + json: async () => (url.endsWith("/readyz") ? { ok: true } : { setupRequired: false, expiresAt: null }) + }), + sleep: async () => {}, + execute: async (spec) => { + executed.push(spec); + return 0; + } + } + ); + assert.equal(code, 0); + const saved = JSON.parse(await readFile(join(root, "config.json"), "utf8")); + assert.equal(saved.engine, "podman"); + assert.ok(executed.some((spec) => spec.command === "podman" && spec.args.includes("up"))); + + executed.length = 0; + assert.equal(await run(["status"], { + env: { SPACEAPP_HOME: root }, + platform: "linux", + stdin: Readable.from([]), + stdout: stdout.stream, + stderr: stderr.stream, + prepareDockerPath: async () => null, + execute: async (spec) => { + executed.push(spec); + return 0; + } + }), 0); + assert.equal(executed[0].command, "podman"); + assert.ok(executed[0].args.includes("ps")); +}); From 09ff6261fb99b559c59d2cfce28243071084258a Mon Sep 17 00:00:00 2001 From: big-chungus <346867131+bigchungusai@users.noreply.github.com> Date: Fri, 23 Oct 2026 12:31:25 +0000 Subject: [PATCH 2/3] fix(run-spaceapp): resolve CodeRabbit review findings on Podman compose and CLI engine dispatch (#63) Signed-off-by: big-chungus <346867131+bigchungusai@users.noreply.github.com> --- packages/run-spaceapp/src/cli.mjs | 31 +++++++++++++-------- packages/run-spaceapp/src/index.mjs | 11 ++++++-- packages/run-spaceapp/tests/podman.test.mjs | 18 +++++++++++- 3 files changed, 46 insertions(+), 14 deletions(-) diff --git a/packages/run-spaceapp/src/cli.mjs b/packages/run-spaceapp/src/cli.mjs index da54dc51e..c2bcd0b94 100644 --- a/packages/run-spaceapp/src/cli.mjs +++ b/packages/run-spaceapp/src/cli.mjs @@ -822,6 +822,7 @@ async function installCommand(args, { root, stateRoot: stagedStateRoot, profile, + engine, platform, stdin, stdout, @@ -837,6 +838,7 @@ async function installCommand(args, { existingConfig, attemptedProfile: profile, companionsEnabled, + engine, platform, stdin, stdout, @@ -1175,12 +1177,10 @@ async function planInteractiveSetup({ }); } let resolvedEngineChoice = parsed.requestedEngine; - if (resolvedEngineChoice === undefined && platform !== "win32") { + if (resolvedEngineChoice === undefined && platform !== "win32" && controllerAnswers.usePodman !== undefined) { const dockerReadyNow = await dockerIsReady(execute); if (!dockerReadyNow && await podmanIsReady(execute)) { - resolvedEngineChoice = stdin?._spaceappPromptController?.nonInteractive - ? "podman" - : (await promptYesNo(stdin, stdout, "Docker is not available, but Podman is ready. Use Podman instead of installing Docker?", { defaultYes: true, answerKey: "usePodman" }) ? "podman" : "docker"); + resolvedEngineChoice = controllerAnswers.usePodman ? "podman" : "docker"; } } return { @@ -1337,8 +1337,10 @@ async function repairRuntime({ root, config, platform, stdin, stdout, stderr, ex "Repair configuration and recreate the current runtime without deleting data.", ...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}}); + const prerequisites = repaired.engine === "podman" + ? await ensurePodmanAvailable({ platform, stdout, stderr, execute }) + : await ensureDocker({platform, arch, env, stdin, stdout, stderr, execute, sleep: sleep || wait, + installArgs: {root,requestedProfile:repaired.profile,requestedAccessMode:repaired.accessMode,noOpen:true,autoConfirm:true}}); 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; @@ -1643,6 +1645,7 @@ async function reportInstallDiagnostics({ root, stateRoot, profile, + engine, platform, stdin, stdout, @@ -1657,7 +1660,8 @@ async function reportInstallDiagnostics({ composeCommand(action, root, { profile, stateRoot, - lines: 200 + lines: 200, + engine }), { stdin, stdout, stderr }, { platform, stderr } @@ -1681,6 +1685,7 @@ async function restoreRuntimeAfterFailedInstall({ existingConfig, attemptedProfile, companionsEnabled, + engine, platform, stdin, stdout, @@ -1699,7 +1704,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) { @@ -1718,7 +1724,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) { @@ -2396,8 +2403,10 @@ async function updateCommand(args, { root, config, version, platform, stdin, std return 0; } } - const prerequisites = await ensureDocker({ platform, arch, env, stdin, stdout, stderr, execute, - installArgs: { root, requestedProfile: config.profile, noOpen: true, autoConfirm: true } }); + const prerequisites = config.engine === "podman" + ? await ensurePodmanAvailable({ platform, stdout, stderr, execute }) + : await ensureDocker({ platform, arch, env, stdin, stdout, stderr, execute, + installArgs: { root, requestedProfile: config.profile, noOpen: true, autoConfirm: true } }); if (prerequisites.reexecuted || prerequisites.code !== 0) return prerequisites.code; const resources = await inspectResources(root); const storagePlan = await inspectStoragePlan({ root, config: { ...config, version: targetVersion }, existingConfig: config, resources, execute, operation: "upgrade" }); diff --git a/packages/run-spaceapp/src/index.mjs b/packages/run-spaceapp/src/index.mjs index f0a03b8b7..1da6c191f 100644 --- a/packages/run-spaceapp/src/index.mjs +++ b/packages/run-spaceapp/src/index.mjs @@ -729,12 +729,14 @@ export function renderWorkspaceCompose(config, { cliSandbox = false } = {}) { " - type: bind", ` source: ${JSON.stringify(workspace.hostPath)}`, ` target: ${JSON.stringify(workspace.containerPath)}`, - ` read_only: ${workspace.readOnly}` + ` read_only: ${workspace.readOnly}`, + ...(isPodman ? [" bind:", " selinux: z"] : []) ]; const userServices = users.flatMap((user) => { const slug = userSlug(user.id); return [ ` spaceapp-cli-${slug}:`, + ...podmanUserns, // 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", @@ -763,6 +765,7 @@ export function renderWorkspaceCompose(config, { cliSandbox = false } = {}) { return [ "services:", " spaceapp-core:", + ...podmanUserns, " environment:", ' SPACE_MULTI_USER_ENABLED: "true"', " SPACE_CLI_HOST_SOCKETS_FILE: /etc/spaceapp/cli-hosts.json", @@ -771,6 +774,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:", + ...podmanUserns, ...service, ...(cliSandbox ? CLI_SANDBOX_OPTIONS : []), ...userServices, @@ -1008,8 +1012,10 @@ export async function prepareInstallation(root, { ? undefined : resolveInstallProfile(profile, profile === "auto" ? await inspectSystemResources(root) : totalmem()); let config; + let existingLoaded = false; try { config = await loadConfig(root); + existingLoaded = true; } catch (error) { if (error?.code !== "ENOENT") { throw error; @@ -1021,7 +1027,8 @@ export async function prepareInstallation(root, { companionsEnabled, ...(engine !== undefined ? { engine } : {}) }); - } else if (engine !== undefined) { + } + if (existingLoaded && engine !== undefined) { const existingEngine = config.engine ?? "docker"; const targetEngine = resolveInstallEngine(engine, existingEngine); if (existingEngine !== targetEngine) { diff --git a/packages/run-spaceapp/tests/podman.test.mjs b/packages/run-spaceapp/tests/podman.test.mjs index ca90eb09d..d0eadd8cc 100644 --- a/packages/run-spaceapp/tests/podman.test.mjs +++ b/packages/run-spaceapp/tests/podman.test.mjs @@ -5,9 +5,11 @@ import { join } from "node:path"; import { Readable, Writable } from "node:stream"; import test from "node:test"; import { + addUser, addWorkspace, composeCommand, createDefaultConfig, + initializeInstallation, prepareInstallation, renderWorkspaceCompose, resolveInstallEngine, @@ -79,13 +81,27 @@ test("host-root access is refused on Podman with a clear error", () => { test("switching engines on an existing installation is refused to protect existing volumes", async () => { const root = await mkdtemp(join(tmpdir(), "spaceapp-engine-switch-")); - await prepareInstallation(root, { version: "1.1.7", profile: "small", engine: "docker" }); + await initializeInstallation(root, { version: "1.1.7", profile: "small", engine: "docker" }); await assert.rejects( () => prepareInstallation(root, { version: "1.1.7", profile: "small", engine: "podman" }), /Cannot switch container engine from docker to podman/i ); }); +test("multi-user Podman workspace compose includes keep-id userns_mode and SELinux z on per-user mounts", async () => { + const root = await mkdtemp(join(tmpdir(), "spaceapp-podman-multiuser-")); + const wsDir = join(root, "user-repo"); + await mkdir(wsDir, { recursive: true }); + + let config = addUser(createDefaultConfig({ version: "1.1.7", profile: "small", engine: "podman" }), "user:alice"); + config = await addWorkspace(config, wsDir, { userId: "user:alice" }); + + const compose = renderWorkspaceCompose(config); + const matches = compose.match(/userns_mode: "keep-id:uid=10001,gid=10001"/g) ?? []; + assert.equal(matches.length, 3); + assert.match(compose, /selinux: z/); +}); + test("inspectStoragePlan probes podman info and podman image inspect when engine is podman", async () => { const root = await mkdtemp(join(tmpdir(), "spaceapp-podman-storage-")); const commands = []; From 536ec37e95be862a096ca3cf48c0d2854b94c9a2 Mon Sep 17 00:00:00 2001 From: big-chungus <346867131+bigchungusai@users.noreply.github.com> Date: Fri, 23 Oct 2026 12:33:00 +0000 Subject: [PATCH 3/3] fix(run-spaceapp): report specific failed Podman readiness probe in ensurePodmanAvailable (#63) Signed-off-by: big-chungus <346867131+bigchungusai@users.noreply.github.com> --- packages/run-spaceapp/src/prerequisites.mjs | 14 +++++++++++--- 1 file changed, 11 insertions(+), 3 deletions(-) diff --git a/packages/run-spaceapp/src/prerequisites.mjs b/packages/run-spaceapp/src/prerequisites.mjs index d294fe843..00e636066 100644 --- a/packages/run-spaceapp/src/prerequisites.mjs +++ b/packages/run-spaceapp/src/prerequisites.mjs @@ -690,13 +690,21 @@ export async function ensurePodmanAvailable({ stderr.write("Podman engine support is available on Linux and macOS only.\n"); return { code: 1, reexecuted: false }; } - if (await podmanIsReady(execute)) { + const cliOk = await execute({ command: "podman", args: ["--version"] }, { stdout: null, stderr: null }) === 0; + const composeOk = cliOk && await execute({ command: "podman", args: ["compose", "version"] }, { stdout: null, stderr: null }) === 0; + const infoOk = composeOk && await execute({ command: "podman", args: ["info"] }, { stdout: null, stderr: null }) === 0; + if (cliOk && composeOk && infoOk) { return { code: 0, reexecuted: false }; } + const failedProbe = !cliOk + ? "podman --version" + : !composeOk + ? "podman compose version (run `podman compose version` to inspect the resolved Compose provider)" + : "podman info"; stderr.write( platform === "darwin" - ? "Podman and `podman compose` (Podman >= 5 with docker-compose v2 provider) must be installed and your Podman machine must be running (`podman machine init --cpus 4 --memory 8192 --disk-size 25 && podman machine start`).\n" - : "Rootless Podman (>= 5) and `podman compose` (with docker-compose v2 provider) must be installed and ready (for example `sudo dnf install -y podman podman-compose docker-compose` on Fedora/RHEL or `sudo apt-get install -y podman podman-compose docker-compose-v2` on Ubuntu/Debian).\n" + ? `Podman check failed at \`${failedProbe}\`. Podman (>= 5) and a Compose provider must be installed and your Podman machine must be running (\`podman machine init --cpus 4 --memory 8192 --disk-size 25 && podman machine start\`).\n` + : `Podman check failed at \`${failedProbe}\`. Rootless Podman (>= 5, cgroup v2) and \`podman compose\` must be installed and ready (verify with \`podman compose version\`).\n` ); return { code: 1, reexecuted: false }; }