diff --git a/README.md b/README.md index 58cbbeb67..5294ffe14 100644 --- a/README.md +++ b/README.md @@ -92,7 +92,9 @@ 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 or Podman. + - **Docker** (default): If Docker is not installed, the installer automatically installs official Docker Desktop (Windows/macOS) or Docker Engine (Linux). + - **Podman**: Rootless Podman (≥ 5 with `podman-compose`) is supported on Linux and macOS (`podman machine`). Pass `--engine podman` to select it. ### Run the Installation Command @@ -100,6 +102,8 @@ Run the universal command in your terminal: ```bash npx --yes run-spaceapp@latest install +# Or explicitly select Podman: +npx --yes run-spaceapp@latest install --engine podman ``` > **Windows 11 Tip**: Use **Command Prompt** (`cmd.exe`) or the Command Prompt profile in Windows Terminal. If PowerShell has a restricted execution policy, run `npx.cmd --yes run-spaceapp@latest install`. diff --git a/docs/getting-started.md b/docs/getting-started.md index 68ac684cb..8ff043e28 100644 --- a/docs/getting-started.md +++ b/docs/getting-started.md @@ -87,6 +87,29 @@ the installer resumes automatically in Command Prompt without entering the command again. SpaceApp reuses Docker Desktop's WSL2 Linux environment and does not install a second full virtual machine. +## Podman support (rootless Linux and macOS) + +SpaceApp supports rootless **Podman** (Podman ≥ 5 with `podman-compose` or `docker-compose` as compose provider) on Linux and macOS (`podman machine`). + +To install using Podman: + +```bash +npx --yes run-spaceapp@latest install --engine podman +``` + +Key considerations and operational details: +- **Engine Selection**: Specifying `--engine podman` records the engine in `config.json`. All future lifecycle and operational commands (`up`, `down`, `logs`, `status`, `backup`, `restore`, `doctor`, `uninstall`) automatically use `podman compose`. If `--engine` is omitted, Docker is preferred if detected. If only Podman is available, the launcher suggests Podman or selects it automatically when `--non-interactive` is passed. +- **SELinux Enforcing**: On distributions like Fedora, Red Hat Enterprise Linux, and CentOS Stream where SELinux is set to Enforcing, workspace bind mounts (`workspace add`) are automatically configured with the `:z` flag (`bind: { selinux: "z" }`) so that rootless containers can access workspace files with correct file labels. +- **Data Safety & Engine Isolation**: Docker and Podman use separate container storage and named volumes. Switching engines in-place on an existing installation is blocked to prevent accidental data loss. To migrate between Docker and Podman, export your database with `spaceapp backup`, reinstall with the target `--engine`, and then restore with `spaceapp restore`. +- **Security Boundaries**: Host-root mode (`--access host-root`) is **strictly refused** under Podman. +- **macOS Podman Machine**: On macOS, Podman operates inside a virtual machine managed by `podman machine`. The launcher verifies that the active machine has sufficient CPU, RAM, and disk for your chosen profile. If machine resources are insufficient, the launcher provides specific commands to reconfigure it: + ```bash + podman machine stop + podman machine set --cpus 4 --memory 8192 --disk-size 50 + podman machine start + ``` +- **Diagnostics (`doctor`)**: Running `spaceapp doctor` with a Podman installation reports the Podman version, compose provider status, rootless status, cgroup version (cgroup v2), and whether CPU/memory limits are delegated. + ## Installation profiles The universal install command defaults to `--profile auto`: diff --git a/packages/run-spaceapp/README.md b/packages/run-spaceapp/README.md index a6defcc3b..42f181c70 100644 --- a/packages/run-spaceapp/README.md +++ b/packages/run-spaceapp/README.md @@ -41,7 +41,22 @@ credential-free config so Docker Desktop's graphical credential helper is never invoked outside an interactive logon. The launcher selects `small`, `medium`, or `large` automatically using CPU, -RAM and free disk. Docker's CPU and RAM allocation is checked as well. +RAM and free disk. Docker and Podman resource allocations are checked as well. + +### Container Engines: Docker and Podman + +The launcher defaults to Docker. It also supports **rootless Podman** (Podman ≥ 5 with `podman-compose` or `docker-compose` provider) on Linux and macOS (`podman machine`): + +```bash +npx --yes run-spaceapp@latest install --engine podman +``` + +- **Selection**: Pass `--engine docker|podman`. The selected engine is saved in `config.json` and reused by all subsequent commands (`up`, `down`, `backup`, `restore`, `doctor`, etc.). When `--engine` is omitted, Docker is preferred if available. If only Podman is detected, interactive setup prompts to use it (or selects it automatically with `--non-interactive`). +- **SELinux**: On Linux distributions with SELinux enforcing (such as Fedora/RHEL), bind mounts configured via `workspace add` are automatically tagged with `:z` (`selinux: "z"`) to ensure correct permissions. +- **Data safety**: Docker volumes are not accessible to Podman (and vice-versa). Directly switching engines on an existing installation is blocked. To switch engines safely, run `spaceapp backup` to export an archive, reinstall with `--engine `, and run `spaceapp restore`. +- **Host-root access**: `--access host-root` is strictly refused when running on Podman. +- **macOS Podman Machine**: On macOS, the launcher inspects CPU, memory, and disk allocated to the default `podman machine` and guides you to resize it (`podman machine stop`, `podman machine set --cpus ... --memory ...`, `podman machine start`) if requirements are not met. + | Profile | CPU | Usable RAM | Fresh free space | Services | | --- | --- | --- | --- | --- | diff --git a/packages/run-spaceapp/src/cli.mjs b/packages/run-spaceapp/src/cli.mjs index df753b52a..0b6a7c147 100644 --- a/packages/run-spaceapp/src/cli.mjs +++ b/packages/run-spaceapp/src/cli.mjs @@ -5,6 +5,7 @@ import { createInterface } from "node:readline"; import { once } from "node:events"; import { PromptController } from "./prompt-controller.mjs"; import { + access, copyFile, mkdir, mkdtemp, @@ -38,14 +39,20 @@ import { saveConfig, selectLatestBackupId, SPACEAPP_UPGRADE_POLICY, + SUPPORTED_ENGINES, upgradePath, writeCredential, writeRuntimeFiles, writeSetupToken } from "./index.mjs"; -import { inspectStoragePlan, storageChecks } from "./storage.mjs"; +import { captureProbe, inspectStoragePlan, storageChecks } from "./storage.mjs"; import { + detectAvailableEngines, + dockerIsReady, ensureDockerAvailable, + ensureEngineAvailable, + podmanInstallHelp, + podmanIsReady, prepareDockerCliPath, windowsPowerShellArgs } from "./prerequisites.mjs"; @@ -74,6 +81,7 @@ const trustedCommands = new Set([ "docker", "hdiutil", "open", + "podman", "powershell.exe", "sg", "shutdown.exe", @@ -638,11 +646,13 @@ async function installCommand(args, { const { requestedProfile, requestedAccessMode, + requestedEngine, noOpen, companionsEnabled, telemetryEnabled } = wizard; const existingConfig = await loadExistingInstallation(root); + const engine = requestedEngine ?? existingConfig?.engine ?? "docker"; const accessMode = resolveInstallAccessMode( requestedAccessMode, existingConfig?.accessMode ?? "isolated" @@ -655,7 +665,18 @@ async function installCommand(args, { "Host-root access is disabled for this launcher-only candidate because its runtime images were not rebuilt. Use --access isolated." ); } - await prepareDockerPath({ platform, env }); + if (engine === "podman" && accessMode === "host-root") { + throw new Error("Host-root access is not supported with the Podman engine."); + } + if (engine === "docker") { + await prepareDockerPath({ platform, env }); + } + if (platform === "darwin" && engine === "podman") { + const machineOk = await checkPodmanMachineOnMac(execute, requestedProfile === "auto" ? "small" : requestedProfile, stderr); + if (!machineOk) { + return 1; + } + } const resources = await inspectResources(root); let profile = requestedProfile === "auto" && existingConfig ? existingConfig.profile : resolveInstallProfile(requestedProfile, resources); @@ -668,6 +689,7 @@ async function installCommand(args, { version: runtimeVersion, profile, accessMode, + engine, ...(companionsEnabled ? { companionsEnabled } : {}) }); // The stored choice also drives the app's first_answer event after later updates, so an explicit @@ -683,6 +705,9 @@ async function installCommand(args, { stdout.write( `Profile: ${existingConfig?.profile ?? "not configured"} -> ${result.config.profile}\n` ); + stdout.write( + `Engine: ${existingConfig?.engine ?? "not configured"} -> ${result.config.engine}\n` + ); stdout.write( `Access: ${existingConfig?.accessMode ?? "not configured"} -> ${result.config.accessMode}\n` ); @@ -704,7 +729,9 @@ async function installCommand(args, { stdin, inspectResources, resources, - profile, operation + profile, + engine, + operation }); stderr.write( `Installation stopped before downloading images. Fix the failed checks and run "${UNIVERSAL_COMMAND} install" again.\n` @@ -713,6 +740,7 @@ async function installCommand(args, { } const prerequisiteResult = await ensureDocker({ + engine, platform, arch, env, @@ -720,7 +748,7 @@ async function installCommand(args, { stdout, stderr, execute, - installArgs: { root, requestedProfile, requestedAccessMode, noOpen, autoConfirm: true } + installArgs: { root, requestedProfile, requestedAccessMode, requestedEngine: engine, noOpen, autoConfirm: true } }); if (prerequisiteResult.reexecuted) { return prerequisiteResult.code; @@ -752,6 +780,7 @@ async function installCommand(args, { inspectResources, resources, profile, + engine, dockerReady: true, operation, storagePlan }); if (doctorCode !== 0) { @@ -1014,6 +1043,22 @@ async function removeInactiveProfileServices({root, config, stateRoot = root, ex return 0; } +async function executableInPath(name, env = process.env, platform = process.platform) { + const pathVal = env?.PATH || env?.Path || ""; + const sep = platform === "win32" ? ";" : ":"; + const exts = platform === "win32" ? [".exe", ".cmd", ".bat", ""] : [""]; + const dirs = pathVal.split(sep).filter(Boolean); + for (const dir of dirs) { + for (const ext of exts) { + try { + await access(join(dir, `${name}${ext}`)); + return true; + } catch {} + } + } + return false; +} + async function planInteractiveSetup({ args, root, @@ -1056,6 +1101,7 @@ async function planInteractiveSetup({ return { requestedProfile: actions.profile ?? "auto", requestedAccessMode: actions.accessMode, + requestedEngine: actions.engine, noOpen: actions.noOpen !== false, companionsEnabled: actions.companionsEnabled ?? false, telemetryEnabled: actions.telemetry ?? false @@ -1071,6 +1117,26 @@ async function planInteractiveSetup({ { label: "light (legacy compatibility)", value: "light" }, { label: "standard (legacy compatibility)", value: "standard" } ], { defaultIndex: Math.max(0, ["auto", "small", "medium", "large", "light", "standard"].indexOf(parsed.requestedProfile)), answerKey: "profile" }); + const promptController = stdin?._spaceappPromptController; + const answeredEngine = promptController?.answers?.engine; + let engineChoice = parsed.requestedEngine ?? (answeredEngine === "docker" || answeredEngine === "podman" ? answeredEngine : undefined); + if (!engineChoice) { + const hasDocker = await executableInPath("docker", env, platform); + const hasPodman = await executableInPath("podman", env, platform); + if (!hasDocker && hasPodman) { + if (!interactiveAvailable(stdin)) { + engineChoice = "podman"; + } else { + engineChoice = await promptChoice(stdin, stdout, "Which container engine?", [ + { label: "podman (detected rootless container engine)", value: "podman" }, + { label: "docker (install Docker automatically)", value: "docker" } + ], { defaultIndex: 0, answerKey: "engine" }); + } + } else { + engineChoice = "docker"; + } + } + const accessChoice = await promptChoice(stdin, stdout, "Which access mode?", [ { label: "isolated (recommended; no host access)", value: "isolated" }, { label: "host-root (Linux only; CLI sessions can read and modify the whole host)", value: "host-root" } @@ -1078,7 +1144,7 @@ async function planInteractiveSetup({ const companionsChoice = await promptYesNo(stdin, stdout, "Enable companion integrations (Claude Code, browser companions)?", { defaultYes: parsed.companionsEnabled, answerKey: "companions" }); const telemetryChoice = await promptYesNo(stdin, stdout, "Enable anonymous usage telemetry?", { defaultYes: false, answerKey: "telemetry" }); let dockerChoice = true; - if (platform === "win32") { + if (engineChoice === "docker" && platform === "win32") { const dockerProbe = await detectDockerAvailable({ execute }); if (dockerProbe !== 0) { dockerChoice = await promptYesNo(stdin, stdout, "Docker Engine was not detected. Include automatic Docker installation?", { defaultYes: true }); @@ -1092,9 +1158,10 @@ async function planInteractiveSetup({ `Runtime image version: not installed -> ${runtimeVersion}`, `Profile: ${profileChoice === "auto" ? `auto (${resolved})` : profileChoice}`, `Access mode: ${accessChoice}`, + `Container engine: ${engineChoice}`, `Companions: ${companionsChoice ? "enabled" : "disabled"}`, `Telemetry: ${telemetryChoice ? "enabled" : "disabled"}`, - `Docker installation: ${dockerChoice ? "automatic if missing" : "not included"}`, + ...(engineChoice === "docker" ? [`Docker installation: ${dockerChoice ? "automatic if missing" : "not included"}`] : []), `Open browser afterwards: ${openBrowserChoice ? "yes" : "no"}`, "Future refreshes preserve data, workspaces, credentials, secrets, and persistent Docker volumes." ]); @@ -1105,6 +1172,7 @@ async function planInteractiveSetup({ await offerUnattendedContinuation(root, runtimeVersion, stdin, stdout, { profile: profileChoice, accessMode: accessChoice, + engine: engineChoice, companionsEnabled: companionsChoice, telemetry: telemetryChoice, noOpen: !openBrowserChoice @@ -1113,6 +1181,7 @@ async function planInteractiveSetup({ return { requestedProfile: profileChoice, requestedAccessMode: accessChoice, + requestedEngine: engineChoice, noOpen: !openBrowserChoice, companionsEnabled: companionsChoice, telemetryEnabled: telemetryChoice @@ -1199,6 +1268,12 @@ function hasRequestedConfigChange(rawExisting, parsed) { if (!rawExisting) { return false; } + if ( + parsed.requestedEngine !== undefined && + (rawExisting.engine ?? "docker") !== parsed.requestedEngine + ) { + return true; + } if ( parsed.requestedAccessMode !== undefined && rawExisting.accessMode !== parsed.requestedAccessMode @@ -1641,6 +1716,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) { @@ -1671,8 +1747,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 ( @@ -1680,13 +1765,17 @@ function parseInstallArgs(args) { ( requestedAccessMode !== undefined && !["isolated", "host-root"].includes(requestedAccessMode) + ) || + ( + requestedEngine !== undefined && + !["docker", "podman"].includes(requestedEngine) ) ) { throw new Error( - `Usage: ${UNIVERSAL_COMMAND} install [--profile auto|small|medium|large|light|standard] [--access isolated|host-root] [--with-companions] [--no-open]` + `Usage: ${UNIVERSAL_COMMAND} install [--profile auto|small|medium|large|light|standard] [--access isolated|host-root] [--engine docker|podman] [--with-companions] [--no-open]` ); } - return { requestedProfile, requestedAccessMode, noOpen, companionsEnabled }; + return { requestedProfile, requestedAccessMode, requestedEngine, noOpen, companionsEnabled }; } async function loadExistingInstallation(root) { @@ -1904,6 +1993,7 @@ async function supportBundleCommand({ ? { version: config.version, profile: config.profile, + engine: config.engine ?? "docker", accessMode: config.accessMode, companionsEnabled: config.companionsEnabled, bindHost: config.bindHost, @@ -1912,10 +2002,24 @@ async function supportBundleCommand({ } : null }, + containerEngine: null, services: {}, health: {} }; + const currentEngine = config?.engine ?? "docker"; + const engineDetails = await inspectEngineDoctorDetails(execute, currentEngine); + if (engineDetails) { + bundle.containerEngine = { + engine: engineDetails.engine, + version: engineDetails.version, + rootless: engineDetails.rootless, + mode: engineDetails.mode, + cgroupVersion: engineDetails.cgroupVersion, + limitsDelegated: engineDetails.limitsDelegated + }; + } + if (config) { const url = `http://${config.bindHost}:${config.port}`; if (typeof request === "function") { @@ -2110,15 +2214,20 @@ async function doctor({ inspectResources, resources, profile, + engine: explicitEngine, operation, storagePlan, dockerReady = false }) { - const configuration = profile ? { ok: true, detail: "Prepared installation configuration" } : await loadConfig(root).then(() => ({ ok: true, detail: root })).catch((error) => ({ ok: false, detail: `${error.message}. Run install for a new setup, or doctor --fix for an existing setup.` })); + const existingConfig = await loadConfig(root).catch(() => null); + const configuration = profile + ? { ok: true, detail: "Prepared installation configuration" } + : (existingConfig + ? { ok: true, detail: root } + : await loadConfig(root).then(() => ({ ok: true, detail: root })).catch((error) => ({ ok: false, detail: `${error.message}. Run install for a new setup, or doctor --fix for an existing setup.` }))); const detectedResources = resources ?? await inspectResources(root); let resolvedProfile = profile; if (!resolvedProfile) { - const existingConfig = await loadConfig(root).catch(() => null); if (existingConfig?.profile) { resolvedProfile = existingConfig.profile; } else if (detectedResources?.totalMemoryBytes) { @@ -2127,7 +2236,8 @@ async function doctor({ resolvedProfile = "light"; } } - operation ??= await loadConfig(root).then(() => "maintenance").catch(() => "fresh"); + const currentEngine = explicitEngine ?? existingConfig?.engine ?? "docker"; + operation ??= existingConfig ? "maintenance" : "fresh"; const resourceChecks = installResourceChecks(storagePlan?.resources ?? detectedResources, resolvedProfile, { operation, ...(storagePlan ? { requiredDiskBytes: storagePlan.requiredDiskBytes } : {}) }); @@ -2137,52 +2247,77 @@ async function doctor({ ...resourceChecks.filter((check) => !storagePlan || check.name !== "Free disk"), ...(storagePlan ? storageChecks(storagePlan, detectedResources) : []) ]; - const dockerResults = []; + const engineResults = []; + const enginePrefix = currentEngine === "podman" ? "Podman" : "Docker"; 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: `${enginePrefix} CLI`, command: currentEngine, args: ["--version"] }, + { name: `${enginePrefix} Compose`, command: currentEngine, args: ["compose", "version"] }, + { name: `${enginePrefix} Engine`, command: currentEngine, args: ["info"] } ]) { const code = dockerReady ? 0 : await execute(probe, { stdin, stdout: null, stderr: null }); - dockerResults.push({ ...probe, code }); + engineResults.push({ ...probe, code }); } - const [dockerCli, dockerCompose, dockerEngine] = dockerResults; + const [engineCli, engineCompose, engineDaemon] = engineResults; checks.push({ - name: dockerCli.name, - ok: dockerCli.code === 0, - detail: dockerCli.code === 0 + name: engineCli.name, + ok: engineCli.code === 0, + detail: engineCli.code === 0 ? "available" - : dockerCli.code === 127 + : engineCli.code === 127 ? "not found on PATH" - : `unavailable (exit ${dockerCli.code})` + : `unavailable (exit ${engineCli.code})` }); checks.push({ - name: dockerCompose.name, - ok: dockerCompose.code === 0, - detail: dockerCompose.code === 0 + name: engineCompose.name, + ok: engineCompose.code === 0, + detail: engineCompose.code === 0 ? "available" - : dockerCli.code !== 0 - ? "not available because Docker CLI is missing" - : `plugin unavailable (exit ${dockerCompose.code})` + : engineCli.code !== 0 + ? `not available because ${enginePrefix} CLI is missing` + : `plugin unavailable (exit ${engineCompose.code})` }); checks.push({ - name: dockerEngine.name, - ok: dockerEngine.code === 0, - detail: dockerEngine.code === 0 + name: engineDaemon.name, + ok: engineDaemon.code === 0, + detail: engineDaemon.code === 0 ? "available" - : dockerCli.code === 0 + : engineCli.code === 0 ? "installed but not running or inaccessible" - : "not reachable because Docker CLI is missing" + : `not reachable because ${enginePrefix} CLI is missing` }); + + if (!dockerReady && engineDaemon.code === 0) { + const details = await inspectEngineDoctorDetails(execute, currentEngine); + if (details) { + checks.push({ + name: `${enginePrefix} details`, + ok: true, + detail: `engine ${details.engine}, version ${details.version}, ${details.mode}, cgroup ${details.cgroupVersion}` + }); + checks.push({ + name: "Resource limits delegation", + ok: details.limitsDelegated, + detail: details.limitsDetail + }); + } + } + + if (!dockerReady && platform === "darwin" && currentEngine === "podman" && engineDaemon.code === 0) { + const machineCheck = await checkPodmanMachineDoctor(execute, resolvedProfile); + if (machineCheck) { + checks.push(machineCheck); + } + } + for (const check of checks) { (check.ok ? stdout : stderr).write(`${check.ok ? "PASS" : "FAIL"} ${check.name}: ${check.detail}\n`); } - if (dockerCli.code !== 0 || dockerCompose.code !== 0) { - stderr.write(`${dockerInstallHelp(platform)}\n`); - } else if (dockerEngine.code !== 0) { - stderr.write(`${dockerEngineHelp(platform)}\n`); + if (engineCli.code !== 0 || engineCompose.code !== 0) { + stderr.write(currentEngine === "podman" ? `${podmanInstallHelp(platform)}\n` : `${dockerInstallHelp(platform)}\n`); + } else if (engineDaemon.code !== 0) { + stderr.write(currentEngine === "podman" ? `${podmanEngineHelp(platform)}\n` : `${dockerEngineHelp(platform)}\n`); } return checks.every((check) => check.ok) ? 0 : 1; } @@ -2361,6 +2496,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); @@ -2449,6 +2585,112 @@ function dockerEngineHelp(platform) { return `Start Docker Engine, verify the current user can access it, then run "${UNIVERSAL_COMMAND} doctor" again.`; } +function podmanEngineHelp(platform) { + if (platform === "darwin") { + return `Start your Podman machine: "podman machine start", then run "${UNIVERSAL_COMMAND} doctor" again.`; + } + return `Start the Podman system service or verify the rootless socket is running, then run "${UNIVERSAL_COMMAND} doctor" again.`; +} + +async function checkPodmanMachineOnMac(execute, profile, stderr) { + const req = PROFILE_REQUIREMENTS[profile] ?? PROFILE_REQUIREMENTS.small; + let info = null; + try { + const probe = await captureProbe(execute, { command: "podman", args: ["info", "--format", "{{json .}}"] }); + if (probe) info = JSON.parse(probe); + } catch { + return true; + } + if (!info?.host) return true; + const hostCpus = Number(info.host.cpus ?? 0); + const hostMem = Number(info.host.memTotal ?? 0); + if ((hostCpus > 0 && hostCpus < req.cpuCount) || (hostMem > 0 && hostMem < req.memoryBytes)) { + stderr.write( + `Podman machine does not have enough resources for profile "${profile}".\n` + + `Detected: ${hostCpus} CPUs, ${formatGibibytes(hostMem)} GiB RAM.\n` + + `Required: ${req.cpuCount} CPUs, ${formatGibibytes(req.memoryBytes)} GiB RAM.\n` + + "To resize your Podman machine, run:\n" + + " podman machine stop\n" + + ` podman machine set --cpus ${req.cpuCount} --memory ${Math.ceil(req.memoryBytes / (1024 ** 2))}\n` + + " podman machine start\n" + ); + return false; + } + return true; +} + +async function checkPodmanMachineDoctor(execute, profile) { + const req = PROFILE_REQUIREMENTS[profile] ?? PROFILE_REQUIREMENTS.small; + let info = null; + try { + const probe = await captureProbe(execute, { command: "podman", args: ["info", "--format", "{{json .}}"] }); + if (probe) info = JSON.parse(probe); + } catch { + return null; + } + if (!info?.host) return null; + const hostCpus = Number(info.host.cpus ?? 0); + const hostMem = Number(info.host.memTotal ?? 0); + const ok = hostCpus >= req.cpuCount && hostMem >= req.memoryBytes; + return { + name: "Podman machine resources", + ok, + detail: ok + ? `${hostCpus} CPUs, ${formatGibibytes(hostMem)} GiB memory allocated for ${profile}` + : `${hostCpus} CPUs, ${formatGibibytes(hostMem)} GiB memory allocated; ${req.cpuCount} CPUs, ${formatGibibytes(req.memoryBytes)} GiB required for ${profile}. Resize with: podman machine stop && podman machine set --cpus ${req.cpuCount} --memory ${Math.ceil(req.memoryBytes / (1024 ** 2))} && podman machine start` + }; +} + +export async function inspectEngineDoctorDetails(execute, engine = "docker") { + const jsonStr = await captureProbe(execute, { command: engine, args: ["info", "--format", "{{json .}}"] }); + if (!jsonStr) { + return null; + } + let info = null; + try { + info = JSON.parse(jsonStr); + } catch { + return null; + } + if (engine === "podman") { + const version = info.version?.Version || info.Client?.Version || info.Server?.Version || "unknown"; + const isRootless = Boolean(info.host?.security?.rootless ?? true); + const cgroupRaw = info.host?.cgroupsVersion || info.host?.cgroupVersion || "2"; + const cgroupVersion = String(cgroupRaw).startsWith("v") ? String(cgroupRaw) : `v${cgroupRaw}`; + const controllers = Array.isArray(info.host?.cgroupControllers) ? info.host.cgroupControllers : []; + const limitsDelegated = !isRootless || cgroupVersion === "v1" || (controllers.length === 0 || (controllers.includes("cpu") && controllers.includes("memory"))); + return { + engine: "podman", + version, + rootless: isRootless, + mode: isRootless ? "rootless" : "rootful", + cgroupVersion, + limitsDelegated, + limitsDetail: limitsDelegated + ? "delegated (CPU and memory limits enforced)" + : "CPU and/or memory limits not delegated in rootless cgroup v2. Container resource limits cannot be applied." + }; + } + const version = info.ServerVersion || "unknown"; + const isRootless = Boolean( + info.SecurityOptions?.some((opt) => String(opt).includes("name=rootless")) || + info.Rootless + ); + const cgroupVersion = info.CgroupVersion ? `v${info.CgroupVersion}` : "v2"; + const limitsDelegated = info.CpuCfsQuota !== false && info.MemoryLimit !== false; + return { + engine: "docker", + version, + rootless: isRootless, + mode: isRootless ? "rootless" : "rootful", + cgroupVersion, + limitsDelegated, + limitsDetail: limitsDelegated + ? "delegated (CPU and memory limits enforced)" + : "CPU and/or memory limits cannot be applied by Docker daemon." + }; +} + function formatGibibytes(bytes) { return Math.floor((bytes / 1024 ** 3) * 10) / 10; } @@ -2464,12 +2706,12 @@ Usage: ${UNIVERSAL_COMMAND} spaceapp Optional global launcher init Create a local SpaceApp installation - install [--profile auto|small|medium|large|light|standard] [--access isolated|host-root] [--with-companions] [--no-open] + install [--profile auto|small|medium|large|light|standard] [--access isolated|host-root] [--engine docker|podman] [--with-companions] [--no-open] Install prerequisites, initialize, and start - reinstall [--keep-data] [--profile auto|small|medium|large|light|standard] [--access isolated|host-root] [--with-companions] [--no-open] + reinstall [--keep-data] [--profile auto|small|medium|large|light|standard] [--access isolated|host-root] [--engine docker|podman] [--with-companions] [--no-open] Clean reinstallation preserving data and credentials repair [--dry-run] Verify and repair configuration and runtime files - up | down | status | logs Manage the Docker application + up | down | status | logs Manage the container application open Open the local web application doctor [--fix] Diagnose; --fix repairs existing config and runtime support-bundle Generate a diagnostic bundle JSON for support @@ -2490,6 +2732,7 @@ Usage: ${UNIVERSAL_COMMAND} uninstall [--purge-data] Remove containers; keep data by default Options: + --engine Container engine (default: docker; rootless podman supported) --plan Dry-run mode: print actions without executing --json Output structured JSON where supported --log-file Append all execution logs to specified file diff --git a/packages/run-spaceapp/src/index.mjs b/packages/run-spaceapp/src/index.mjs index 2415d17c5..0a56249b6 100644 --- a/packages/run-spaceapp/src/index.mjs +++ b/packages/run-spaceapp/src/index.mjs @@ -1,4 +1,5 @@ import { createHash, randomBytes } from "node:crypto"; +import { readFileSync } from "node:fs"; import { chmod, mkdir, @@ -101,9 +102,11 @@ const PROVIDERS = Object.freeze({ ownerInstalled: Object.freeze(["codex", "gemini", "qwen", "kimi", "grok", "autohand", "cursor", "copilot", "claude"]), experimental: Object.freeze(["deepseek"]) }); +export const SUPPORTED_ENGINES = Object.freeze(["docker", "podman"]); const ALL_PROVIDERS = new Set(Object.values(PROVIDERS).flat()); const CONFIG_KEYS = new Set([ "schemaVersion", + "engine", "version", "previousVersion", "bindHost", @@ -287,13 +290,17 @@ 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); + if (engine !== undefined && !SUPPORTED_ENGINES.includes(engine)) { + throw new Error("Container engine must be docker or podman."); + } return { schemaVersion: CONFIG_SCHEMA_VERSION, version, @@ -305,6 +312,7 @@ export function createDefaultConfig({ accessMode: resolvedAccessMode, companionsEnabled, optionalModules: "none", + ...(engine !== undefined ? { engine } : {}), workspaces: [] }; } @@ -346,6 +354,12 @@ export function validateConfig(config) { if (typeof config.companionsEnabled !== "boolean") { throw new Error("companionsEnabled must be boolean."); } + 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 is not supported with the podman engine."); + } if (config.optionalModules !== undefined && !OPTIONAL_MODULES_PATTERN.test(config.optionalModules)) { throw new Error("optionalModules must be all, none or a comma-separated list of module names."); } @@ -569,11 +583,13 @@ export function renderRuntimeEnv(config) { export function renderWorkspaceCompose(config) { 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 service = mounts.length > 0 ? [" volumes:", ...mounts] @@ -623,6 +639,20 @@ export function composeCommand(action, root, options = {}) { validateHome(stateRoot); const profile = options.profile ?? "standard"; const settings = profileRuntimeSettings(profile); + let engine = options.engine ?? (options.config?.engine); + if (!engine) { + try { + const raw = readFileSync(join(stateRoot, "config.json"), "utf8"); + engine = JSON.parse(raw)?.engine; + } catch {} + } + if (!engine) { + try { + const raw = readFileSync(join(root, "config.json"), "utf8"); + engine = JSON.parse(raw)?.engine; + } catch {} + } + engine = engine ?? "docker"; const base = [ "compose", "--project-name", composeProjectName(root), @@ -723,7 +753,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, args: [...base, ...selected] }; } export function composeProjectName(root) { @@ -760,7 +790,8 @@ export async function prepareInstallation(root, { version, profile, accessMode, - companionsEnabled + companionsEnabled, + engine }) { validateHome(root); await mkdir(root, { recursive: true, mode: 0o700 }); @@ -782,9 +813,15 @@ export async function prepareInstallation(root, { version, profile: resolvedProfile ?? "light", accessMode: resolveInstallAccessMode(accessMode), - companionsEnabled + companionsEnabled, + engine: engine ?? "docker" }); } + if (engine !== undefined && config.engine !== undefined && config.engine !== engine) { + throw new Error( + `Switching container engine from "${config.engine}" to "${engine}" on an existing installation is refused because persistent volumes are not shared between engines. Use "${UNIVERSAL_COMMAND} backup" to export your data, reinstall with "--engine ${engine}", and then run "${UNIVERSAL_COMMAND} restore".` + ); + } if (config.version !== version) { config = { ...config, @@ -802,6 +839,11 @@ export async function prepareInstallation(root, { if (companionsEnabled !== undefined && config.companionsEnabled !== companionsEnabled) { config = { ...config, companionsEnabled }; } + if (engine !== undefined) { + config = { ...config, engine }; + } else if (!config.engine) { + config = { ...config, engine: "docker" }; + } 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 c22d4b5f1..cc232b724 100644 --- a/packages/run-spaceapp/src/prerequisites.mjs +++ b/packages/run-spaceapp/src/prerequisites.mjs @@ -653,7 +653,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 +666,81 @@ 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 detectAvailableEngines(execute) { + const [docker, podman] = await Promise.all([ + dockerIsReady(execute), + podmanIsReady(execute) + ]); + return { docker, podman }; +} + +export function podmanInstallHelp(platform) { + if (platform === "darwin") { + return "Install Podman and Podman Compose via Homebrew:\n brew install podman podman-compose\n podman machine init\n podman machine start"; + } + return "Install Podman and Podman Compose via your package manager:\n" + + " Fedora/RHEL: sudo dnf install -y podman podman-compose\n" + + " Ubuntu/Debian: sudo apt update && sudo apt install -y podman podman-compose\n" + + " openSUSE: sudo zypper install podman podman-compose"; +} + +export async function ensureEngineAvailable({ + engine = "docker", + platform = process.platform, + arch = process.arch, + env = process.env, + stdin = process.stdin, + stdout = process.stdout, + stderr = process.stderr, + execute, + launch = launchDetachedCommand, + download = downloadFile, + pathExists = fileExists, + sleep = wait, + osRelease, + installArgs, + scheduleResume = scheduleWindowsInstallResume, + confirmRestart = confirmWindowsRestart +}) { + if (engine === "podman") { + if (await podmanIsReady(execute)) { + return { code: 0, reexecuted: false }; + } + stderr.write("Podman and Podman Compose are required.\n" + podmanInstallHelp(platform) + "\n"); + return { code: 1, reexecuted: false }; + } + return ensureDockerAvailable({ + platform, + arch, + env, + stdin, + stdout, + stderr, + execute, + launch, + download, + pathExists, + sleep, + osRelease, + installArgs, + scheduleResume, + confirmRestart + }); +} + async function installLinuxDockerEngine({ arch, release, diff --git a/packages/run-spaceapp/src/storage.mjs b/packages/run-spaceapp/src/storage.mjs index f755dbc94..9f0c519b4 100644 --- a/packages/run-spaceapp/src/storage.mjs +++ b/packages/run-spaceapp/src/storage.mjs @@ -85,7 +85,10 @@ 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 ?? existingConfig?.engine ?? "docker"; + const rawExecute = execute; + execute = (spec, io) => rawExecute(spec.command === "docker" ? { ...spec, command: engineCommand } : spec, io); + 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(); @@ -134,16 +137,17 @@ 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 storage filesystem directly even on a fresh install. + const storageRootDir = engine?.DockerRootDir || engine?.store?.graphRoot; + if (storageRootDir && process.platform === "linux") { const { inspectSystemResources } = await import("./index.mjs"); - try { dockerFreeDiskBytes = (await inspectSystemResources(engine.DockerRootDir)).freeDiskBytes; } catch {} + try { dockerFreeDiskBytes = (await inspectSystemResources(storageRootDir)).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/cli.test.mjs b/packages/run-spaceapp/tests/cli.test.mjs index f7602f22d..897badad3 100644 --- a/packages/run-spaceapp/tests/cli.test.mjs +++ b/packages/run-spaceapp/tests/cli.test.mjs @@ -200,6 +200,7 @@ test("the process launcher dispatches only hard-coded executable literals", asyn for (const command of [ "codesign", "docker", + "podman", "hdiutil", "open", "sg", diff --git a/packages/run-spaceapp/tests/config.test.mjs b/packages/run-spaceapp/tests/config.test.mjs index 07f02188e..e1d84eaba 100644 --- a/packages/run-spaceapp/tests/config.test.mjs +++ b/packages/run-spaceapp/tests/config.test.mjs @@ -15,7 +15,8 @@ import { resolveInstallAccessMode, resolveInstallProfile, resolveSpaceAppHome, - saveConfig + saveConfig, + validateConfig } from "../src/index.mjs"; test("resolves an explicit home before platform defaults", () => { @@ -300,6 +301,55 @@ test("installation preparation defers committed version and runtime changes", as assert.match(await readFile(join(root, "runtime.env"), "utf8"), /^SPACEAPP_IMAGE_TAG=0\.1\.13$/m); }); +test("validateConfig rejects unsupported engines and podman host-root access", () => { + const baseConfig = createDefaultConfig({ version: "0.1.0" }); + assert.equal(validateConfig({ ...baseConfig, engine: "docker" }).engine, "docker"); + assert.equal(validateConfig({ ...baseConfig, engine: "podman" }).engine, "podman"); + + assert.throws( + () => validateConfig({ ...baseConfig, engine: "containerd" }), + /engine must be docker or podman/i + ); + + assert.throws( + () => validateConfig({ ...baseConfig, engine: "podman", accessMode: "host-root" }), + /host-root access is not supported with the podman engine/i + ); +}); + +test("prepareInstallation enforces engine switching safety and preserves podman engine", async () => { + const root = await mkdtemp(join(tmpdir(), "spaceapp-engine-safety-")); + const templateDir = join(root, "templates"); + await mkdir(templateDir); + await writeFile(join(templateDir, "compose.yml"), "services: {}\n"); + + const initialized = await initializeInstallation(root, { + version: "0.1.10", + templateDir, + profile: "standard", + engine: "podman" + }); + assert.equal(initialized.config.engine, "podman"); + assert.equal((await loadConfig(root)).engine, "podman"); + + // Attempting to switch existing installation to docker must be rejected + await assert.rejects( + () => prepareInstallation(root, { + version: "0.1.11", + engine: "docker" + }), + /Switching container engine from "podman" to "docker"/i + ); + + // Upgrading with the same engine is allowed + const preparedSame = await prepareInstallation(root, { + version: "0.1.11", + engine: "podman" + }); + assert.equal(preparedSame.config.engine, "podman"); +}); + + async function assertOwnerOnlyFile(path) { const metadata = await stat(path); assert.equal(metadata.isFile(), true); diff --git a/packages/run-spaceapp/tests/prerequisites.test.mjs b/packages/run-spaceapp/tests/prerequisites.test.mjs index 0c59d7084..301d9b61c 100644 --- a/packages/run-spaceapp/tests/prerequisites.test.mjs +++ b/packages/run-spaceapp/tests/prerequisites.test.mjs @@ -6,7 +6,11 @@ import { ensureDockerAvailable, prepareDockerCliPath, windowsResumeScript, - windowsPowerShellArgs + windowsPowerShellArgs, + podmanIsReady, + detectAvailableEngines, + podmanInstallHelp, + ensureEngineAvailable } from "../src/prerequisites.mjs"; function capture() { @@ -954,3 +958,52 @@ test('Linux waits for a stopped engine to become ready without unnecessarily cha assert.ok(calls.some(s=>s.args.includes('systemctl'))); assert.ok(!calls.some(s=>s.args.includes('usermod'))); }); + +test("podmanIsReady checks podman --version, compose version, and info", async () => { + const calls = []; + const ready = await podmanIsReady(async (spec) => { + calls.push(spec); + return 0; + }); + assert.equal(ready, true); + assert.deepEqual(calls.map((s) => [s.command, ...s.args]), [ + ["podman", "--version"], + ["podman", "compose", "version"], + ["podman", "info"] + ]); + + const notReady = await podmanIsReady(async (spec) => { + return spec.args[0] === "compose" ? 1 : 0; + }); + assert.equal(notReady, false); +}); + +test("detectAvailableEngines reports both docker and podman readiness", async () => { + const result = await detectAvailableEngines(async (spec) => { + if (spec.command === "docker") return 0; + if (spec.command === "podman") return 1; + return 0; + }); + assert.deepEqual(result, { docker: true, podman: false }); +}); + +test("ensureEngineAvailable routes to podman and reports install help when not ready", async () => { + const stderr = capture(); + const failed = await ensureEngineAvailable({ + engine: "podman", + platform: "linux", + stderr: stderr.stream, + execute: async () => 1 + }); + assert.equal(failed.code, 1); + assert.match(stderr.value(), /podman and podman compose are required/i); + assert.match(stderr.value(), /sudo apt install/i); + + const ready = await ensureEngineAvailable({ + engine: "podman", + platform: "linux", + execute: async () => 0 + }); + assert.equal(ready.code, 0); +}); + diff --git a/packages/run-spaceapp/tests/runtime.test.mjs b/packages/run-spaceapp/tests/runtime.test.mjs index f11a45eab..e39918472 100644 --- a/packages/run-spaceapp/tests/runtime.test.mjs +++ b/packages/run-spaceapp/tests/runtime.test.mjs @@ -169,6 +169,34 @@ test("runtime env exposes the companions toggle without leaking credentials", () assert.doesNotMatch(`${enabled}\n${disabled}`, /password|secret|token|api.?key/i); }); +test("composeCommand supports Podman engine and retains project flags and user specs", () => { + const podmanUp = composeCommand("up", home, { engine: "podman" }); + assert.equal(podmanUp.command, "podman"); + assert.equal(podmanUp.args[0], "compose"); + assert.deepEqual(podmanUp.args.slice(1, 5), [ + "--project-name", podmanUp.args[2], + "--project-directory", home + ]); + assert.deepEqual(podmanUp.args.slice(-3), ["up", "-d", "--remove-orphans"]); + + const podmanToken = composeCommand("rotateOwnerSetupToken", home, { engine: "podman" }); + assert.equal(podmanToken.command, "podman"); + assert.deepEqual(podmanToken.args.slice(-8), [ + "exec", "-T", "--user", "10001:10001", "spaceapp-core", + "node", "scripts/rotate-owner-setup-token.mjs", "--stdin" + ]); +}); + +test("workspace override renders SELinux z relabeling when engine is podman", () => { + const podmanConfig = { ...config, engine: "podman" }; + const compose = renderWorkspaceCompose(podmanConfig); + assert.match(compose, /type: bind/); + assert.match(compose, /source: "\/home\/alice\/project"/); + assert.match(compose, /target: "\/workspaces\/project"/); + assert.match(compose, /bind:\s*\n\s*selinux: "z"/); + assert.doesNotMatch(compose, /docker\.sock/); +}); + test("workspace override renders explicit bind mounts without Docker socket access", () => { const compose = renderWorkspaceCompose(config); assert.match(compose, /type: bind/);