diff --git a/.github/workflows/platform-matrix.yml b/.github/workflows/platform-matrix.yml index e09ecb53c..e98c08a9f 100644 --- a/.github/workflows/platform-matrix.yml +++ b/.github/workflows/platform-matrix.yml @@ -57,3 +57,84 @@ 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 + + # Issue #63 acceptance: a real rootless Podman install of the small profile on Linux — install, + # wait for /readyz, back up, purge, and leave nothing running. + podman-linux: + name: Launcher / ubuntu-latest / Podman rootless + runs-on: ubuntu-latest + timeout-minutes: 40 + 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 + + # ubuntu-latest ships Podman 4.9; the launcher supports Podman >= 5, so install it from the + # libcontainers (Kubic) repository, the upstream packagers' channel for current Podman on Ubuntu. + - name: Install Podman 5 + run: | + . /etc/os-release + echo "deb http://download.opensuse.org/repositories/devel:/kubic:/libcontainers:/unstable/xUbuntu_${VERSION_ID}/ /" | sudo tee /etc/apt/sources.list.d/devel-kubic-libcontainers-unstable.list + curl -fsSL "https://download.opensuse.org/repositories/devel:/kubic:/libcontainers:/unstable/xUbuntu_${VERSION_ID}/Release.key" | gpg --dearmor | sudo tee /etc/apt/trusted.gpg.d/devel-kubic-libcontainers-unstable.gpg > /dev/null + sudo apt-get update + sudo apt-get install -y podman + podman --version + + # The supported Compose provider is Docker Compose v2. The runner image carries the Docker CLI + # plugin; expose it where Podman's provider lookup finds it, then prove `podman compose` works. + - name: Provide the Docker Compose v2 provider for podman compose + run: | + if ! podman compose version >/dev/null 2>&1; then + plugin="$(find /usr/libexec/docker /usr/lib/docker /usr/local/lib/docker -name docker-compose -type f 2>/dev/null | head -n 1)" + test -n "$plugin" + sudo ln -sf "$plugin" /usr/local/bin/docker-compose + fi + podman compose version + + # Rootless prerequisites: subordinate id ranges and the API socket the Compose provider dials. + - name: Prepare rootless Podman + run: | + grep -q "^runner:" /etc/subuid || sudo usermod --add-subuids 100000-165535 --add-subgids 100000-165535 runner + mkdir -p "/run/user/$(id -u)/podman" + nohup podman system service --time=0 "unix:///run/user/$(id -u)/podman/podman.sock" >/tmp/podman-service.log 2>&1 & + podman info --format 'rootless={{.Host.Security.Rootless}} cgroup={{.Host.CgroupVersion}} version={{.Version.Version}}' + + - name: Install workspace dependencies + run: npm ci + env: + ELECTRON_SKIP_BINARY_DOWNLOAD: "1" + + - name: Install SpaceApp small on rootless Podman + run: | + node packages/run-spaceapp/bin/spaceapp.mjs install --engine podman --profile small --non-interactive \ + --answers '{"confirm":true,"profile":"small","access":"isolated","engine":"podman","companions":false,"telemetry":false,"open":false}' + env: + SPACEAPP_HOME: ${{ runner.temp }}/spaceapp-home + + - name: Wait for the application to answer on 127.0.0.1:4911 + run: | + for attempt in $(seq 1 60); do + if curl -fsS http://127.0.0.1:4911/readyz; then exit 0; fi + sleep 5 + done + echo "SpaceApp did not become ready" >&2 + exit 1 + + - name: Back up the Podman installation + run: node packages/run-spaceapp/bin/spaceapp.mjs backup + env: + SPACEAPP_HOME: ${{ runner.temp }}/spaceapp-home + + - name: Uninstall with data purge and confirm nothing is left running + run: | + node packages/run-spaceapp/bin/spaceapp.mjs uninstall --purge-data --non-interactive \ + --answers '{"purgeConfirm":"DELETE"}' + test -z "$(podman ps -q)" + test -z "$(podman ps -aq)" + env: + SPACEAPP_HOME: ${{ runner.temp }}/spaceapp-home diff --git a/README.md b/README.md index fd303d486..251556577 100644 --- a/README.md +++ b/README.md @@ -93,6 +93,7 @@ Coordinate multi-step tasks conversationally through the Room Agent or speak nat - 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). +- **Podman (alternative, Linux/macOS)**: `install --engine podman` runs the same stack on rootless Podman >= 5 (cgroup v2) through the Docker Compose v2 provider (`podman compose`). Podman is never installed by the launcher; see the run-spaceapp README's "Container engines" section for requirements, the macOS machine sizing check, and the backup/restore migration used instead of in-place engine switching. ### Run the Installation Command diff --git a/docs/getting-started.md b/docs/getting-started.md index f892f4439..51f3fb40b 100644 --- a/docs/getting-started.md +++ b/docs/getting-started.md @@ -53,6 +53,40 @@ its code signature, installs it, starts it, and waits until it is ready. Docker Desktop uses its managed lightweight Linux environment. Apple Silicon uses the native `linux/arm64` images and Intel Macs use `linux/amd64`. +## Podman instead of Docker (Linux and macOS) + +The same stack runs on rootless Podman. Install Podman yourself first (the +launcher never installs it), then: + +```bash +npx --yes run-spaceapp@latest install --engine podman +``` + +You need Podman >= 5 (on Linux, on cgroup v2) and its Compose provider: +Docker Compose v2, so that `podman compose version` succeeds — the stack +relies on Compose v2 semantics (profiles, file secrets, health conditions, +`--wait`), and podman-compose is not a supported provider. On Linux, start +the rootless user socket the provider connects to: + +```bash +systemctl --user enable --now podman.socket +``` + +On macOS, create and start a Podman machine (`podman machine init && +podman machine start`); `spaceapp doctor` compares the machine's CPUs, +memory, and disk with your profile and prints the `podman machine set` +resize command when it is too small. + +The engine is stored in the configuration and used by every later command. +If Docker is missing but Podman is ready, a plain `install` picks Podman +automatically and says so. Switching engines later is refused — volumes are +engine-private — and the installer prints the supported migration instead: +`backup`, `uninstall --purge-data`, `install --engine `, `restore`. +`--access host-root` stays Docker-only, and Podman on Windows is not +supported. `spaceapp doctor` reports the engine version, rootless state, +cgroup version, and whether memory/CPU limits can be delegated to your user +cgroup; when they cannot, it says so up front rather than failing late. + ## Windows 11 — Command Prompt Enable hardware virtualization and install Node.js 20.11 or newer. Open diff --git a/packages/run-spaceapp/README.md b/packages/run-spaceapp/README.md index 0e8b1ab3b..3f9478679 100644 --- a/packages/run-spaceapp/README.md +++ b/packages/run-spaceapp/README.md @@ -62,6 +62,63 @@ before pausing writers. Existing data is kept in place; the launcher never delet images, backups or volumes to free space. Separate Docker disk capacity is checked when measurable and its availability is reported. +### Container engines: Docker or rootless Podman + +Docker is the default engine. On Linux and macOS the same stack can run on +rootless Podman instead: + +```bash +npx --yes run-spaceapp@latest install --engine podman +``` + +Requirements for the Podman engine: + +- Podman >= 5 on cgroup v2 (Linux). The launcher never installs Podman; when + it is missing or too old it prints the install instructions for your + platform and stops. +- The Compose provider is Docker Compose v2, reached through `podman compose` + (the standalone `docker-compose` binary, or the Docker CLI with its compose + plugin). podman-compose is not a supported provider: the stack relies on + Compose v2 semantics (profiles, file-based secrets, `depends_on` health + conditions including `required: false`, `--wait`). Confirm with + `podman compose version`. +- Rootless Podman needs its user socket for the provider: + `systemctl --user enable --now podman.socket`. +- On macOS, a Podman machine: `podman machine init && podman machine start`. + The launcher checks the machine's CPUs, memory, and disk against the + selected profile in `doctor` and explains how to resize it + (`podman machine set --cpus N --memory MIB --disk-size GIB`). + +The chosen engine is saved in the installation configuration and used by +every later command (`up`, `down`, `status`, `logs`, `update`, `backup`, +`restore`, `doctor`, `uninstall`, the provider installer, and the rest). If +Docker is absent but a supported Podman is ready, a plain `install` picks +Podman automatically and says so; `--engine docker` insists on Docker. + +Engines cannot be switched on an existing installation: volumes are private +to each engine, so the data would be stranded. The installer refuses with the +supported migration — `backup` on the current engine, +`uninstall --purge-data`, `install --engine `, then `restore`. + +Rootless notes: + +- The published images start as root inside the container and drop to + `10001:10001` themselves; the launcher deliberately does **not** set + `userns_mode: keep-id`, which breaks that startup. The default subordinate + UID mapping is correct, and backup/restore one-off containers run as `0:0` + (the mapped root) so they can read every file. +- Workspace bind mounts are rendered with the SELinux `z` relabel option + under Podman, so they work on SELinux-enforcing hosts and keep their host + ownership. Read-only workspaces stay read-only. +- Profile memory/CPU limits (`mem_limit`/`cpus`) need cgroup v2 controller + delegation to your user cgroup. `doctor` reports the engine, version, + rootless state, cgroup version, and delegation; when cpu/memory are not + delegated it says so plainly (the stack runs, limits unenforced) instead of + failing late. The support bundle carries the same facts, never secrets. +- `--access host-root` is refused with the Podman engine (rootless-only + support); use isolated access, or Docker for host-root. Podman on Windows + is not supported. + ### Telemetry Telemetry is off unless you turn it on. The install wizard lists what is sent diff --git a/packages/run-spaceapp/package.json b/packages/run-spaceapp/package.json index 86528ff2a..64397b568 100644 --- a/packages/run-spaceapp/package.json +++ b/packages/run-spaceapp/package.json @@ -36,7 +36,7 @@ ], "scripts": { "test": "node --test tests/*.test.mjs", - "check": "node --check bin/spaceapp.mjs && node --check src/package-info.mjs && node --check src/index.mjs && node --check src/cli.mjs && node --check src/storage.mjs && node --check src/telemetry.mjs && node --check src/prerequisites.mjs", + "check": "node --check bin/spaceapp.mjs && node --check src/package-info.mjs && node --check src/index.mjs && node --check src/cli.mjs && node --check src/storage.mjs && node --check src/telemetry.mjs && node --check src/prerequisites.mjs && node --check src/engine.mjs", "pack:dry-run": "npm pack --dry-run" }, "repository": { diff --git a/packages/run-spaceapp/src/cli.mjs b/packages/run-spaceapp/src/cli.mjs index e0e35fc03..f6b220a2f 100644 --- a/packages/run-spaceapp/src/cli.mjs +++ b/packages/run-spaceapp/src/cli.mjs @@ -27,6 +27,7 @@ import { initializeInstallation, inspectSystemResources, installResourceChecks, + PROFILE_REQUIREMENTS, profileRuntimeSettings, loadConfig, planConfigRepairs, @@ -48,12 +49,26 @@ import { writeRuntimeFiles, writeSetupToken } from "./index.mjs"; -import { inspectStoragePlan, storageChecks } from "./storage.mjs"; +import { captureProbe, inspectStoragePlan, storageChecks } from "./storage.mjs"; import { ensureDockerAvailable, + ensurePodmanAvailable, prepareDockerCliPath, windowsPowerShellArgs } from "./prerequisites.mjs"; +import { + PODMAN_MIN_VERSION, + engineOf, + engineSwitchRefusal, + parseEngineVersion, + podmanInfoFacts, + podmanInstallInstructions, + podmanMachineCheck, + podmanMachineFacts, + probeCgroupDelegation, + resolveInstallEngine, + versionAtLeast +} from "./engine.mjs"; import { HOST_ROOT_RUNTIME_COMPATIBLE, PACKAGE_VERSION, @@ -82,6 +97,7 @@ const trustedCommands = new Set([ "codesign", "docker", "hdiutil", + "podman", "open", "powershell.exe", "sg", @@ -279,6 +295,7 @@ export async function run(argv, { execute = executeCommand, inspectResources = inspectSystemResources, ensureDocker = ensureDockerAvailable, + ensurePodman = ensurePodmanAvailable, prepareDockerPath = prepareDockerCliPath, request = globalThis.fetch, sleep = wait, @@ -344,7 +361,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; @@ -385,6 +402,7 @@ export async function run(argv, { execute, inspectResources, ensureDocker, + ensurePodman, prepareDockerPath, request, sleep, @@ -418,7 +436,7 @@ export async function run(argv, { } if (command === "repair" || command === "doctor" && args.includes("--fix")) { if (args.some((arg) => !["--dry-run", "--fix"].includes(arg))) throw new Error("Usage: repair [--dry-run] or doctor --fix"); - return repairRuntime({ root, config: null, platform, stdin, stdout, stderr, execute, request, sleep, ensureDocker, env, arch, inspectResources, dryRun: args.includes("--dry-run") }); + return repairRuntime({ root, config: null, platform, stdin, stdout, stderr, execute, request, sleep, ensureDocker, ensurePodman, env, arch, inspectResources, dryRun: args.includes("--dry-run") }); } const config = await loadConfig(root); if (commandNeedsRuntimeFiles(command, args)) { @@ -433,7 +451,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: engineOf(config) }), { stdin, stdout, stderr }); } if (command === "open") { assertNoArgs(args, "open"); @@ -495,7 +513,7 @@ export async function run(argv, { execute: runtimeExecute, request, sleep, - inspectResources, ensureDocker, env, arch + inspectResources, ensureDocker, ensurePodman, env, arch }); } if (command === "rollback") { @@ -523,14 +541,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: engineOf(rollback) }), (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: engineOf(rollback) }), { stdin, stdout, stderr }); if (upCode !== 0) { await writeRuntimeFiles(root, config); return upCode; @@ -562,18 +580,18 @@ export async function run(argv, { const backupId = await selectLatestBackupId(root); const backupCode = await backupWithWritersPaused(runtimeExecute, root, config, { stdin, stdout, stderr }); if (backupCode !== 0) return backupCode; - const stopCode = await runtimeExecute(composeCommand("stopForPortableRestore", root, { profile: config.profile, companionsEnabled: config.companionsEnabled }), { stdin, stdout, stderr }); + const stopCode = await runtimeExecute(composeCommand("stopForPortableRestore", root, { profile: config.profile, companionsEnabled: config.companionsEnabled, engine: engineOf(config) }), { stdin, stdout, stderr }); if (stopCode !== 0) return stopCode; // The backup just made holds the data from before the restore. const safetyBackupId = await selectLatestBackupId(root).catch(() => "the newest backup"); const restoreCode = await runtimeExecute( - composeCommand("restore", root, { backupId, profile: config.profile }), + composeCommand("restore", root, { backupId, profile: config.profile, engine: engineOf(config) }), { stdin, stdout, stderr } ); if (restoreCode !== 0) { // Exit 3: the restore changed nothing or put every database and file back, so SpaceApp starts again as it was (1.1.8). if (restoreCode === RESTORE_UNCHANGED_EXIT) { - const againCode = await runtimeExecute(composeCommand("up", root, { profile: config.profile, companionsEnabled: config.companionsEnabled }), { stdin, stdout, stderr }); + const againCode = await runtimeExecute(composeCommand("up", root, { profile: config.profile, companionsEnabled: config.companionsEnabled, engine: engineOf(config) }), { stdin, stdout, stderr }); stderr.write(againCode === 0 ? `The restore of ${backupId} did not happen; SpaceApp runs again with its data from before the restore (also kept in the backup ${safetyBackupId}).\n` : `The restore of ${backupId} did not happen and SpaceApp could not be started again; run "${UNIVERSAL_COMMAND} up". Its data from before the restore is kept in the backup ${safetyBackupId}.\n`); @@ -582,7 +600,7 @@ export async function run(argv, { stderr.write(`The restore of ${backupId} failed and SpaceApp stays stopped: the messages above say what is left at the backup's state. To start SpaceApp anyway, run "${UNIVERSAL_COMMAND} up"; to try again, run "${UNIVERSAL_COMMAND} restore". The data from before the restore is kept in the backup ${safetyBackupId}.\n`); return restoreCode; } - const upCode = await runtimeExecute(composeCommand("up", root, { profile: config.profile, companionsEnabled: config.companionsEnabled }), { stdin, stdout, stderr }); + const upCode = await runtimeExecute(composeCommand("up", root, { profile: config.profile, companionsEnabled: config.companionsEnabled, engine: engineOf(config) }), { stdin, stdout, stderr }); if (upCode !== 0) return upCode; await restoreBackupExtras({ root, config, backupId, stdin, stdout, stderr, execute: runtimeExecute }) .catch((error) => stderr.write(`The data was restored; the CLI settings of the backup were not: ${error.message}\n`)); @@ -600,7 +618,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: engineOf(config) }), { stdin, stdout, stderr } ); if (code === 0) { @@ -622,7 +640,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: engineOf(config) }), { stdin, stdout, stderr } ); if (code === 0) { @@ -656,6 +674,7 @@ async function installCommand(args, { execute, inspectResources, ensureDocker, + ensurePodman = ensurePodmanAvailable, prepareDockerPath, request, sleep, @@ -670,7 +689,7 @@ async function installCommand(args, { stdout, stderr, execute, - inspectResources, request, sleep, ensureDocker, env, arch + inspectResources, request, sleep, ensureDocker, ensurePodman, env, arch }); if (wizard.exit !== undefined) { return wizard.exit; @@ -678,6 +697,7 @@ async function installCommand(args, { const { requestedProfile, requestedAccessMode, + requestedEngine, noOpen, companionsEnabled, telemetryEnabled, @@ -696,6 +716,26 @@ async function installCommand(args, { "Host-root access is disabled for this launcher-only candidate because its runtime images were not rebuilt. Use --access isolated." ); } + // Engine selection (#63): an explicit --engine (or the "engine" automation answer) wins; an existing + // installation keeps its stored engine. Switching engines in place is refused — volumes are private to + // each engine, so the data would be stranded; the backup/restore migration in the refusal is the path. + const existingEngine = engineOf(existingConfig); + if (existingConfig && requestedEngine !== undefined) { + resolveInstallEngine(requestedEngine); + if (requestedEngine !== existingEngine) { + throw new Error(engineSwitchRefusal(existingEngine, requestedEngine)); + } + } + let engine = resolveInstallEngine(requestedEngine, existingEngine); + if (engine === "podman" && platform === "win32") { + throw new Error("The Podman engine is not supported on Windows. Use the Docker engine (Docker Desktop)."); + } + if (engine === "podman" && accessMode === "host-root") { + throw new Error( + "--access host-root is refused with the Podman engine: Podman support is rootless-only. " + + "Use --access isolated with Podman, or the Docker engine for host-root." + ); + } await prepareDockerPath({ platform, env }); const resources = await inspectResources(root); let profile = requestedProfile === "auto" && existingConfig @@ -709,6 +749,8 @@ async function installCommand(args, { version: runtimeVersion, profile, accessMode, + // Stored only when explicitly chosen; an absent field means Docker (the pre-engine configuration shape). + ...(requestedEngine === undefined ? {} : { engine }), ...(companionsEnabled ? { companionsEnabled } : {}) }); // The stored choice also drives the app's first_answer event after later updates, so an explicit @@ -739,6 +781,11 @@ async function installCommand(args, { stdout.write( `Access: ${existingConfig?.accessMode ?? "not configured"} -> ${result.config.accessMode}\n` ); + if (engine === "podman" || requestedEngine !== undefined) { + stdout.write( + `Container engine: ${existingConfig ? existingEngine : "not configured"} -> ${engine}\n` + ); + } stdout.write(existingConfig ? "Preserved existing data, workspaces, credentials, secrets, and persistent Docker volumes.\n" : "Future refreshes preserve data, workspaces, credentials, secrets, and persistent Docker volumes.\n"); @@ -757,6 +804,7 @@ async function installCommand(args, { stdin, inspectResources, resources, + engine, profile, operation }); stderr.write( @@ -766,16 +814,45 @@ async function installCommand(args, { } stage = "docker"; - const prerequisiteResult = await ensureDocker({ - platform, - arch, - env, - stdin, - stdout, - stderr, - execute, - installArgs: { root, requestedProfile, requestedAccessMode, noOpen, autoConfirm: true } - }); + let prerequisiteResult; + if (engine === "podman") { + prerequisiteResult = await ensurePodman({ platform, stdout, stderr, execute }); + } else if (!existingConfig && requestedEngine === undefined && accessMode !== "host-root" && platform !== "win32") { + // No engine was chosen and Docker is the default: when Docker is absent but a supported Podman is + // ready, pick Podman and say so loudly, instead of failing into a Docker install. The Docker check is CLI + // presence (`docker --version`): `docker info` also fails when Docker is installed but its daemon is + // stopped, and a stopped daemon must fall through to the Docker path, not silently switch the saved engine. + // Podman is never picked on Windows (unsupported there). The probes go through + // captureProbe (timeout-tagged, output-parsed): a bare exit code is not enough — the version must parse. + const dockerCli = await captureProbe(execute, { command: "docker", args: ["--version"] }); + if (dockerCli === null) { + const podmanVersion = parseEngineVersion(await captureProbe(execute, { command: "podman", args: ["--version"] }) ?? ""); + const podmanReady = podmanVersion !== null && versionAtLeast(podmanVersion, PODMAN_MIN_VERSION) + && await captureProbe(execute, { command: "podman", args: ["compose", "version"] }) !== null + && await captureProbe(execute, { command: "podman", args: ["info"] }) !== null; + if (podmanReady) { + engine = "podman"; + result.config = { ...result.config, engine }; + stdout.write( + "Docker is not available, but Podman is: installing with the Podman engine (rootless). " + + "It is saved in the configuration and used by every later command; pass --engine docker to insist on Docker.\n" + ); + prerequisiteResult = await ensurePodman({ platform, stdout, stderr, execute }); + } + } + } + if (!prerequisiteResult) { + prerequisiteResult = await ensureDocker({ + platform, + arch, + env, + stdin, + stdout, + stderr, + execute, + installArgs: { root, requestedProfile, requestedAccessMode, noOpen, autoConfirm: true } + }); + } if (prerequisiteResult.reexecuted) { return prerequisiteResult.code; } @@ -794,7 +871,7 @@ async function installCommand(args, { if (engineProfile !== profile) { profile = engineProfile; result.config = { ...result.config, profile }; - stdout.write(`Docker resources select profile: ${profile}.\n`); + stdout.write(`${engine === "podman" ? "Podman" : "Docker"} resources select profile: ${profile}.\n`); storagePlan = await inspectStoragePlan({ root, config: result.config, resources: storagePlan.resources, execute, operation }); } } @@ -808,6 +885,7 @@ async function installCommand(args, { inspectResources, resources, profile, + engine, dockerReady: true, operation, storagePlan }); if (doctorCode !== 0) { @@ -830,7 +908,8 @@ async function installCommand(args, { stdin, stdout, stderr, - execute + execute, + engine }); if (upgradeCheckpoint) { await retainFailedUpgrade(root, upgradeCheckpoint, @@ -845,7 +924,8 @@ async function installCommand(args, { stdin, stdout, stderr, - execute + execute, + engine }); runtimeMutationAttempted = false; } @@ -859,6 +939,7 @@ async function installCommand(args, { profile, stateRoot: stagedStateRoot, companionsEnabled, + engine, ...options }); const pullCode = await withHeadlessDockerConfig( @@ -1051,7 +1132,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: engineOf(config)}), {stdin, stdout, stderr}); if (code !== 0) { stderr.write("Inactive service cleanup failed. Data volumes were preserved. Run doctor --fix.\n"); return code; @@ -1070,9 +1151,13 @@ async function planInteractiveSetup({ stderr, execute, inspectResources, - request, sleep, ensureDocker, env, arch + request, sleep, ensureDocker, ensurePodman = ensurePodmanAvailable, env, arch }) { const parsed = parseInstallArgs(args); + // The engine can also arrive through the automation answers ("engine": "podman"); no wizard prompt is + // added for it, so existing prompt/answer sequences are unchanged. + const answersEngine = stdin?._spaceappPromptController?.answers?.engine; + const requestedEngine = parsed.requestedEngine ?? (typeof answersEngine === "string" ? answersEngine : undefined); const rawExisting = await readRawConfig(root); const repairs = planConfigRepairs(rawExisting); const path = upgradePath(rawExisting?.version ?? null, runtimeVersion); @@ -1102,6 +1187,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, @@ -1168,6 +1254,7 @@ async function planInteractiveSetup({ return { requestedProfile: profileChoice, requestedAccessMode: accessChoice, + requestedEngine, noOpen: !openBrowserChoice, companionsEnabled: companionsChoice, telemetryEnabled: telemetryChoice, @@ -1190,7 +1277,7 @@ async function planInteractiveSetup({ } await applyApprovedConfigRepairs(root, rawExisting); const config = await loadExistingInstallation(root); - const code = await repairRuntime({ root, config, platform, stdin, stdout, stderr, execute, request, sleep, ensureDocker, env, arch, inspectResources }); + const code = await repairRuntime({ root, config, platform, stdin, stdout, stderr, execute, request, sleep, ensureDocker, ensurePodman, env, arch, inspectResources }); if(code === 0 && !parsed.noOpen) await openBrowser(`http://${config.bindHost}:${config.port}`, platform, execute, {stdin,stdout,stderr}); return {exit:code}; } @@ -1245,6 +1332,7 @@ async function planInteractiveSetup({ return { requestedProfile: parsed.requestedProfile, requestedAccessMode: parsed.requestedAccessMode, + requestedEngine, noOpen: parsed.noOpen, companionsEnabled: parsed.companionsEnabled, telemetryEnabled: false @@ -1261,6 +1349,12 @@ function hasRequestedConfigChange(rawExisting, parsed) { ) { return true; } + if ( + parsed.requestedEngine !== undefined && + (rawExisting.engine ?? "docker") !== parsed.requestedEngine + ) { + return true; + } if (parsed.companionsEnabled && rawExisting.companionsEnabled !== true) { return true; } @@ -1304,7 +1398,7 @@ async function offerUnattendedContinuation(root, runtimeVersion, stdin, stdout, } } -async function repairRuntime({ root, config, platform, stdin, stdout, stderr, execute, request, sleep, dryRun = false, ensureDocker = ensureDockerAvailable, env = process.env, arch = process.arch, inspectResources = inspectSystemResources }) { +async function repairRuntime({ root, config, platform, stdin, stdout, stderr, execute, request, sleep, dryRun = false, ensureDocker = ensureDockerAvailable, ensurePodman = ensurePodmanAvailable, env = process.env, arch = process.arch, inspectResources = inspectSystemResources }) { const raw = await readRawConfig(root); const plan = planConfigRepairs(raw); if (dryRun) { @@ -1317,8 +1411,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 = engineOf(repaired) === "podman" + ? await ensurePodman({ 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; @@ -1326,7 +1422,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: engineOf(config) }), (pullSpec) => execute(pullSpec, { stdin, stdout, stderr }) ); if (pullCode !== 0) { @@ -1335,7 +1431,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: engineOf(config) }), { stdin, stdout, stderr } ); if (upCode !== 0) { @@ -1400,7 +1496,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: engineOf(updated) }), { stdin, stdout, stderr } ); if (upCode !== 0) { @@ -1478,7 +1574,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: engineOf(config) }; io.stdout.write("Pausing SpaceApp writers for the backup; the app and CLI sessions resume afterwards...\n"); const pauseCode = await execute(composeCommand("pauseWriters", root, options), io); if (pauseCode !== 0) return pauseCode; @@ -1541,7 +1637,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: engineOf(config) }), { stdin: null, stdout: dumpStream, stderr } ); } finally { @@ -1627,7 +1723,8 @@ async function reportInstallDiagnostics({ stdin, stdout, stderr, - execute + execute, + engine = "docker" }) { stderr.write("Collecting SpaceApp service status and recent logs before rollback...\n"); for (const action of ["status", "logs"]) { @@ -1637,6 +1734,7 @@ async function reportInstallDiagnostics({ composeCommand(action, root, { profile, stateRoot, + engine, lines: 200 }), { stdin, stdout, stderr }, @@ -1665,7 +1763,8 @@ async function restoreRuntimeAfterFailedInstall({ stdin, stdout, stderr, - execute + execute, + engine = "docker" }) { const runtimeExecute = (spec) => executeWithDockerDiagnostics( execute, @@ -1679,7 +1778,8 @@ async function restoreRuntimeAfterFailedInstall({ const restoreCode = await runtimeExecute( composeCommand("up", root, { profile: existingConfig.profile, - companionsEnabled: existingConfig.companionsEnabled + companionsEnabled: existingConfig.companionsEnabled, + engine: engineOf(existingConfig) }) ); if (restoreCode === 0) { @@ -1698,7 +1798,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 ? engineOf(existingConfig) : engine }) ); if (stopCode !== 0) { @@ -1716,6 +1817,7 @@ async function restoreRuntimeAfterFailedInstall({ function parseInstallArgs(args) { let requestedProfile = "auto"; let requestedAccessMode; + let requestedEngine; let noOpen = false; let companionsEnabled = false; for (let index = 0; index < args.length; index += 1) { @@ -1746,8 +1848,17 @@ function parseInstallArgs(args) { requestedAccessMode = argument.slice("--access=".length); continue; } + if (argument === "--engine" && index + 1 < args.length) { + requestedEngine = args[index + 1]; + index += 1; + continue; + } + if (argument.startsWith("--engine=")) { + requestedEngine = argument.slice("--engine=".length); + continue; + } throw new Error( - `Usage: ${UNIVERSAL_COMMAND} install [--profile auto|small|medium|large|light|standard] [--access isolated|host-root] [--with-companions] [--no-open]` + `Usage: ${UNIVERSAL_COMMAND} install [--profile auto|small|medium|large|light|standard] [--access isolated|host-root] [--engine docker|podman] [--with-companions] [--no-open]` ); } if ( @@ -1755,13 +1866,17 @@ function parseInstallArgs(args) { ( requestedAccessMode !== undefined && !["isolated", "host-root"].includes(requestedAccessMode) + ) || + ( + requestedEngine !== undefined && + !["docker", "podman"].includes(requestedEngine) ) ) { throw new Error( - `Usage: ${UNIVERSAL_COMMAND} install [--profile auto|small|medium|large|light|standard] [--access isolated|host-root] [--with-companions] [--no-open]` + `Usage: ${UNIVERSAL_COMMAND} install [--profile auto|small|medium|large|light|standard] [--access isolated|host-root] [--engine docker|podman] [--with-companions] [--no-open]` ); } - return { requestedProfile, requestedAccessMode, noOpen, companionsEnabled }; + return { requestedProfile, requestedAccessMode, requestedEngine, noOpen, companionsEnabled }; } async function loadExistingInstallation(root) { @@ -1796,7 +1911,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: engineOf(updated) }), { stdin, stdout, stderr }); if (code !== 0) stderr.write(`The change is saved, but the running services could not pick it up. Run "${UNIVERSAL_COMMAND} up".\n`); return code; }; @@ -1852,7 +1967,7 @@ async function exportArchive(archive, action, { root, config, service, stdin, st const out = createWriteStream(archive, { mode: 0o600 }); await once(out, "open"); const code = await execute( - composeCommand(action, root, { profile: config.profile, companionsEnabled: config.companionsEnabled, service }), + composeCommand(action, root, { profile: config.profile, companionsEnabled: config.companionsEnabled, engine: engineOf(config), service }), { stdin, stdout: out, stderr } ).catch(() => 1); out.end(); @@ -1892,7 +2007,7 @@ async function restoreUserState({ root, config, directory, userId, stdin, stdout const input = await readFile(userArchive(directory, userId, kind)).catch(() => null); if (!input) continue; const code = await execute( - composeCommand(action, root, { profile: config.profile, companionsEnabled: config.companionsEnabled, service }), + composeCommand(action, root, { profile: config.profile, companionsEnabled: config.companionsEnabled, engine: engineOf(config), service }), { stdin, stdout, stderr, input } ).catch(() => 1); if (code === 0) restored.push(label); @@ -1908,7 +2023,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: engineOf(config) }), { stdin, stdout, stderr, input: archive } ).catch(() => 1); stdout.write(code === 0 ? "CLI settings (MCP servers, base URLs) restored.\n" : "The CLI settings in this backup could not be restored.\n"); @@ -1955,7 +2070,7 @@ async function credentialsCommand(rawArgs, { root, config, stdin, stdout, stderr const value = await readSecret(stdin, stdout, `Enter ${provider} credential: `); await writeCredential(root, provider, value, { userId }); const syncCode = await execute( - composeCommand("syncCredentials", root, { profile: config.profile, companionsEnabled: config.companionsEnabled, service }), + composeCommand("syncCredentials", root, { profile: config.profile, companionsEnabled: config.companionsEnabled, engine: engineOf(config), service }), { stdin, stdout, stderr } ); if (syncCode !== 0) { @@ -1971,7 +2086,7 @@ async function credentialsCommand(rawArgs, { root, config, stdin, stdout, stderr } const removed = await removeCredential(root, provider, { userId }); const syncCode = await execute( - composeCommand("syncCredentials", root, { profile: config.profile, companionsEnabled: config.companionsEnabled, service }), + composeCommand("syncCredentials", root, { profile: config.profile, companionsEnabled: config.companionsEnabled, engine: engineOf(config), service }), { stdin, stdout, stderr } ); if (syncCode !== 0) { @@ -1997,7 +2112,7 @@ async function waitForUserCli({ root, config, userId, stdin, stdout, stderr, exe const service = `spaceapp-cli-${userSlug(userId)}`; for (let attempt = 0; attempt < USER_CLI_READY_ATTEMPTS; attempt++) { const code = await execute( - composeCommand("cliHostReady", root, { profile: config.profile, companionsEnabled: config.companionsEnabled, service }), + composeCommand("cliHostReady", root, { profile: config.profile, companionsEnabled: config.companionsEnabled, engine: engineOf(config), service }), { stdin, stdout: null, stderr: null } ).catch(() => 1); if (code === 0) { @@ -2016,7 +2131,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: engineOf(updated) }), { stdin, stdout, stderr }); if (code !== 0) stderr.write(`The change is saved, but the running services could not pick it up. Run "${UNIVERSAL_COMMAND} up".\n`); return code; }; @@ -2070,13 +2185,13 @@ async function providerCommand(rawArgs, { root, config, stdin, stdout, stderr, e stdout.write(`Installing optional provider ${provider} into ${userId ? `the private provider volume of ${userId}` : "this installation's private provider volume"}.\n`); // Keep the existing Claude path compatible with older runtime images. const action=provider === "claude" ? "installClaude" : "installProvider"; - return execute(composeCommand(action, root, { profile: config.profile, companionsEnabled: config.companionsEnabled, provider, service }), { stdin, stdout, stderr }); + return execute(composeCommand(action, root, { profile: config.profile, companionsEnabled: config.companionsEnabled, engine: engineOf(config), provider, service }), { stdin, stdout, stderr }); } async function ownerCommand(args, { root, config, stdin, stdout, stderr, execute }) { if (args.length === 1 && args[0] === "rotate-setup-token") { const token = randomBytes(32).toString("base64url"); - const code = await execute(composeCommand("rotateOwnerSetupToken", root, { profile: config.profile, companionsEnabled: config.companionsEnabled }), { + const code = await execute(composeCommand("rotateOwnerSetupToken", root, { profile: config.profile, companionsEnabled: config.companionsEnabled, engine: engineOf(config) }), { stdin, stdout, stderr, @@ -2097,7 +2212,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: engineOf(config) }), { stdin, stdout, stderr, @@ -2125,7 +2240,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: engineOf(config) }), { stdin, stdout, stderr } ); if (downCode !== 0) { @@ -2133,7 +2248,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: engineOf(config) }), { stdin, stdout, stderr } ); if (upCode !== 0) { @@ -2190,6 +2305,7 @@ async function supportBundleCommand({ version: config.version, profile: config.profile, accessMode: config.accessMode, + engine: engineOf(config), companionsEnabled: config.companionsEnabled, bindHost: config.bindHost, port: config.port, @@ -2201,6 +2317,27 @@ async function supportBundleCommand({ health: {} }; + if (config && engineOf(config) === "podman") { + // The same engine facts doctor reports; no secrets are collected, only engine state. + bundle.engine = { name: "podman" }; + const infoOutput = await captureProbe(execute, { command: "podman", args: ["info", "--format", "{{json .}}"] }).catch(() => null); + let facts = null; + try { facts = podmanInfoFacts(JSON.parse(infoOutput)); } catch { /* facts stay minimal */ } + if (facts) { + bundle.engine = { + name: "podman", + version: facts.version, + rootless: facts.rootless, + cgroupVersion: facts.cgroupVersion + }; + if (platform === "linux" && facts.rootless) { + const delegation = await probeCgroupDelegation({ platform }); + bundle.engine.cgroupDelegatedControllers = delegation.delegated; + bundle.engine.limitDelegation = delegation.cpuMemoryDelegated; + } + } + } + if (config) { const url = `http://${config.bindHost}:${config.port}`; if (typeof request === "function") { @@ -2265,7 +2402,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: engineOf(config) }), { stdin, stdout, stderr } ); if (purgeCode !== 0) { @@ -2288,7 +2425,7 @@ async function factoryResetCommand({ return 0; } -async function updateCommand(args, { root, config, version, platform, stdin, stdout, stderr, execute, request, sleep, inspectResources = inspectSystemResources, ensureDocker = ensureDockerAvailable, env = process.env, arch = process.arch }) { +async function updateCommand(args, { root, config, version, platform, stdin, stdout, stderr, execute, request, sleep, inspectResources = inspectSystemResources, ensureDocker = ensureDockerAvailable, ensurePodman = ensurePodmanAvailable, env = process.env, arch = process.arch }) { if (args.length > 1) { throw new Error(`Usage: ${UNIVERSAL_COMMAND} update [version]`); } @@ -2322,7 +2459,7 @@ async function updateCommand(args, { root, config, version, platform, stdin, std return doctor({ root, platform, stdout, stderr, execute, stdin, inspectResources: inspectSystemResources }); } } - return repairRuntime({ root, config, platform, stdin, stdout, stderr, execute, request, sleep, inspectResources, ensureDocker, env, arch }); + return repairRuntime({ root, config, platform, stdin, stdout, stderr, execute, request, sleep, inspectResources, ensureDocker, ensurePodman, env, arch }); } if (path === "downgrade") { if (!continuation) { @@ -2361,13 +2498,15 @@ 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 = engineOf(config) === "podman" + ? await ensurePodman({ 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" }); if (await doctor({ root, platform, stdout, stderr, execute, stdin, inspectResources, resources, - profile: config.profile, dockerReady: true, operation: "upgrade", storagePlan }) !== 0) return 1; + profile: config.profile, engine: engineOf(config), dockerReady: true, operation: "upgrade", storagePlan }) !== 0) return 1; return performUpdate({ root, config, @@ -2408,13 +2547,15 @@ async function doctor({ profile, operation, storagePlan, + engine, 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 detectedResources = resources ?? await inspectResources(root); + const existingConfig = await loadConfig(root).catch(() => null); + const resolvedEngine = engine ?? engineOf(existingConfig); let resolvedProfile = profile; if (!resolvedProfile) { - const existingConfig = await loadConfig(root).catch(() => null); if (existingConfig?.profile) { resolvedProfile = existingConfig.profile; } else if (detectedResources?.totalMemoryBytes) { @@ -2433,6 +2574,114 @@ async function doctor({ ...resourceChecks.filter((check) => !storagePlan || check.name !== "Free disk"), ...(storagePlan ? storageChecks(storagePlan, detectedResources) : []) ]; + const warnings = []; + if (resolvedEngine === "podman") { + const podmanResults = []; + for (const probe of [ + { name: "Podman CLI", command: "podman", args: ["--version"] }, + { name: "Podman Compose", command: "podman", args: ["compose", "version"] }, + { name: "Podman Engine", command: "podman", args: ["info"] } + ]) { + const code = dockerReady + ? 0 + : await execute(probe, { stdin, stdout: null, stderr: null }); + podmanResults.push({ ...probe, code }); + } + const [podmanCli, podmanCompose, podmanEngine] = podmanResults; + checks.push({ + name: podmanCli.name, + ok: podmanCli.code === 0, + detail: podmanCli.code === 0 + ? "available" + : podmanCli.code === 127 + ? "not found on PATH" + : `unavailable (exit ${podmanCli.code})` + }); + checks.push({ + name: podmanCompose.name, + ok: podmanCompose.code === 0, + detail: podmanCompose.code === 0 + ? "available (Docker Compose v2 provider)" + : podmanCli.code !== 0 + ? "not available because Podman CLI is missing" + : `provider unavailable (exit ${podmanCompose.code}); install Docker Compose v2 so \`podman compose\` works` + }); + checks.push({ + name: podmanEngine.name, + ok: podmanEngine.code === 0, + detail: podmanEngine.code === 0 + ? "available" + : podmanCli.code === 0 + ? "installed but not running or inaccessible (rootless: systemctl --user start podman.socket; macOS: podman machine start)" + : "not reachable because Podman CLI is missing" + }); + if (podmanEngine.code === 0) { + // The engine facts the issue requires doctor to report: version, rootless/rootful, cgroup version, + // and whether resource limits can actually be delegated to the rootless user cgroup. + const infoOutput = await captureProbe(execute, { command: "podman", args: ["info", "--format", "{{json .}}"] }); + let facts = null; + try { facts = podmanInfoFacts(JSON.parse(infoOutput)); } catch { /* facts stay unknown */ } + if (facts) { + checks.push({ + name: "Podman runtime", + ok: true, + detail: [ + facts.version ? `Podman ${facts.version}` : "version unknown", + facts.rootless === null ? "rootless state unknown" : facts.rootless ? "rootless" : "rootful", + facts.cgroupVersion ? `cgroup ${facts.cgroupVersion}` : "cgroup version unknown" + ].join(", ") + }); + if (platform === "linux" && facts.rootless) { + const delegation = await probeCgroupDelegation({ platform }); + if (delegation.version === 2 && delegation.cpuMemoryDelegated === true) { + checks.push({ + name: "Podman limit delegation", + ok: true, + detail: `cgroup v2 controllers delegated to the user cgroup: ${delegation.delegated.join(" ") || "none reported"}` + }); + } else if (delegation.version === 2) { + // Not a hard failure: the stack runs, but mem_limit/cpus cannot be enforced. Say so here, at + // doctor time, instead of letting it surface as a late, confusing runtime failure. + warnings.push( + "Podman limits: cgroup v2 cpu/memory controllers are not delegated to your user cgroup, so rootless " + + "Podman cannot enforce the profile memory/CPU limits (mem_limit/cpus). The stack runs without enforced " + + "limits until an administrator delegates them (systemd user slice Delegate=cpu memory, then re-login)." + ); + } else if (delegation.version === 1) { + warnings.push( + "Podman limits: this host uses cgroup v1, where rootless Podman cannot enforce the profile " + + "memory/CPU limits (mem_limit/cpus). The acceptance target is cgroup v2; the stack runs without enforced limits here." + ); + } + } + } + if (platform === "darwin") { + const machineOutput = await captureProbe(execute, { command: "podman", args: ["machine", "inspect"] }); + let machineFacts = null; + try { machineFacts = podmanMachineFacts(JSON.parse(machineOutput)); } catch { /* handled below */ } + if (machineOutput === null) { + checks.push({ name: "Podman machine", ok: false, detail: "could not be inspected (podman machine inspect failed)" }); + } else { + const machineCheck = podmanMachineCheck(machineFacts, PROFILE_REQUIREMENTS[resolvedProfile]); + checks.push({ + name: "Podman machine", + ok: machineCheck.ok, + detail: machineCheck.details.join("; ") + (machineCheck.guidance ? `. Resize: ${machineCheck.guidance}` : "") + }); + } + } + } + for (const check of checks) { + (check.ok ? stdout : stderr).write(`${check.ok ? "PASS" : "FAIL"} ${check.name}: ${check.detail}\n`); + } + for (const warning of warnings) { + stderr.write(`WARN ${warning}\n`); + } + if (podmanCli.code !== 0 || podmanCompose.code !== 0 || podmanEngine.code !== 0) { + stderr.write(`${podmanInstallInstructions(platform)}\n`); + } + return checks.every((check) => check.ok) ? 0 : 1; + } const dockerResults = []; for (const probe of [ { name: "Docker CLI", command: "docker", args: ["--version"] }, @@ -2587,6 +2836,11 @@ async function executeWithDockerDiagnostics(execute, spec, io, { platform, stder `SpaceApp could not find the Docker CLI. ${dockerInstallHelp(platform)}\n` ); } + if (spec.command === "podman" && code === 127) { + stderr.write( + `SpaceApp could not find the Podman CLI. ${podmanInstallInstructions(platform).split("\n")[0]}\n` + ); + } return code; } @@ -2656,6 +2910,7 @@ function spawnTrustedCommand(spec, options) { case "codesign": return spawn("codesign", args, options); case "docker": return spawn("docker", args, options); case "hdiutil": return spawn("hdiutil", args, options); + case "podman": return spawn("podman", args, options); case "open": return spawn("open", args, options); case "powershell.exe": return spawn("powershell.exe", windowsPowerShellArgs(spec.operation), options); @@ -2760,7 +3015,7 @@ Usage: ${UNIVERSAL_COMMAND} spaceapp Optional global launcher init Create a local SpaceApp installation - install [--profile auto|small|medium|large|light|standard] [--access isolated|host-root] [--with-companions] [--no-open] + install [--profile auto|small|medium|large|light|standard] [--access isolated|host-root] [--engine docker|podman] [--with-companions] [--no-open] Install prerequisites, initialize, and start reinstall [--keep-data] [--profile auto|small|medium|large|light|standard] [--access isolated|host-root] [--with-companions] [--no-open] Clean reinstallation preserving data and credentials @@ -2817,6 +3072,12 @@ Data lives in SPACEAPP_HOME (config/secrets) and persistent Docker volumes. Use backup before major changes; uninstall preserves data unless --purge-data. Never use factory-reset to solve an ordinary installation problem. +Container engines: Docker is the default. --engine podman (Linux and macOS) runs the same stack on +rootless Podman >= 5 through the Docker Compose v2 provider; the chosen engine is saved in the +configuration and used by every later command. Engines cannot be switched on an existing installation +(volumes are engine-private and would be stranded): migrate with backup, uninstall --purge-data, +install --engine , restore. --access host-root is Docker-only; Podman on Windows is unsupported. + Automation: --non-interactive --answers '{"confirm":true,"profile":"auto","access":"isolated","companions":false,"telemetry":false,"open":false}' For shells with different quoting, save that JSON to a file and pass --answers file.json. Use help or --help from any directory, including before installation. diff --git a/packages/run-spaceapp/src/engine.mjs b/packages/run-spaceapp/src/engine.mjs new file mode 100644 index 000000000..a6f4ae4b5 --- /dev/null +++ b/packages/run-spaceapp/src/engine.mjs @@ -0,0 +1,221 @@ +import { readFile } from "node:fs/promises"; +import { join } from "node:path"; + +/** + * Container engine support (#63): the launcher runs the same Compose stack on Docker (the default) or on + * rootless Podman. This module holds the engine facts the launcher needs — selection, version rules, the + * `podman info` / `podman machine inspect` shapes, cgroup v2 delegation, and install guidance — as pure, + * testable helpers. Dispatch stays static in cli.mjs (spawnTrustedCommand), exactly like Docker. + */ +export const CONTAINER_ENGINES = Object.freeze(["docker", "podman"]); +export const DEFAULT_ENGINE = "docker"; + +/** + * Supported Podman floor and Compose provider. The launcher drives `podman compose`, which delegates to an + * external provider; the supported provider is Docker Compose v2 (the standalone `docker-compose` binary or + * the Docker CLI plugin), because the template relies on Compose v2 semantics (profiles, file-based secrets, + * `depends_on` conditions with `required: false`, `--wait`). podman-compose (the Python tool) is not a + * supported provider: it lacks several of those semantics. + */ +export const PODMAN_MIN_VERSION = "5.0.0"; +export const PODMAN_COMPOSE_PROVIDER = "Docker Compose v2, reached through `podman compose`"; + +/** The engine a configuration runs on; configurations written before engine selection have no field. */ +export function engineOf(config) { + return config?.engine === "podman" ? "podman" : DEFAULT_ENGINE; +} + +/** A requested engine wins over the stored one; an unknown value is a hard error, never a silent fallback. */ +export function resolveInstallEngine(requested, existing = DEFAULT_ENGINE) { + if (requested === undefined || requested === null) { + return existing; + } + if (!CONTAINER_ENGINES.includes(requested)) { + throw new Error(`Container engine must be docker or podman, not "${requested}".`); + } + return requested; +} + +/** "podman version 5.2.2", "5.2.2", "version 5.2.2 (build ...)" -> "5.2.2"; anything else -> null. */ +export function parseEngineVersion(output) { + if (typeof output !== "string") { + return null; + } + const match = /(\d+)\.(\d+)\.(\d+)/.exec(output); + if (!match) { + return null; + } + return `${Number(match[1])}.${Number(match[2])}.${Number(match[3])}`; +} + +export function versionAtLeast(version, minimum) { + const parse = (value) => { + const match = /^(\d+)\.(\d+)\.(\d+)$/.exec(String(value ?? "")); + return match ? [Number(match[1]), Number(match[2]), Number(match[3])] : null; + }; + const actual = parse(version); + const floor = parse(minimum); + if (!actual || !floor) { + return false; + } + for (let index = 0; index < 3; index += 1) { + if (actual[index] !== floor[index]) { + return actual[index] > floor[index]; + } + } + return true; +} + +/** + * The facts doctor and the support bundle report, from `podman info --format {{json .}}` (Podman 5 shape). + * Returns null when the payload is not a Podman info document. + */ +export function podmanInfoFacts(info) { + if (!info || typeof info !== "object" || !info.host || typeof info.host !== "object") { + return null; + } + const version = info.version?.Version ?? info.version?.version ?? null; + return { + version: typeof version === "string" ? parseEngineVersion(version) ?? version : null, + rootless: typeof info.host.security?.rootless === "boolean" ? info.host.security.rootless : null, + cgroupVersion: typeof info.host.cgroupVersion === "string" ? info.host.cgroupVersion : null, + graphRoot: typeof info.store?.graphRoot === "string" ? info.store.graphRoot : null, + cpuCount: Number.isFinite(info.host.cpus) ? info.host.cpus : null, + memoryBytes: Number.isFinite(info.host.memTotal) ? info.host.memTotal : null, + architecture: typeof info.host.arch === "string" ? info.host.arch : null + }; +} + +/** + * The sizing facts of a Podman machine, from `podman machine inspect` (a JSON array; Memory is MiB and + * DiskSize is GiB in the machine definition). Sizing lives under Resources — only Name/State are top-level — + * so top-level fields are read only as a fallback for older output shapes. Returns null when there is no + * machine to inspect. + */ +export function podmanMachineFacts(inspect) { + const machine = Array.isArray(inspect) ? inspect[0] : inspect; + if (!machine || typeof machine !== "object") { + return null; + } + const resources = machine.Resources && typeof machine.Resources === "object" ? machine.Resources : machine; + const memoryMiB = Number(resources.Memory); + const diskGiB = Number(resources.DiskSize); + return { + name: typeof machine.Name === "string" ? machine.Name : null, + cpus: Number.isFinite(Number(resources.CPUs)) ? Number(resources.CPUs) : null, + memoryBytes: Number.isFinite(memoryMiB) ? memoryMiB * 1024 ** 2 : null, + diskBytes: Number.isFinite(diskGiB) ? diskGiB * 1024 ** 3 : null, + running: machine.Running === true || machine.State === "running" + }; +} + +/** + * Whether a Podman machine can hold a profile, and how to fix it when it cannot. The memory/disk figures a + * profile needs are its install requirements (PROFILE_REQUIREMENTS in index.mjs supplies them). + */ +export function podmanMachineCheck(facts, requirements) { + if (!facts) { + return { + ok: false, + details: ["No Podman machine exists. Create and start one: podman machine init && podman machine start"], + guidance: "podman machine init && podman machine start" + }; + } + const details = []; + const { cpuCount, memoryBytes, freshDiskBytes } = requirements; + const gib = (bytes) => Math.floor((bytes / 1024 ** 3) * 10) / 10; + if (facts.cpus !== null && facts.cpus < cpuCount) { + details.push(`CPUs: ${facts.cpus} allocated; ${cpuCount} required`); + } + if (facts.memoryBytes !== null && facts.memoryBytes < memoryBytes) { + details.push(`Memory: ${gib(facts.memoryBytes)} GiB allocated; ${gib(memoryBytes)} GiB required`); + } + if (facts.diskBytes !== null && facts.diskBytes < freshDiskBytes) { + details.push(`Disk: ${gib(facts.diskBytes)} GiB allocated; ${gib(freshDiskBytes)} GiB required`); + } + if (details.length === 0) { + return { + ok: true, + details: [`${facts.cpus ?? "?"} CPUs, ${facts.memoryBytes ? gib(facts.memoryBytes) : "?"} GiB memory, ${facts.diskBytes ? gib(facts.diskBytes) : "?"} GiB disk`], + guidance: null + }; + } + const memoryMiB = Math.ceil(memoryBytes / 1024 ** 2); + const diskGiB = Math.ceil(freshDiskBytes / 1024 ** 3); + const guidance = + "Resize the machine (it must be stopped; the disk can only grow): " + + `podman machine stop && podman machine set --cpus ${cpuCount} --memory ${memoryMiB} --disk-size ${diskGiB} && podman machine start`; + return { ok: false, details, guidance }; +} + +/** + * cgroup v2 controller delegation for the current user's cgroup — the fact that decides whether rootless + * Podman can enforce the template's mem_limit/cpus. On cgroup v2 the controllers a cgroup may enable for its + * children are listed in its own cgroup.controllers file; systemd delegates cpu/memory only when the user + * slice is configured for it. Reads are injectable for tests. + */ +export async function probeCgroupDelegation({ + platform = process.platform, + read = (path) => readFile(path, "utf8"), + selfCgroupPath = "/proc/self/cgroup", + cgroupRoot = "/sys/fs/cgroup" +} = {}) { + if (platform !== "linux") { + return { version: null, delegated: [], cpuMemoryDelegated: null }; + } + try { + const entries = String(await read(selfCgroupPath)).split(/\r?\n/).filter(Boolean); + const unified = entries.find((line) => line.startsWith("0::")); + if (!unified) { + return { version: 1, delegated: [], cpuMemoryDelegated: null }; + } + const ownPath = unified.slice("0::".length) || "/"; + const controllers = String(await read(join(cgroupRoot, ownPath, "cgroup.controllers"))).trim(); + const delegated = controllers ? controllers.split(/\s+/) : []; + return { + version: 2, + delegated, + cpuMemoryDelegated: delegated.includes("cpu") && delegated.includes("memory") + }; + } catch { + return { version: null, delegated: [], cpuMemoryDelegated: null }; + } +} + +/** The refusal text for an in-place engine switch: volumes are engine-private, so the data would vanish. */ +export function engineSwitchRefusal(existingEngine, requestedEngine) { + const other = requestedEngine; + return ( + `Switching container engines on an existing installation is refused: ${existingEngine} volumes are not ` + + `visible to ${other}, so the existing SpaceApp data would be stranded. To migrate engines instead: ` + + `1) run "spaceapp backup" on the current (${existingEngine}) engine, 2) run "spaceapp uninstall --purge-data", ` + + `3) run "spaceapp install --engine ${requestedEngine}", 4) run "spaceapp restore". ` + + "The backup/restore path is the supported migration and keeps your data." + ); +} + +/** Podman is never installed by the launcher (out of scope); these are the instructions it prints instead. */ +export function podmanInstallInstructions(platform) { + if (platform === "darwin") { + return [ + "Install Podman and create its machine (the launcher does not install Podman itself):", + " brew install podman", + " podman machine init && podman machine start", + `Then install the Compose provider (${PODMAN_COMPOSE_PROVIDER}): brew install docker-compose`, + "and make Compose visible to Podman as described in the run-spaceapp README." + ].join("\n"); + } + if (platform === "win32") { + return "The Podman engine is not supported on Windows. Use the Docker engine (Docker Desktop)."; + } + return [ + "Install Podman with your distribution (the launcher does not install Podman itself):", + " Fedora/RHEL: sudo dnf install podman", + " Debian/Ubuntu: sudo apt-get install podman", + " openSUSE: sudo zypper install podman", + `SpaceApp needs Podman >= ${PODMAN_MIN_VERSION} on cgroup v2, plus its Compose provider (${PODMAN_COMPOSE_PROVIDER}):`, + " install Docker Compose v2 (the standalone docker-compose binary, or the Docker CLI with its compose plugin)", + " so that `podman compose version` succeeds. podman-compose is not a supported provider.", + "Rootless Podman also needs the user socket for the Compose provider: systemctl --user enable --now podman.socket" + ].join("\n"); +} diff --git a/packages/run-spaceapp/src/index.mjs b/packages/run-spaceapp/src/index.mjs index e41d6986c..5f50836d6 100644 --- a/packages/run-spaceapp/src/index.mjs +++ b/packages/run-spaceapp/src/index.mjs @@ -20,6 +20,7 @@ import { import { UNIVERSAL_COMMAND } from "./package-info.mjs"; import { imageVerificationOff, verifyLockedImages } from "./image-attestations.mjs"; import { IMAGE_TARGETS, imageDigestEnv, PACKAGE_IMAGE_LOCK, parseRecordedDigests } from "./image-lock.mjs"; +import { CONTAINER_ENGINES, engineOf } from "./engine.mjs"; const CONFIG_SCHEMA_VERSION = 4; const CONTAINER_SECRET_MODE = 0o644; @@ -128,6 +129,7 @@ const CONFIG_KEYS = new Set([ "telemetry", "profile", "accessMode", + "engine", "workspaces", "companionsEnabled", "optionalModules", @@ -311,13 +313,17 @@ export function createDefaultConfig({ version, profile = "light", accessMode = "isolated", - companionsEnabled = false + companionsEnabled = false, + engine = undefined }) { assertVersion(version); if (!INSTALL_PROFILES.includes(profile)) { throw new Error("Default config requires a resolved installation profile."); } const resolvedAccessMode = resolveInstallAccessMode(accessMode); + if (engine !== undefined && !CONTAINER_ENGINES.includes(engine)) { + throw new Error("Default config engine must be docker or podman."); + } return { schemaVersion: CONFIG_SCHEMA_VERSION, version, @@ -327,6 +333,9 @@ export function createDefaultConfig({ telemetry: false, profile, accessMode: resolvedAccessMode, + // Only an explicitly chosen engine is stored: configurations written before engine selection have no + // field and mean Docker, so the default keeps producing the exact pre-engine configuration shape. + ...(engine === undefined ? {} : { engine }), companionsEnabled, optionalModules: "none", workspaces: [] @@ -367,6 +376,14 @@ export function validateConfig(config) { if (config.accessMode !== "isolated" && config.accessMode !== "host-root") { throw new Error("accessMode must be isolated or host-root."); } + if (config.engine !== undefined && !CONTAINER_ENGINES.includes(config.engine)) { + throw new Error("engine must be docker or podman."); + } + // host-root hands the containers the whole host filesystem; the Podman support in this launcher is + // rootless-only, so the combination is refused here as well as at install time (#63). + if (config.engine === "podman" && config.accessMode === "host-root") { + throw new Error("accessMode host-root is not supported with the Podman engine; use isolated access or the Docker engine."); + } if (typeof config.companionsEnabled !== "boolean") { throw new Error("companionsEnabled must be boolean."); } @@ -676,11 +693,18 @@ const CLI_SANDBOX_OPTIONS = [" security_opt:", " - seccomp=./seccomp-cli export function renderWorkspaceCompose(config, { cliSandbox = false } = {}) { validateConfig(config); + // On SELinux-enforcing hosts a Podman bind mount needs the shared relabel option or the container cannot + // read the workspace at all; "z" relabels for shared use and keeps the host ownership (rootless Podman maps + // the mount to the invoking user). Docker renders no bind options, so Docker output is unchanged. + const bindExtras = engineOf(config) === "podman" + ? [" bind:", ' selinux: "z"'] + : []; 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}`, + ...bindExtras ]); const service = mounts.length > 0 ? [" volumes:", ...mounts] @@ -695,7 +719,8 @@ export function renderWorkspaceCompose(config, { cliSandbox = false } = {}) { " - type: bind", ` source: ${JSON.stringify(workspace.hostPath)}`, ` target: ${JSON.stringify(workspace.containerPath)}`, - ` read_only: ${workspace.readOnly}` + ` read_only: ${workspace.readOnly}`, + ...bindExtras ]; const userServices = users.flatMap((user) => { const slug = userSlug(user.id); @@ -786,6 +811,12 @@ export function renderHostAccessCompose(config) { export function composeCommand(action, root, options = {}) { validateHome(root); + // The engine travels on the options object; callers that pass the configuration itself (which carries the + // stored engine) get the right binary for free. `podman compose` accepts the same Compose v2 arguments. + const engine = options.engine ?? "docker"; + if (!CONTAINER_ENGINES.includes(engine)) { + throw new Error(`Unsupported container engine "${engine}".`); + } const stateRoot = options.stateRoot ?? root; validateHome(stateRoot); const profile = options.profile ?? "standard"; @@ -909,7 +940,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) { @@ -984,8 +1015,12 @@ export async function prepareInstallation(root, { version, profile, accessMode, - companionsEnabled + companionsEnabled, + engine = undefined }) { + if (engine !== undefined && !CONTAINER_ENGINES.includes(engine)) { + throw new Error("Install engine must be docker or podman."); + } validateHome(root); await mkdir(root, { recursive: true, mode: 0o700 }); await Promise.all([ @@ -1007,7 +1042,10 @@ export async function prepareInstallation(root, { version, profile: resolvedProfile ?? "light", accessMode: resolveInstallAccessMode(accessMode), - companionsEnabled + companionsEnabled, + // A fresh install stores the engine only when one was explicitly chosen; an existing installation + // keeps its stored engine untouched here (cli.mjs refuses an in-place switch before this runs). + ...(engine === undefined ? {} : { engine }) }); } if (config.version !== version) { diff --git a/packages/run-spaceapp/src/prerequisites.mjs b/packages/run-spaceapp/src/prerequisites.mjs index 42bb242da..3526272cc 100644 --- a/packages/run-spaceapp/src/prerequisites.mjs +++ b/packages/run-spaceapp/src/prerequisites.mjs @@ -5,11 +5,19 @@ import { tmpdir } from "node:os"; import { join, posix, win32 } from "node:path"; import process from "node:process"; import { createInterface } from "node:readline/promises"; -import { Readable } from "node:stream"; +import { Readable, Writable } from "node:stream"; import { pipeline } from "node:stream/promises"; import { spawn } from "node:child_process"; import { fileURLToPath } from "node:url"; import { UNIVERSAL_INSTALL_COMMAND } from "./package-info.mjs"; +import { + PODMAN_COMPOSE_PROVIDER, + PODMAN_MIN_VERSION, + parseEngineVersion, + podmanInfoFacts, + podmanInstallInstructions, + versionAtLeast +} from "./engine.mjs"; const DOCKER_TERMS_URL = "https://www.docker.com/legal/docker-subscription-service-agreement/"; const DOWNLOAD_TIMEOUT_MS = 30 * 60 * 1_000; @@ -185,6 +193,123 @@ export async function ensureDockerAvailable({ }); } +/** Capture a probe command's stdout (the execute contract streams output, exactly like storage.captureProbe). */ +async function captureProbeOutput(spec, execute) { + let output = ""; + const stdout = new Writable({ + write(chunk, _encoding, callback) { + output += chunk.toString(); + callback(); + } + }); + try { + const code = await execute(spec, { stdout, stderr: null }); + return { code, output }; + } catch { + return { code: 127, output }; + } +} + +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; +} + +/** + * Podman prerequisite gate (#63). Unlike Docker, Podman is never installed by the launcher (installing Podman + * is out of scope): when anything is missing or too old this prints the instructions and fails, so the user + * hears about it at install time instead of as a late stack failure. + */ +export async function ensurePodmanAvailable({ + platform = process.platform, + stdout = process.stdout, + stderr = process.stderr, + execute +}) { + if (platform === "win32") { + stderr.write("The Podman engine is not supported on Windows. Rerun the installer with the Docker engine.\n"); + return { code: 1, reexecuted: false }; + } + const versionProbe = await captureProbeOutput({ command: "podman", args: ["--version"] }, execute); + const version = versionProbe.code === 0 ? parseEngineVersion(versionProbe.output) : null; + if (!version) { + stderr.write("Podman was not found on PATH.\n"); + stderr.write(`${podmanInstallInstructions(platform)}\n`); + return { code: 1, reexecuted: false }; + } + if (!versionAtLeast(version, PODMAN_MIN_VERSION)) { + stderr.write( + `Podman ${version} is too old: SpaceApp requires Podman >= ${PODMAN_MIN_VERSION}. ` + + "Upgrade Podman with your package manager or from https://podman.io and rerun the installer.\n" + ); + return { code: 1, reexecuted: false }; + } + // A zero exit code alone does not prove the provider: `podman compose` also delegates to podman-compose + // (the Python tool) when that is the host's provider, and it is not supported. Require the output to + // identify Docker Compose v2 or later (v5 exists now; the gate must not pin the major version). + const composeProbe = await captureProbeOutput({ command: "podman", args: ["compose", "version"] }, execute); + if ( + composeProbe.code !== 0 || + !/\bDocker Compose version v?(?:[2-9]|[1-9]\d+)\.\d+\.\d+\b/.test(composeProbe.output) + ) { + stderr.write( + "`podman compose` has no working provider. SpaceApp drives Compose through " + + `${PODMAN_COMPOSE_PROVIDER}; install Docker Compose v2 or later (the standalone docker-compose binary or the ` + + "Docker CLI compose plugin), then confirm `podman compose version` succeeds. " + + "podman-compose (the Python tool) is not a supported provider.\n" + ); + return { code: 1, reexecuted: false }; + } + if (await execute({ command: "podman", args: ["info"] }, { stdout: null, stderr: null }) !== 0) { + if (platform === "darwin") { + stderr.write( + "Podman is installed but its machine is not reachable. Create and start it, then rerun the installer:\n" + + " podman machine init && podman machine start\n" + ); + } else { + stderr.write( + "Podman is installed but not reachable. For rootless Podman, start the user socket the Compose " + + "provider connects to, then rerun the installer:\n" + + " systemctl --user enable --now podman.socket\n" + + "and confirm `podman info` succeeds.\n" + ); + } + return { code: 1, reexecuted: false }; + } + // Reachable is not enough: the launcher requires rootless Podman, and a rootful engine must not slip + // through on an exit code. Fail closed when the rootless state is rootful or cannot be determined. + const infoProbe = await captureProbeOutput({ command: "podman", args: ["info", "--format", "{{json .}}"] }, execute); + let infoFacts = null; + try { + infoFacts = podmanInfoFacts(JSON.parse(infoProbe.output)); + } catch { + // infoFacts stays null: unparseable info counts as unknown, and is rejected below. + } + if (infoFacts?.rootless !== true) { + if (infoFacts && infoFacts.rootless === false) { + stderr.write( + "Podman is running rootful. SpaceApp requires rootless Podman: rerun the installer as a non-root " + + "user with the rootless socket enabled (`systemctl --user enable --now podman.socket`), or install with the Docker engine.\n" + ); + } else { + stderr.write( + "Podman is installed but its rootless state could not be determined. Confirm `podman info` succeeds and rerun the installer.\n" + ); + } + return { code: 1, reexecuted: false }; + } + stdout.write(`Podman ${version} with the Docker Compose v2 provider is ready.\n`); + return { code: 0, reexecuted: false }; +} + async function ensureWindowsDocker({ arch, env, @@ -654,7 +779,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"] }, diff --git a/packages/run-spaceapp/src/storage.mjs b/packages/run-spaceapp/src/storage.mjs index c8b4cfadc..e5cb6f4c3 100644 --- a/packages/run-spaceapp/src/storage.mjs +++ b/packages/run-spaceapp/src/storage.mjs @@ -3,6 +3,10 @@ import { readdir, readFile, stat } from "node:fs/promises"; import { join } from "node:path"; import { composeCommand, profileRuntimeSettings, PROFILE_REQUIREMENTS } from "./index.mjs"; import { localImageRefs } from "./image-lock.mjs"; +import { engineOf, podmanInfoFacts } from "./engine.mjs"; + +// Docker reports image ids as "sha256:"; Podman reports the bare hex digest. Both identify the image. +const isImageId = (value) => /^(?:sha256:)?[a-f0-9]{64}$/.test(value ?? ""); const GiB = 1024 ** 3; const MiB = 1024 ** 2; @@ -96,15 +100,30 @@ 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 engineName = engineOf(config); + const engineInfo = await captureProbe(execute, { command: engineName, args: ["info", "--format", "{{json .}}"] }); let engine = null; - try { engine = JSON.parse(dockerInfo); } catch { /* Conservative reserve when the engine is unavailable. */ } + try { + const parsed = JSON.parse(engineInfo); + if (engineName === "podman") { + // Normalize Podman's info document onto the Docker-shaped fields the plan math below reads. + const facts = podmanInfoFacts(parsed); + engine = facts ? { + DockerRootDir: facts.graphRoot, + NCPU: facts.cpuCount, + MemTotal: facts.memoryBytes, + Architecture: facts.architecture + } : null; + } else { + engine = parsed; + } + } catch { /* Conservative reserve when the engine is unavailable. */ } const cachedLayers = new Set(); if (engine && existingConfig) { const existingDigests = await installedImageDigests(root, existingConfig.version); for (const type of ["core", "cli", "browser"]) { for (const ref of localImageRefs(type, existingConfig.version, existingDigests[type])) { - const output = await captureProbe(execute, { command: "docker", args: ["image", "inspect", "--format", "{{json .RootFS.Layers}}", ref] }); + const output = await captureProbe(execute, { command: engineName, args: ["image", "inspect", "--format", "{{json .RootFS.Layers}}", ref] }); try { for (const layer of JSON.parse(output)) cachedLayers.add(layer); break; } catch {} } } @@ -126,10 +145,10 @@ export async function inspectStoragePlan({ root, config, existingConfig, resourc const refs = ["core", "cli", "browser"].includes(type) ? localImageRefs(type, config.version) : [image]; let cached = null; for (const ref of refs) { - cached = await captureProbe(execute, { command: "docker", args: ["image", "inspect", "--format", "{{.Id}}", ref] }); - if (/^sha256:[a-f0-9]{64}$/.test(cached ?? "")) break; + cached = await captureProbe(execute, { command: engineName, args: ["image", "inspect", "--format", "{{.Id}}", ref] }); + if (isImageId(cached)) break; } - if (!/^sha256:[a-f0-9]{64}$/.test(cached ?? "")) { + if (!isImageId(cached)) { missingImages.push(image); let reserve = imageReserve[type] ?? GiB; if (engine && existingConfig && cachedLayers.size && typeof fetch === "function") { @@ -159,7 +178,7 @@ export async function inspectStoragePlan({ root, config, existingConfig, resourc const { inspectSystemResources } = await import("./index.mjs"); try { dockerFreeDiskBytes = (await inspectSystemResources(engine.DockerRootDir)).freeDiskBytes; } catch {} } - return { ...plan, missingImages, sharedLayersEstimated, dockerFreeDiskBytes, + return { ...plan, engine: engineName, missingImages, sharedLayersEstimated, dockerFreeDiskBytes, resources: { ...resources, freeDiskBytes: dockerFreeDiskBytes === null ? resources.freeDiskBytes : Math.min(resources.freeDiskBytes, dockerFreeDiskBytes), engineCpuCount: engine?.NCPU, @@ -168,11 +187,12 @@ export async function inspectStoragePlan({ root, config, existingConfig, resourc } export function storageChecks(plan, resources) { + const engineLabel = plan.engine === "podman" ? "Podman" : "Docker"; const checks = [{ name: "Free disk", ok: resources.freeDiskBytes >= plan.requiredDiskBytes, detail: `${gib(resources.freeDiskBytes)} GiB available; ${gib(plan.requiredDiskBytes)} GiB required additionally for ${plan.operation} (images ${gib(plan.missingImageBytes)}, checkpoint ${gib(plan.checkpointBytes)}, reserve 0.5)` }]; - if (plan.dockerFreeDiskBytes !== null) checks.push({ name: "Docker storage", ok: plan.dockerFreeDiskBytes >= plan.requiredDiskBytes, + if (plan.dockerFreeDiskBytes !== null) checks.push({ name: `${engineLabel} storage`, ok: plan.dockerFreeDiskBytes >= plan.requiredDiskBytes, detail: `${gib(plan.dockerFreeDiskBytes)} GiB available; ${gib(plan.requiredDiskBytes)} GiB required additionally` }); - else checks.push({ name: "Docker storage", ok: true, detail: "Separate engine disk capacity unavailable; host estimate used. Docker ENOSPC will stop the operation before cutover." }); + else checks.push({ name: `${engineLabel} storage`, ok: true, detail: `Separate engine disk capacity unavailable; host estimate used. ${engineLabel} ENOSPC will stop the operation before cutover.` }); return checks; } diff --git a/packages/run-spaceapp/tests/podman.test.mjs b/packages/run-spaceapp/tests/podman.test.mjs new file mode 100644 index 000000000..a48b10efa --- /dev/null +++ b/packages/run-spaceapp/tests/podman.test.mjs @@ -0,0 +1,755 @@ +import assert from "node:assert/strict"; +import { mkdtemp, readFile, writeFile } 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 { executeCommand, run } from "../src/cli.mjs"; +import { + composeCommand, + createDefaultConfig, + initializeInstallation, + prepareInstallation, + renderWorkspaceCompose, + validateConfig +} from "../src/index.mjs"; +import { + PODMAN_MIN_VERSION, + engineOf, + engineSwitchRefusal, + parseEngineVersion, + podmanInfoFacts, + podmanMachineCheck, + podmanMachineFacts, + probeCgroupDelegation, + resolveInstallEngine, + versionAtLeast +} from "../src/engine.mjs"; +import { ensurePodmanAvailable } from "../src/prerequisites.mjs"; +import { inspectStoragePlan, storageChecks } from "../src/storage.mjs"; + +const launcherManifest = JSON.parse( + await readFile(new URL("../package.json", import.meta.url), "utf8") +); +const RUNTIME_VERSION = launcherManifest.spaceappRuntimeVersion; + +function capture() { + let value = ""; + const stream = new Writable({ + write(chunk, _encoding, callback) { + value += chunk.toString(); + callback(); + } + }); + return { stream, value: () => value }; +} + +function ttyStdin(...answers) { + let index = 0; + let eventMode = false; + const dataHandlers = []; + const endHandlers = []; + const keypressHandlers = []; + const stream = { + isTTY: true, + setRawMode() {}, + resume() {}, + pause() {}, + setEncoding() {}, + on(event, handler) { + if (event === "data") { + dataHandlers.push(handler); + if (!eventMode) { + eventMode = true; + queueMicrotask(() => deliverEvents()); + } + } else if (event === "end") { + endHandlers.push(handler); + } else if (event === "keypress") { + keypressHandlers.push(handler); + if (!eventMode) { + eventMode = true; + queueMicrotask(() => deliverEvents()); + } + } + return stream; + }, + once(event, handler) { + if (event === "data" || event === "end" || event === "keypress") { + stream.on(event, handler); + } + return stream; + }, + removeListener() { + return stream; + }, + off() { + return stream; + }, + listenerCount(event) { + if (event === "data") return dataHandlers.length; + if (event === "end") return endHandlers.length; + if (event === "keypress") return keypressHandlers.length; + return 0; + }, + [Symbol.asyncIterator]() { + return { + async next() { + if (index >= answers.length) { + return { done: true }; + } + const answer = answers[index]; + index += 1; + return { done: false, value: `${answer}\n` }; + }, + async return() { + return { done: true }; + } + }; + } + }; + function deliverEvents() { + while (index < answers.length) { + const answer = answers[index]; + index += 1; + if (keypressHandlers.length > 0) { + for (const character of answer) { + for (const handler of [...keypressHandlers]) { + handler({ sequence: character, name: undefined, ctrl: false, meta: false, shift: false }); + } + } + for (const handler of [...keypressHandlers]) { + handler({ sequence: "\r", name: "return", ctrl: false, meta: false, shift: false }); + } + } else { + for (const handler of [...dataHandlers]) { + handler(`${answer}\n`); + } + } + } + for (const handler of [...endHandlers]) { + handler(); + } + } + return stream; +} + +const FRESH_APPROVALS = ["", "", "", "", "", "y"]; +const GENEROUS_RESOURCES = Object.freeze({ + cpuCount: 8, + totalMemoryBytes: 16 * 1024 ** 3, + freeDiskBytes: 40 * 1024 ** 3 +}); + +const PODMAN_INFO = Object.freeze({ + version: { Version: "5.2.2" }, + host: { + security: { rootless: true }, + cgroupVersion: "v2", + cpus: 8, + memTotal: 16 * 1024 ** 3, + arch: "amd64" + }, + store: { graphRoot: "/home/spaceuser/.local/share/containers/storage" } +}); + +/** A Podman host as the launcher's probes see it; Docker is absent (127) on purpose. */ +function podmanExecute(calls) { + return async (spec, io) => { + calls?.push(spec); + if (spec.command === "docker") return 127; + if (spec.command !== "podman") return 0; + if (spec.args[0] === "--version") { + io?.stdout?.write?.("podman version 5.2.2\n"); + return 0; + } + if (spec.args[0] === "compose" && spec.args[1] === "version") { + io?.stdout?.write?.("Docker Compose version v2.39.1\n"); + return 0; + } + if (spec.args[0] === "info" && spec.args.includes("--format")) { + io?.stdout?.write?.(JSON.stringify(PODMAN_INFO)); + return 0; + } + return 0; + }; +} + +function jsonResponse(payload, status = 200) { + return new Response(JSON.stringify(payload), { + status, + headers: { "content-type": "application/json" } + }); +} + +function readyRequest(url) { + return Promise.resolve(url.endsWith("/readyz") + ? jsonResponse({ ok: true }) + : jsonResponse({ setupRequired: false, expiresAt: null })); +} + +test("engine version parsing and comparison follow the Podman >= 5 floor", () => { + assert.equal(PODMAN_MIN_VERSION, "5.0.0"); + assert.equal(parseEngineVersion("podman version 5.2.2"), "5.2.2"); + assert.equal(parseEngineVersion("5.0.0"), "5.0.0"); + assert.equal(parseEngineVersion("podman version 4.9.3"), "4.9.3"); + assert.equal(parseEngineVersion("not a version"), null); + assert.equal(parseEngineVersion(null), null); + assert.equal(versionAtLeast("5.0.0", PODMAN_MIN_VERSION), true); + assert.equal(versionAtLeast("5.2.2", PODMAN_MIN_VERSION), true); + assert.equal(versionAtLeast("4.9.3", PODMAN_MIN_VERSION), false); + assert.equal(versionAtLeast(null, PODMAN_MIN_VERSION), false); +}); + +test("engine selection: explicit wins, stored is kept, unknown is refused", () => { + assert.equal(engineOf(null), "docker"); + assert.equal(engineOf({}), "docker"); + assert.equal(engineOf({ engine: "podman" }), "podman"); + assert.equal(resolveInstallEngine(undefined, "podman"), "podman"); + assert.equal(resolveInstallEngine("podman", "docker"), "podman"); + assert.throws(() => resolveInstallEngine("nerdctl"), /must be docker or podman/); + assert.match(engineSwitchRefusal("docker", "podman"), /not\s+visible to podman/); + assert.match(engineSwitchRefusal("podman", "docker"), /not\s+visible to docker/); + assert.match(engineSwitchRefusal("docker", "podman"), /uninstall --purge-data/); +}); + +test("podman info facts are extracted from the Podman 5 info document", () => { + assert.deepEqual(podmanInfoFacts(PODMAN_INFO), { + version: "5.2.2", + rootless: true, + cgroupVersion: "v2", + graphRoot: "/home/spaceuser/.local/share/containers/storage", + cpuCount: 8, + memoryBytes: 16 * 1024 ** 3, + architecture: "amd64" + }); + assert.equal(podmanInfoFacts(null), null); + assert.equal(podmanInfoFacts({}), null); + assert.equal(podmanInfoFacts("nope"), null); +}); + +test("podman machine facts are sized against the profile and resizing is explained", () => { + const requirements = { cpuCount: 4, memoryBytes: 7 * 1024 ** 3, freshDiskBytes: 6.5 * 1024 ** 3 }; + const small = podmanMachineFacts([{ Name: "podman-machine-default", Resources: { CPUs: 2, Memory: 2048, DiskSize: 20 }, Running: true }]); + assert.equal(small.cpus, 2); + assert.equal(small.memoryBytes, 2048 * 1024 ** 2); + const failing = podmanMachineCheck(small, requirements); + assert.equal(failing.ok, false); + assert.match(failing.guidance, /podman machine set --cpus 4 --memory 7168 --disk-size 7/); + const adequate = podmanMachineFacts([{ Name: "m", Resources: { CPUs: 8, Memory: 16384, DiskSize: 100 }, Running: true }]); + assert.equal(podmanMachineCheck(adequate, requirements).ok, true); + const missing = podmanMachineCheck(null, requirements); + assert.equal(missing.ok, false); + assert.match(missing.guidance, /podman machine init/); +}); + +test("cgroup v2 delegation is probed from the current cgroup's controllers", async () => { + const reads = { + "/proc/self/cgroup": "0::/user.slice/user-1000.slice/session-3.scope\n", + "/sys/fs/cgroup/user.slice/user-1000.slice/session-3.scope/cgroup.controllers": "cpuset cpu io memory hugetlb pids\n" + }; + const delegated = await probeCgroupDelegation({ platform: "linux", read: async (path) => reads[path] }); + assert.equal(delegated.version, 2); + assert.equal(delegated.cpuMemoryDelegated, true); + const partial = await probeCgroupDelegation({ + platform: "linux", + read: async (path) => path.endsWith("cgroup.controllers") ? "cpuset io pids\n" : reads["/proc/self/cgroup"] + }); + assert.equal(partial.cpuMemoryDelegated, false); + const v1 = await probeCgroupDelegation({ + platform: "linux", + read: async () => "12:memory:/docker/abc\n11:cpu:/docker/abc\n" + }); + assert.equal(v1.version, 1); + assert.equal(v1.cpuMemoryDelegated, null); + const other = await probeCgroupDelegation({ platform: "darwin", read: async () => { throw new Error("unread"); } }); + assert.equal(other.version, null); +}); + +test("composeCommand targets the configured engine with identical Compose arguments", () => { + const root = "/tmp/spaceapp-engine-test"; + const dockerUp = composeCommand("up", root, { profile: "small" }); + const podmanUp = composeCommand("up", root, { profile: "small", engine: "podman" }); + assert.equal(dockerUp.command, "docker"); + assert.equal(podmanUp.command, "podman"); + assert.deepEqual(podmanUp.args, dockerUp.args); + // Backup/restore run as 0:0 under both engines (required under rootless Podman). + const backup = composeCommand("backup", root, { profile: "small", engine: "podman" }); + assert.deepEqual(backup.args.slice(-8, -6), ["--user", "0:0"]); + // The checkpoint database wait keeps its health-condition semantics under podman compose. + const checkpoint = composeCommand("ensureCheckpointDatabase", root, { profile: "small", engine: "podman" }); + assert.ok(checkpoint.args.includes("--wait")); + // exec identity for the CLI services is unchanged. + const ready = composeCommand("cliHostReady", root, { profile: "small", engine: "podman" }); + assert.ok(ready.args.includes("10001:10001")); + assert.throws(() => composeCommand("up", root, { engine: "nerdctl" }), /Unsupported container engine/); +}); + +test("configuration persists the engine only when chosen and validates combinations", async () => { + const unchosen = createDefaultConfig({ version: RUNTIME_VERSION }); + assert.equal("engine" in unchosen, false); + const chosen = createDefaultConfig({ version: RUNTIME_VERSION, engine: "podman" }); + assert.equal(chosen.engine, "podman"); + assert.equal(validateConfig(chosen), chosen); + assert.throws( + () => validateConfig({ ...chosen, accessMode: "host-root" }), + /host-root is not supported with the Podman engine/ + ); + assert.throws( + () => createDefaultConfig({ version: RUNTIME_VERSION, engine: "nerdctl" }), + /engine must be docker or podman/ + ); + const root = await mkdtemp(join(tmpdir(), "spaceapp-podman-config-")); + const prepared = await prepareInstallation(root, { + version: RUNTIME_VERSION, + profile: "small", + accessMode: "isolated", + engine: "podman" + }); + assert.equal(prepared.config.engine, "podman"); + // Once committed, preparing again without an engine keeps the stored one (the CLI layer is what + // refuses an explicit switch; preparation itself never rewrites the engine). + const installed = await initializeInstallation(root, { + version: RUNTIME_VERSION, + profile: "small", + accessMode: "isolated", + engine: "podman" + }); + assert.equal(installed.config.engine, "podman"); + const reprepared = await prepareInstallation(root, { + version: RUNTIME_VERSION, + profile: "small", + accessMode: "isolated" + }); + assert.equal(engineOf(reprepared.config), "podman"); + const onDisk = JSON.parse(await readFile(join(root, "config.json"), "utf8")); + assert.equal(onDisk.engine, "podman"); +}); + +test("workspace bind mounts gain the SELinux relabel option only under Podman", () => { + const workspace = { + id: "docs-0123abcd", + hostPath: "/home/spaceuser/docs", + containerPath: "/workspaces/docs", + readOnly: false + }; + const dockerConfig = { ...createDefaultConfig({ version: RUNTIME_VERSION }), workspaces: [workspace] }; + const dockerRender = renderWorkspaceCompose(dockerConfig); + assert.doesNotMatch(dockerRender, /selinux/); + const podmanRender = renderWorkspaceCompose({ ...dockerConfig, engine: "podman" }); + assert.match(podmanRender, /selinux: "z"/); + const readOnlyRender = renderWorkspaceCompose({ + ...dockerConfig, + engine: "podman", + workspaces: [{ ...workspace, readOnly: true }] + }); + assert.match(readOnlyRender, /read_only: true/); + assert.match(readOnlyRender, /selinux: "z"/); +}); + +test("ensurePodmanAvailable gates on presence, version floor, compose provider, and reachability", async () => { + const healthy = { + platform: "linux", + stdout: capture().stream, + stderr: capture().stream, + execute: podmanExecute(null) + }; + assert.deepEqual(await ensurePodmanAvailable(healthy), { code: 0, reexecuted: false }); + + const missingStderr = capture(); + const missing = await ensurePodmanAvailable({ + platform: "linux", + stdout: capture().stream, + stderr: missingStderr.stream, + execute: async () => 127 + }); + assert.equal(missing.code, 1); + assert.match(missingStderr.value(), /Podman was not found/); + assert.match(missingStderr.value(), /dnf install podman/); + + const oldStderr = capture(); + const tooOld = await ensurePodmanAvailable({ + platform: "linux", + stdout: capture().stream, + stderr: oldStderr.stream, + execute: async (spec, io) => { + if (spec.args[0] === "--version") io?.stdout?.write?.("podman version 4.9.3\n"); + return 0; + } + }); + assert.equal(tooOld.code, 1); + assert.match(oldStderr.value(), /too old: SpaceApp requires Podman >= 5\.0\.0/); + + const providerStderr = capture(); + const noProvider = await ensurePodmanAvailable({ + platform: "linux", + stdout: capture().stream, + stderr: providerStderr.stream, + execute: async (spec, io) => { + if (spec.args[0] === "--version") { + io?.stdout?.write?.("podman version 5.2.2\n"); + return 0; + } + if (spec.args[0] === "compose") return 1; + return 0; + } + }); + assert.equal(noProvider.code, 1); + assert.match(providerStderr.value(), /podman-compose \(the Python tool\) is not a supported provider/); + + const socketStderr = capture(); + const unreachable = await ensurePodmanAvailable({ + platform: "linux", + stdout: capture().stream, + stderr: socketStderr.stream, + execute: async (spec, io) => { + if (spec.args[0] === "--version") { + io?.stdout?.write?.("podman version 5.2.2\n"); + return 0; + } + if (spec.args[0] === "compose") { + io?.stdout?.write?.("Docker Compose version v2.39.1\n"); + return 0; + } + if (spec.args[0] === "info") return 1; + return 0; + } + }); + assert.equal(unreachable.code, 1); + assert.match(socketStderr.value(), /podman\.socket/); + + // A zero exit from `podman compose version` is not enough: the Python podman-compose provider + // exits 0 too, and it is not supported — only Docker Compose v2 output passes. + const pythonProviderStderr = capture(); + const pythonProvider = await ensurePodmanAvailable({ + platform: "linux", + stdout: capture().stream, + stderr: pythonProviderStderr.stream, + execute: async (spec, io) => { + if (spec.args[0] === "--version") { + io?.stdout?.write?.("podman version 5.2.2\n"); + return 0; + } + if (spec.args[0] === "compose") { + io?.stdout?.write?.("podman-compose version: 1.5.0\nusing podman version: 5.2.2\n"); + return 0; + } + return 0; + } + }); + assert.equal(pythonProvider.code, 1); + assert.match(pythonProviderStderr.value(), /podman-compose \(the Python tool\) is not a supported provider/); + + // Docker Compose v5 must pass the gate too (the v2-only pattern rejected it), with or without + // the `v` prefix; v1 must still be rejected. + for (const composeOutput of ["Docker Compose version v5.6.1\n", "Docker Compose version 5.0.0\n"]) { + const v5 = await ensurePodmanAvailable({ + platform: "linux", + stdout: capture().stream, + stderr: capture().stream, + execute: async (spec, io) => { + if (spec.args[0] === "--version") { + io?.stdout?.write?.("podman version 5.2.2\n"); + return 0; + } + if (spec.args[0] === "compose") { + io?.stdout?.write?.(composeOutput); + return 0; + } + if (spec.args[0] === "info" && spec.args.includes("--format")) { + io?.stdout?.write?.(JSON.stringify(PODMAN_INFO)); + return 0; + } + return 0; + } + }); + assert.equal(v5.code, 0, composeOutput); + } + const v1Stderr = capture(); + const v1 = await ensurePodmanAvailable({ + platform: "linux", + stdout: capture().stream, + stderr: v1Stderr.stream, + execute: async (spec, io) => { + if (spec.args[0] === "--version") { + io?.stdout?.write?.("podman version 5.2.2\n"); + return 0; + } + if (spec.args[0] === "compose") { + io?.stdout?.write?.("Docker Compose version v1.29.2\n"); + return 0; + } + return 0; + } + }); + assert.equal(v1.code, 1); + assert.match(v1Stderr.value(), /podman-compose \(the Python tool\) is not a supported provider/); + + // Rootful Podman must not pass the prerequisite gate, even though `podman info` succeeds. + const rootfulStderr = capture(); + const rootful = await ensurePodmanAvailable({ + platform: "linux", + stdout: capture().stream, + stderr: rootfulStderr.stream, + execute: async (spec, io) => { + if (spec.args[0] === "--version") { + io?.stdout?.write?.("podman version 5.2.2\n"); + return 0; + } + if (spec.args[0] === "compose") { + io?.stdout?.write?.("Docker Compose version v2.39.1\n"); + return 0; + } + if (spec.args[0] === "info" && spec.args.includes("--format")) { + io?.stdout?.write?.(JSON.stringify({ + ...PODMAN_INFO, + host: { ...PODMAN_INFO.host, security: { rootless: false } } + })); + return 0; + } + return 0; + } + }); + assert.equal(rootful.code, 1); + assert.match(rootfulStderr.value(), /running rootful/); + + // An info document that does not establish rootless fails closed, too. + const unknownStderr = capture(); + const unknown = await ensurePodmanAvailable({ + platform: "linux", + stdout: capture().stream, + stderr: unknownStderr.stream, + execute: async (spec, io) => { + if (spec.args[0] === "--version") { + io?.stdout?.write?.("podman version 5.2.2\n"); + return 0; + } + if (spec.args[0] === "compose") { + io?.stdout?.write?.("Docker Compose version v2.39.1\n"); + return 0; + } + if (spec.args[0] === "info" && spec.args.includes("--format")) { + io?.stdout?.write?.(JSON.stringify({ + version: { Version: "5.2.2" }, + host: { cgroupVersion: "v2", cpus: 8, memTotal: 16 * 1024 ** 3, arch: "amd64" }, + store: { graphRoot: "/home/spaceuser/.local/share/containers/storage" } + })); + return 0; + } + return 0; + } + }); + assert.equal(unknown.code, 1); + assert.match(unknownStderr.value(), /could not be determined/); + + const windows = await ensurePodmanAvailable({ + platform: "win32", + stdout: capture().stream, + stderr: capture().stream, + execute: async () => 0 + }); + assert.equal(windows.code, 1); +}); + +test("the process launcher trusts podman exactly like docker", async () => { + const code = await executeCommand( + { command: "podman", args: ["--version"] }, + { stdout: capture().stream, stderr: capture().stream } + ); + // 127 where Podman is not installed (this sandbox, most CI), 0 where it is; never a refusal. + assert.ok(code === 0 || code === 127); + await assert.rejects( + executeCommand({ command: "podman-compose", args: ["version"] }), + /refused to execute an untrusted command/ + ); +}); + +async function podmanInstallationRoot() { + const root = await mkdtemp(join(tmpdir(), "spaceapp-podman-install-")); + const installed = await initializeInstallation(root, { + version: RUNTIME_VERSION, + profile: "small", + accessMode: "isolated", + engine: "podman" + }); + assert.equal(installed.config.engine, "podman"); + return root; +} + +test("doctor reports the Podman engine, runtime facts, and rootless state", async () => { + const root = await podmanInstallationRoot(); + const stdout = capture(); + const stderr = capture(); + const code = await run(["doctor"], { + env: { SPACEAPP_HOME: root }, + platform: "linux", + stdout: stdout.stream, + stderr: stderr.stream, + stdin: Readable.from([]), + inspectResources: async () => GENEROUS_RESOURCES, + execute: podmanExecute(null) + }); + assert.equal(code, 0); + assert.match(stdout.value(), /PASS Podman CLI: available/); + assert.match(stdout.value(), /PASS Podman Compose: available \(Docker Compose v2 provider\)/); + assert.match(stdout.value(), /PASS Podman Engine: available/); + assert.match(stdout.value(), /PASS Podman runtime: Podman 5\.2\.2, rootless, cgroup v2/); + assert.doesNotMatch(stdout.value(), /Docker CLI/); +}); + +test("storage planning probes Podman and accepts its bare-hex image ids", async () => { + const root = await mkdtemp(join(tmpdir(), "spaceapp-podman-storage-")); + const config = createDefaultConfig({ version: RUNTIME_VERSION, profile: "small", engine: "podman" }); + const bareHexId = "a".repeat(64); + const execute = async (spec, io) => { + if (spec.command !== "podman") return 127; + if (spec.args[0] === "info") { + io?.stdout?.write?.(JSON.stringify(PODMAN_INFO)); + return 0; + } + if (spec.args.includes("inspect")) { + io?.stdout?.write?.(bareHexId); + return 0; + } + return 0; + }; + const plan = await inspectStoragePlan({ + root, + config, + existingConfig: null, + resources: { ...GENEROUS_RESOURCES }, + execute, + operation: "fresh" + }); + assert.equal(plan.engine, "podman"); + assert.deepEqual(plan.missingImages, []); + const checks = storageChecks(plan, plan.resources); + assert.equal(checks[1].name, "Podman storage"); +}); + +async function installPodmanEndToEnd(root) { + const calls = []; + const stdout = capture(); + const stderr = capture(); + const code = await run(["install", "--engine", "podman", "--profile", "small", "--no-open"], { + env: { SPACEAPP_HOME: root }, + platform: "linux", + stdout: stdout.stream, + stderr: stderr.stream, + stdin: ttyStdin(...FRESH_APPROVALS), + inspectResources: async () => GENEROUS_RESOURCES, + request: readyRequest, + sleep: async () => {}, + execute: podmanExecute(calls) + }); + return { code, calls, stdout, stderr }; +} + +test("install --engine podman runs the whole flow on Podman and persists the engine", async () => { + const root = await mkdtemp(join(tmpdir(), "spaceapp-podman-e2e-")); + const { code, calls, stdout } = await installPodmanEndToEnd(root); + assert.equal(code, 0); + assert.match(stdout.value(), /Container engine: not configured -> podman/); + assert.match(stdout.value(), /SpaceApp is ready at http:\/\/127\.0\.0\.1:4911/); + const config = JSON.parse(await readFile(join(root, "config.json"), "utf8")); + assert.equal(config.engine, "podman"); + // Every executed command went to Podman; Docker was never invoked. + assert.ok(calls.length > 0); + assert.ok(calls.every((spec) => spec.command === "podman")); + const composeActions = calls.filter((spec) => spec.args[0] === "compose").map((spec) => spec.args.at(-1)); + assert.ok(composeActions.includes("pull")); + // Status on the installed root also drives Podman. + const statusCalls = []; + const statusCode = await run(["status"], { + env: { SPACEAPP_HOME: root }, + platform: "linux", + stdout: capture().stream, + stderr: capture().stream, + stdin: Readable.from([]), + execute: podmanExecute(statusCalls) + }); + assert.equal(statusCode, 0); + assert.ok(statusCalls.every((spec) => spec.command === "podman")); +}); + +test("install without --engine picks Podman when the Docker CLI is absent", async () => { + const root = await mkdtemp(join(tmpdir(), "spaceapp-podman-autopick-")); + const stdout = capture(); + const code = await run(["install", "--profile", "small", "--no-open"], { + env: { SPACEAPP_HOME: root }, + platform: "linux", + stdout: stdout.stream, + stderr: capture().stream, + stdin: ttyStdin(...FRESH_APPROVALS), + inspectResources: async () => GENEROUS_RESOURCES, + request: readyRequest, + sleep: async () => {}, + execute: podmanExecute(null) + }); + assert.equal(code, 0); + assert.match(stdout.value(), /Docker is not available, but Podman is/); + const config = JSON.parse(await readFile(join(root, "config.json"), "utf8")); + assert.equal(config.engine, "podman"); +}); + +test("switching engines on an existing installation is refused with the migration path", async () => { + const root = await mkdtemp(join(tmpdir(), "spaceapp-podman-switch-")); + const { code } = await installPodmanEndToEnd(root); + assert.equal(code, 0); + await assert.rejects( + run(["install", "--engine", "docker", "--no-open"], { + env: { SPACEAPP_HOME: root }, + platform: "linux", + stdout: capture().stream, + stderr: capture().stream, + stdin: ttyStdin("y"), + inspectResources: async () => GENEROUS_RESOURCES, + request: readyRequest, + sleep: async () => {}, + execute: podmanExecute(null) + }), + /Switching container engines on an existing installation is refused/ + ); + const config = JSON.parse(await readFile(join(root, "config.json"), "utf8")); + assert.equal(config.engine, "podman"); +}); + +test("--access host-root is refused with the Podman engine", async () => { + const root = await mkdtemp(join(tmpdir(), "spaceapp-podman-hostroot-")); + await assert.rejects( + run(["install", "--engine", "podman", "--access", "host-root", "--no-open"], { + env: { SPACEAPP_HOME: root }, + platform: "linux", + stdout: capture().stream, + stderr: capture().stream, + stdin: ttyStdin(...FRESH_APPROVALS), + inspectResources: async () => GENEROUS_RESOURCES, + request: readyRequest, + sleep: async () => {}, + execute: podmanExecute(null) + }), + /host-root is refused with the Podman engine/ + ); +}); + +test("the support bundle carries the Podman engine facts and no secrets", async () => { + const root = await podmanInstallationRoot(); + const outPath = join(root, "bundle.json"); + const code = await run(["support-bundle", "--out", outPath], { + env: { SPACEAPP_HOME: root }, + platform: "linux", + stdout: capture().stream, + stderr: capture().stream, + stdin: Readable.from([]), + request: async () => { throw new Error("offline"); }, + execute: podmanExecute(null) + }); + assert.equal(code, 0); + const bundle = JSON.parse(await readFile(outPath, "utf8")); + assert.equal(bundle.installation.config.engine, "podman"); + assert.equal(bundle.engine.name, "podman"); + assert.equal(bundle.engine.version, "5.2.2"); + assert.equal(bundle.engine.rootless, true); + assert.equal(bundle.engine.cgroupVersion, "v2"); + assert.doesNotMatch(JSON.stringify(bundle), /secret/i); +});