From 08b9511c529ec2cad145665ce1d13a9f70090cc4 Mon Sep 17 00:00:00 2001 From: Khanh Tran <218711032+Khanhtnq@users.noreply.github.com> Date: Thu, 22 Oct 2026 23:34:15 +0700 Subject: [PATCH] feat(run-spaceapp): support rootless Podman as a container engine --- .github/workflows/platform-matrix.yml | 101 ++++ README.md | 2 +- docs/getting-started.md | 24 + packages/run-spaceapp/README.md | 24 + packages/run-spaceapp/src/cli.mjs | 465 +++++++++++++---- packages/run-spaceapp/src/index.mjs | 69 ++- packages/run-spaceapp/src/prerequisites.mjs | 189 +++++++ packages/run-spaceapp/src/storage.mjs | 46 +- packages/run-spaceapp/tests/cli.test.mjs | 4 +- packages/run-spaceapp/tests/podman.test.mjs | 545 ++++++++++++++++++++ packages/run-spaceapp/tests/wizard.test.mjs | 2 +- 11 files changed, 1355 insertions(+), 116 deletions(-) create mode 100644 packages/run-spaceapp/tests/podman.test.mjs diff --git a/.github/workflows/platform-matrix.yml b/.github/workflows/platform-matrix.yml index e09ecb53c..1fe0d839c 100644 --- a/.github/workflows/platform-matrix.yml +++ b/.github/workflows/platform-matrix.yml @@ -57,3 +57,104 @@ jobs: - run: npm run check -w run-spaceapp - run: npm run pack:dry-run -w run-spaceapp - run: node packages/run-spaceapp/bin/spaceapp.mjs --version + + podman-linux: + name: Launcher / ubuntu-latest / Rootless Podman (small) + runs-on: ubuntu-latest + timeout-minutes: 30 + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + + - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0 + with: + node-version: "22.23.0" + cache: npm + + - name: Install workspace dependencies + run: npm ci + + - name: Ensure rootless Podman 5+ and cgroup v2 delegation + run: | + sudo apt-get update + sudo apt-get install -y podman uidmap slirp4netns fuse-overlayfs dbus-user-session docker-compose-v2 + PODMAN_MAJOR="$(podman --version | sed -E 's/.*version ([0-9]+).*/\1/')" + if [ "${PODMAN_MAJOR:-0}" -lt 5 ]; then + PODMAN_STATIC_VERSION="v5.8.8" + PODMAN_STATIC_SHA256="e0e12f9de61bdb054d19a77df0e84ddd51cadfee13688e0fb607d61b6833e8cd" + if curl -fsSL -o /tmp/podman-linux-amd64.tar.gz "https://github.com/mgoltzsche/podman-static/releases/download/${PODMAN_STATIC_VERSION}/podman-linux-amd64.tar.gz" && \ + echo "${PODMAN_STATIC_SHA256} /tmp/podman-linux-amd64.tar.gz" | sha256sum -c -; then + tar -xzf /tmp/podman-linux-amd64.tar.gz -C /tmp + sudo cp -r /tmp/podman-linux-amd64/usr /tmp/podman-linux-amd64/etc / + sudo cp -f /usr/local/bin/podman /usr/bin/podman || true + hash -r + elif [ -x /home/linuxbrew/.linuxbrew/bin/brew ]; then + /home/linuxbrew/.linuxbrew/bin/brew install podman + echo "/home/linuxbrew/.linuxbrew/bin" >> "$GITHUB_PATH" + export PATH="/home/linuxbrew/.linuxbrew/bin:$PATH" + fi + fi + sudo mkdir -p /etc/systemd/system/user@.service.d + printf "[Service]\nDelegate=cpu cpuset io memory pids\n" | sudo tee /etc/systemd/system/user@.service.d/delegate.conf >/dev/null + sudo systemctl daemon-reload + sudo loginctl enable-linger "$USER" || true + sudo systemctl start "user@$(id -u).service" || true + sudo systemctl restart "user@$(id -u).service" || true + export XDG_RUNTIME_DIR="/run/user/$(id -u)" + export DBUS_SESSION_BUS_ADDRESS="unix:path=${XDG_RUNTIME_DIR}/bus" + systemctl --user enable --now podman.socket || true + if [ ! -S "${XDG_RUNTIME_DIR}/podman/podman.sock" ]; then + mkdir -p "${XDG_RUNTIME_DIR}/podman" + nohup podman system service --time=0 "unix://${XDG_RUNTIME_DIR}/podman/podman.sock" >/tmp/podman-service.log 2>&1 & + for _ in $(seq 1 20); do + [ -S "${XDG_RUNTIME_DIR}/podman/podman.sock" ] && break + sleep 0.5 + done + fi + echo "XDG_RUNTIME_DIR=${XDG_RUNTIME_DIR}" >> "$GITHUB_ENV" + echo "DBUS_SESSION_BUS_ADDRESS=${DBUS_SESSION_BUS_ADDRESS}" >> "$GITHUB_ENV" + echo "DOCKER_HOST=unix://${XDG_RUNTIME_DIR}/podman/podman.sock" >> "$GITHUB_ENV" + podman --version + podman compose version + podman info --format '{{json .}}' + + - name: Install small profile with rootless Podman, verify readyz, backup, and uninstall + env: + SPACEAPP_HOME: ${{ runner.temp }}/spaceapp-podman-home + SPACEAPP_TELEMETRY: "0" + run: | + mkdir -p "$SPACEAPP_HOME" + node packages/run-spaceapp/bin/spaceapp.mjs install \ + --profile small \ + --engine podman \ + --non-interactive \ + --answers '{"confirm":true,"open":false}' \ + --no-open + curl -fsS http://127.0.0.1:4911/readyz + node packages/run-spaceapp/bin/spaceapp.mjs status + node packages/run-spaceapp/bin/spaceapp.mjs doctor + node packages/run-spaceapp/bin/spaceapp.mjs backup + node packages/run-spaceapp/bin/spaceapp.mjs uninstall \ + --purge-data \ + --non-interactive \ + --answers '{"confirm":"DELETE"}' + CONTAINERS="$(podman ps -aq)" + if [ -n "$CONTAINERS" ]; then + echo "Unexpected remaining Podman containers: $CONTAINERS" >&2 + podman ps -a >&2 + exit 1 + fi + VOLUMES="$(podman volume ls -q)" + if [ -n "$VOLUMES" ]; then + echo "Unexpected remaining Podman volumes: $VOLUMES" >&2 + podman volume ls >&2 + exit 1 + fi + + - name: Diagnostic logs on failure + if: failure() + run: | + podman ps -a || true + podman logs $(podman ps -aq) || true + diff --git a/README.md b/README.md index a3e90d971..c3293cf1f 100644 --- a/README.md +++ b/README.md @@ -92,7 +92,7 @@ Coordinate multi-step tasks conversationally through the Room Agent or speak nat - **Hardware**: - Minimum (`small` profile): 4 CPU cores, 8 GB-class RAM (at least 7 GiB reported by the operating system), 6.5 GiB free disk. - `large` profile (adds the managed browser): 8 CPU cores, 15 GiB RAM, 11 GiB free disk. -- **Docker**: If Docker is not installed, the installer automatically installs official Docker Desktop (Windows/macOS) or Docker Engine (Linux). +- **Container Engine**: Docker (default) or rootless Podman (Linux/macOS, Podman 5+ with Compose support verified via `podman compose version`). If Docker is not installed, the installer automatically installs official Docker Desktop (Windows/macOS) or Docker Engine (Linux). To use Podman, run `install --engine podman` or select it in the interactive installer. ### Run the Installation Command diff --git a/docs/getting-started.md b/docs/getting-started.md index c56d99ce9..39a99377f 100644 --- a/docs/getting-started.md +++ b/docs/getting-started.md @@ -87,6 +87,30 @@ the installer resumes automatically in Command Prompt without entering the command again. SpaceApp reuses Docker Desktop's WSL2 Linux environment and does not install a second full virtual machine. +## Container engines: Docker and Rootless Podman + +SpaceApp supports Docker and rootless Podman as container engines: + +- **Docker** (default): Docker Engine or Docker Desktop on Linux, macOS, and Windows. +- **Rootless Podman**: Podman 5+ with Compose support (`podman compose version`) on Linux and macOS. + +To install using Podman: + +```bash +npx --yes run-spaceapp@latest install --engine podman +``` + +On Linux and macOS, if Docker is unavailable but Podman is detected on PATH, the interactive installation wizard automatically offers to use Podman. + +### Podman considerations + +- **Rootless by default**: Podman executes under your user account with user namespace mapping (`keep-id:uid=10001,gid=10001` for unprivileged containers). +- **SELinux compatibility**: Registered workspace bind mounts are automatically annotated with `:z` relabeling for SELinux enforcing environments. +- **cgroup v2 & Resource limits**: On Linux, cgroup v2 with CPU and memory delegation (`Delegate=cpu cpuset io memory pids` in systemd) enables resource limit enforcement. If controller delegation is not configured, containers run without CPU/memory limits. +- **macOS virtual machine resources**: Ensure your Podman machine has sufficient CPU, RAM, and disk for your selected profile (e.g. `podman machine set --cpus 4 --memory 11264 --disk-size 50`). +- **Access mode limitation**: The `--access host-root` mode requires Docker daemon host mount semantics and is not supported with rootless Podman. Use `--access isolated` (the default). +- **Engine switching**: Switching the container engine on an existing installation is not directly supported. To migrate between Docker and Podman, create a backup with `run-spaceapp backup`, reinstall with `--engine `, and restore with `run-spaceapp restore`. + ## Installation profiles The universal install command defaults to `--profile auto`: diff --git a/packages/run-spaceapp/README.md b/packages/run-spaceapp/README.md index 0e8b1ab3b..d5f71f462 100644 --- a/packages/run-spaceapp/README.md +++ b/packages/run-spaceapp/README.md @@ -62,6 +62,30 @@ 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 and Rootless Podman + +SpaceApp supports both Docker and rootless Podman as container engines: + +- **Docker** (default): Docker Engine or Docker Desktop on Linux, macOS, and Windows. +- **Rootless Podman**: Podman 5 or newer with Compose support (`podman compose version`) on Linux and macOS. + +To install with Podman: + +```bash +npx --yes run-spaceapp@latest install --engine podman +``` + +Or select Podman interactively during installation. If Docker is not found on Linux or macOS but Podman is available, the launcher automatically prompts to use Podman. + +### Podman Requirements and Characteristics + +- **Rootless execution**: Podman runs rootless under your user account with user namespace mapping (`keep-id:uid=10001,gid=10001` for unprivileged containers). +- **SELinux compatibility**: Host workspace bind mounts automatically receive `:z` relabeling so containers can access registered workspaces under SELinux enforcing mode. +- **cgroup v2 & Resource limits**: On Linux, cgroup v2 with CPU and memory controller delegation (`Delegate=cpu cpuset io memory pids`) is recommended for enforcing resource limits. If systemd controller delegation is not configured, containers run without CPU/memory limits. +- **macOS Podman Machine**: On macOS, Podman runs inside a virtual machine. Ensure the machine has adequate resources for your profile (e.g. `podman machine set --cpus 4 --memory 11264 --disk-size 50`). +- **Access mode limitation**: The `--access host-root` mode requires Docker daemon host mount semantics and is not supported with rootless Podman. Use `--access isolated` (the default) with Podman. +- **Engine switching**: Switching the container engine of an existing installation is not supported directly. To switch between Docker and Podman, back up your installation with `run-spaceapp backup`, reinstall with `--engine `, and restore with `run-spaceapp restore`. + ### Telemetry Telemetry is off unless you turn it on. The install wizard lists what is sent diff --git a/packages/run-spaceapp/src/cli.mjs b/packages/run-spaceapp/src/cli.mjs index e0c16bdb4..f7169dbd9 100644 --- a/packages/run-spaceapp/src/cli.mjs +++ b/packages/run-spaceapp/src/cli.mjs @@ -5,6 +5,7 @@ import { createInterface } from "node:readline"; import { once } from "node:events"; import { PromptController } from "./prompt-controller.mjs"; import { + access, copyFile, mkdir, mkdtemp, @@ -47,9 +48,13 @@ import { writeRuntimeFiles, writeSetupToken } from "./index.mjs"; -import { inspectStoragePlan, storageChecks } from "./storage.mjs"; +import { captureProbe, inspectStoragePlan, storageChecks } from "./storage.mjs"; import { + detectAvailableEngines, ensureDockerAvailable, + ensurePodmanAvailable, + podmanInstallHelp, + podmanIsReady, prepareDockerCliPath, windowsPowerShellArgs } from "./prerequisites.mjs"; @@ -78,6 +83,7 @@ const trustedCommands = new Set([ "docker", "hdiutil", "open", + "podman", "powershell.exe", "sg", "shutdown.exe", @@ -274,6 +280,7 @@ export async function run(argv, { execute = executeCommand, inspectResources = inspectSystemResources, ensureDocker = ensureDockerAvailable, + ensurePodman = ensurePodmanAvailable, prepareDockerPath = prepareDockerCliPath, request = globalThis.fetch, sleep = wait, @@ -338,7 +345,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; @@ -379,6 +386,7 @@ export async function run(argv, { execute, inspectResources, ensureDocker, + ensurePodman, prepareDockerPath, request, sleep, @@ -408,11 +416,16 @@ export async function run(argv, { if (command === "doctor" && !args.includes("--fix")) { assertNoArgs(args, "doctor"); - return doctor({ root, platform, stdout, stderr, execute, stdin, inspectResources }); + let configuredEngine = undefined; + try { + const existing = await loadConfig(root); + configuredEngine = existing?.engine; + } catch {} + return doctor({ root, platform, stdout, stderr, execute, stdin, inspectResources, engine: configuredEngine }); } 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)) { @@ -427,7 +440,7 @@ export async function run(argv, { if (["up", "down", "status", "logs"].includes(command)) { assertNoArgs(args, command); - return runtimeExecute(composeCommand(command, root, { profile: config.profile, companionsEnabled: config.companionsEnabled }), { stdin, stdout, stderr }); + return runtimeExecute(composeCommand(command, root, { profile: config.profile, companionsEnabled: config.companionsEnabled, engine: config.engine }), { stdin, stdout, stderr }); } if (command === "open") { assertNoArgs(args, "open"); @@ -440,7 +453,7 @@ export async function run(argv, { } if (command === "doctor") { assertNoArgs(args, "doctor"); - return doctor({ root, platform, stdout, stderr, execute, stdin, inspectResources }); + return doctor({ root, platform, stdout, stderr, execute, stdin, inspectResources, engine: config?.engine }); } if (command === "support-bundle") { let outPath = null; @@ -452,11 +465,22 @@ export async function run(argv, { } if (command === "repair") { const dryRun = args.includes("--dry-run"); - return repairRuntime({ root, config, platform, stdin, stdout, stderr, execute: runtimeExecute, request, sleep, dryRun }); + return repairRuntime({ root, config, platform, stdin, stdout, stderr, execute: runtimeExecute, request, sleep, ensureDocker, ensurePodman, env, arch, inspectResources, dryRun }); } if (command === "reinstall") { const keepData = !args.includes("--purge-data"); - return reinstallCommand({ root, config, platform, stdin, stdout, stderr, execute: runtimeExecute, request, sleep, keepData }); + let requestedEngine = undefined; + for (let i = 0; i < args.length; i += 1) { + if (args[i] === "--engine" && i + 1 < args.length) { + requestedEngine = args[++i]; + } else if (args[i].startsWith("--engine=")) { + requestedEngine = args[i].slice("--engine=".length); + } + } + if (requestedEngine !== undefined && !["docker", "podman"].includes(requestedEngine)) { + throw new Error(`Unsupported engine: ${requestedEngine}`); + } + return reinstallCommand({ root, config, platform, stdin, stdout, stderr, execute: runtimeExecute, request, sleep, keepData, requestedEngine }); } if (command === "factory-reset") { assertNoArgs(args, "factory-reset"); @@ -489,7 +513,7 @@ export async function run(argv, { execute: runtimeExecute, request, sleep, - inspectResources, ensureDocker, env, arch + inspectResources, ensureDocker, ensurePodman, env, arch }); } if (command === "rollback") { @@ -512,14 +536,14 @@ export async function run(argv, { await writeRuntimeFiles(root, rollback); const pullCode = await withHeadlessDockerConfig( platform, - composeCommand("pull", root, { profile: rollback.profile, companionsEnabled: rollback.companionsEnabled }), + composeCommand("pull", root, { profile: rollback.profile, companionsEnabled: rollback.companionsEnabled, engine: rollback.engine }), (pullSpec) => runtimeExecute(pullSpec, { stdin, stdout, stderr }) ); if (pullCode !== 0) { await writeRuntimeFiles(root, config); return pullCode; } - const upCode = await runtimeExecute(composeCommand("up", root, { profile: rollback.profile, companionsEnabled: rollback.companionsEnabled }), { stdin, stdout, stderr }); + const upCode = await runtimeExecute(composeCommand("up", root, { profile: rollback.profile, companionsEnabled: rollback.companionsEnabled, engine: rollback.engine }), { stdin, stdout, stderr }); if (upCode !== 0) { await writeRuntimeFiles(root, config); return upCode; @@ -551,63 +575,64 @@ export async function run(argv, { const backupId = await selectLatestBackupId(root); const backupCode = await backupWithWritersPaused(runtimeExecute, root, config, { stdin, stdout, stderr }); if (backupCode !== 0) return backupCode; - const stopCode = await runtimeExecute(composeCommand("stopForPortableRestore", root, { profile: config.profile, companionsEnabled: config.companionsEnabled }), { stdin, stdout, stderr }); + const stopCode = await runtimeExecute(composeCommand("stopForPortableRestore", root, { profile: config.profile, companionsEnabled: config.companionsEnabled, engine: config.engine }), { stdin, stdout, stderr }); if (stopCode !== 0) return stopCode; const restoreCode = await runtimeExecute( - composeCommand("restore", root, { backupId, profile: config.profile }), + composeCommand("restore", root, { backupId, profile: config.profile, engine: config.engine }), { stdin, stdout, stderr } ); if (restoreCode !== 0) return restoreCode; - const upCode = await runtimeExecute(composeCommand("up", root, { profile: config.profile, companionsEnabled: config.companionsEnabled }), { stdin, stdout, stderr }); + const upCode = await runtimeExecute(composeCommand("up", root, { profile: config.profile, companionsEnabled: config.companionsEnabled, engine: config.engine }), { stdin, stdout, stderr }); if (upCode !== 0) return upCode; await restoreBackupExtras({ root, config, backupId, stdin, stdout, stderr, execute: runtimeExecute }) .catch((error) => stderr.write(`The data was restored; the CLI settings of the backup were not: ${error.message}\n`)); return 0; } if (command === "uninstall") { + const engineLabel = config.engine === "podman" ? "Podman" : "Docker"; if (args.length === 0) { const approved = await requireChangeApproval(stdin, stdout, stderr, [ "Stop and remove SpaceApp containers and network.", "Data, configuration, secrets, and backups remain at the installation root.", - "Docker volumes are retained; the global SpaceApp CLI remains installed." + `${engineLabel} volumes are retained; the global SpaceApp CLI remains installed.` ]); if (!approved) { return 0; } stdout.write("Stopping and removing SpaceApp containers and network...\n"); const code = await runtimeExecute( - composeCommand("down", root, { profile: config.profile, companionsEnabled: config.companionsEnabled }), + composeCommand("down", root, { profile: config.profile, companionsEnabled: config.companionsEnabled, engine: config.engine }), { stdin, stdout, stderr } ); if (code === 0) { stdout.write("SpaceApp runtime removed successfully. It is safe to run this command again.\n"); stdout.write(`Data, configuration, secrets, and backups remain at ${root}.\n`); - stdout.write("Docker volumes are retained. The global SpaceApp CLI remains installed.\n"); + stdout.write(`${engineLabel} volumes are retained. The global SpaceApp CLI remains installed.\n`); stdout.write("To remove only the global CLI, run: npm uninstall -g run-spaceapp\n"); } else { - stderr.write(`Uninstall could not remove the runtime (Docker exit ${code}).\n`); + stderr.write(`Uninstall could not remove the runtime (${engineLabel} exit ${code}).\n`); stdout.write(`Data, configuration, secrets, and backups remain at ${root}.\n`); stdout.write("The global SpaceApp CLI remains installed.\n"); } return code; } if (args.length === 1 && args[0] === "--purge-data") { - const confirmation = await readSecret(stdin, stdout, "Type DELETE to remove Docker volumes: ", { mask: false, answerKey: "purgeConfirm" }); + const confirmation = await readSecret(stdin, stdout, `Type DELETE to remove ${engineLabel} volumes: `, { mask: false, answerKey: "purgeConfirm" }); if (confirmation !== "DELETE") { throw new Error("Purge cancelled."); } - stdout.write("Removing SpaceApp containers, network, and Docker volumes...\n"); + stdout.write(`Removing SpaceApp containers, network, and ${engineLabel} volumes...\n`); const code = await runtimeExecute( - composeCommand("purge", root, { profile: config.profile, companionsEnabled: config.companionsEnabled }), + composeCommand("purge", root, { profile: config.profile, companionsEnabled: config.companionsEnabled, engine: config.engine }), { stdin, stdout, stderr } ); if (code === 0) { - stdout.write("SpaceApp runtime and Docker volumes removed successfully.\n"); + stdout.write(`SpaceApp runtime and ${engineLabel} volumes removed successfully.\n`); stdout.write(`Host configuration and backups remain at ${root} for manual review.\n`); stdout.write("The global SpaceApp CLI remains installed.\n"); stdout.write("To remove only the global CLI, run: npm uninstall -g run-spaceapp\n"); } else { - stderr.write(`SpaceApp Docker volume purge failed (Docker exit ${code}).\n`); + stderr.write(`SpaceApp ${engineLabel} volume purge failed (${engineLabel} exit ${code}).\n`); stdout.write(`Host files and the global SpaceApp CLI remain at ${root}.\n`); } return code; @@ -632,6 +657,7 @@ async function installCommand(args, { execute, inspectResources, ensureDocker, + ensurePodman = ensurePodmanAvailable, prepareDockerPath, request, sleep, @@ -646,7 +672,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; @@ -654,6 +680,7 @@ async function installCommand(args, { const { requestedProfile, requestedAccessMode, + requestedEngine, noOpen, companionsEnabled, telemetryEnabled, @@ -681,10 +708,12 @@ async function installCommand(args, { "WARNING: host-root access lets SpaceApp CLI sessions read and modify the entire Linux host through /host, including credentials and system files.\n" ); } - const result = await prepareInstallation(root, { + const engine = requestedEngine || existingConfig?.engine || "docker"; + let result = await prepareInstallation(root, { version: runtimeVersion, profile, accessMode, + engine, ...(companionsEnabled ? { companionsEnabled } : {}) }); // The stored choice also drives the app's first_answer event after later updates, so an explicit @@ -709,6 +738,9 @@ async function installCommand(args, { stdout.write( `Runtime image version: ${existingConfig?.version ?? "not installed"} -> ${result.config.version}\n` ); + stdout.write( + `Engine: ${existingConfig?.engine ?? "not configured"} -> ${result.config.engine}\n` + ); stdout.write( `Profile: ${existingConfig?.profile ?? "not configured"} -> ${result.config.profile}\n` ); @@ -716,8 +748,8 @@ async function installCommand(args, { `Access: ${existingConfig?.accessMode ?? "not configured"} -> ${result.config.accessMode}\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"); + ? `Preserved existing data, workspaces, credentials, secrets, and persistent ${result.config.engine === "podman" ? "Podman" : "Docker"} volumes.\n` + : `Future refreshes preserve data, workspaces, credentials, secrets, and persistent ${result.config.engine === "podman" ? "Podman" : "Docker"} volumes.\n`); stdout.write( `Selected profile: ${profile} (${formatGibibytes(resources.totalMemoryBytes)} GiB system memory detected).\n` ); @@ -733,7 +765,8 @@ async function installCommand(args, { stdin, inspectResources, resources, - profile, operation + profile, operation, + engine: result.config.engine }); stderr.write( `Installation stopped before downloading images. Fix the failed checks and run "${UNIVERSAL_COMMAND} install" again.\n` @@ -741,20 +774,72 @@ async function installCommand(args, { return failInstall(doctorCode); } - stage = "docker"; - const prerequisiteResult = await ensureDocker({ - platform, - arch, - env, - stdin, - stdout, - stderr, - execute, - installArgs: { root, requestedProfile, requestedAccessMode, noOpen, autoConfirm: true } - }); + stage = result.config.engine === "podman" ? "podman" : "docker"; + let prerequisiteResult = result.config.engine === "podman" + ? await ensurePodman({ + platform, + arch, + env, + stdin, + stdout, + stderr, + execute, + profile, + installArgs: { root, requestedProfile, requestedAccessMode, requestedEngine: result.config.engine, noOpen, autoConfirm: true } + }) + : await ensureDocker({ + platform, + arch, + env, + stdin, + stdout, + stderr, + execute, + installArgs: { root, requestedProfile, requestedAccessMode, requestedEngine: result.config.engine, noOpen, autoConfirm: true } + }); if (prerequisiteResult.reexecuted) { return prerequisiteResult.code; } + const explicitEngine = parseInstallArgs(args).requestedEngine !== undefined || stdin?._spaceappPromptController?.answers?.engine !== undefined; + if (prerequisiteResult.code !== 0 && !existingConfig && !explicitEngine && accessMode !== "host-root" && interactiveAvailable(stdin)) { + const podmanReady = await podmanIsReady(execute, { platform }); + if (podmanReady) { + const pickPodman = await promptYesNo( + stdin, + stdout, + "Docker was not detected, but Podman is available. Use Podman as container engine?", + { defaultYes: false, answerKey: "engine" } + ); + if (pickPodman) { + const previousConfig = result.config; + result = await prepareInstallation(root, { + version: runtimeVersion, + profile, + accessMode, + engine: "podman", + ...(companionsEnabled ? { companionsEnabled } : {}) + }); + if (previousConfig.telemetry) { + result.config = { ...result.config, telemetry: true, installId: previousConfig.installId }; + } + stage = "podman"; + prerequisiteResult = await ensurePodman({ + platform, + arch, + env, + stdin, + stdout, + stderr, + execute, + profile, + installArgs: { root, requestedProfile, requestedAccessMode, requestedEngine: "podman", noOpen, autoConfirm: true } + }); + if (prerequisiteResult.reexecuted) { + return prerequisiteResult.code; + } + } + } + } if (prerequisiteResult.code !== 0) { stderr.write( `Installation stopped before downloading images. Fix the failed checks and run "${UNIVERSAL_COMMAND} install" again.\n` @@ -764,14 +849,14 @@ async function installCommand(args, { stage = "doctor"; - let storagePlan = await inspectStoragePlan({ root, config: result.config, existingConfig, resources, execute, operation }); + let storagePlan = await inspectStoragePlan({ root, config: result.config, existingConfig, resources, execute, operation, platform }); if (requestedProfile === "auto" && !existingConfig) { const engineProfile = resolveInstallProfile("auto", storagePlan.resources); if (engineProfile !== profile) { profile = engineProfile; result.config = { ...result.config, profile }; - stdout.write(`Docker resources select profile: ${profile}.\n`); - storagePlan = await inspectStoragePlan({ root, config: result.config, resources: storagePlan.resources, execute, operation }); + stdout.write(`${result.config.engine === "podman" ? "Podman" : "Docker"} resources select profile: ${profile}.\n`); + storagePlan = await inspectStoragePlan({ root, config: result.config, resources: storagePlan.resources, execute, operation, platform }); } } const doctorCode = await doctor({ @@ -784,7 +869,8 @@ async function installCommand(args, { inspectResources, resources, profile, - dockerReady: true, operation, storagePlan + dockerReady: true, operation, storagePlan, + engine: result.config.engine }); if (doctorCode !== 0) { stderr.write( @@ -798,6 +884,7 @@ async function installCommand(args, { const failAfterRuntimeMutation = async (code) => { await failInstall(code); if (runtimeMutationAttempted) { + const activeEngine = result?.config?.engine || existingConfig?.engine; await reportInstallDiagnostics({ root, stateRoot: stagedStateRoot, @@ -806,7 +893,8 @@ async function installCommand(args, { stdin, stdout, stderr, - execute + execute, + engine: activeEngine }); if (upgradeCheckpoint) { await retainFailedUpgrade(root, upgradeCheckpoint, @@ -821,7 +909,8 @@ async function installCommand(args, { stdin, stdout, stderr, - execute + execute, + engine: activeEngine }); runtimeMutationAttempted = false; } @@ -835,6 +924,7 @@ async function installCommand(args, { profile, stateRoot: stagedStateRoot, companionsEnabled, + engine: result.config.engine, ...options }); const pullCode = await withHeadlessDockerConfig( @@ -850,7 +940,7 @@ async function installCommand(args, { if (pullCode !== 0) return failInstall(pullCode); stage = "storage"; if (await verifyStorage({ root, config: result.config, existingConfig, operation, - inspectResources, execute, stdout, stderr }) !== 0) return failInstall(1); + inspectResources, execute, stdout, stderr, platform }) !== 0) return failInstall(1); stage = "start"; if(existingConfig && existingConfig.version !== result.config.version) { upgradeCheckpoint = await createQuiescedCheckpoint(root, existingConfig, {stdin,stdout,stderr,execute,platform}); @@ -1027,7 +1117,7 @@ async function removeInactiveProfileServices({root, config, stateRoot = root, ex [settings.temporalEnabled, "removeTemporal", "medium"] ]) { if (enabled) continue; - const code = await execute(composeCommand(action, root, {profile, stateRoot}), {stdin, stdout, stderr}); + const code = await execute(composeCommand(action, root, {profile, stateRoot, engine: config?.engine}), {stdin, stdout, stderr}); if (code !== 0) { stderr.write("Inactive service cleanup failed. Data volumes were preserved. Run doctor --fix.\n"); return code; @@ -1078,6 +1168,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, @@ -1116,17 +1207,30 @@ async function planInteractiveSetup({ } const openBrowserChoice = await promptYesNo(stdin, stdout, "Open the web application when installation completes?", { defaultYes: !parsed.noOpen, answerKey: "open" }); + let engineChoice = parsed.requestedEngine; + if (!engineChoice && platform !== "win32") { + engineChoice = await promptChoice(stdin, stdout, "Which container engine?", [ + { label: "Docker (default; Docker Desktop or Engine)", value: "docker" }, + { label: "Podman (rootless container engine)", value: "podman" } + ], { defaultIndex: 0, answerKey: "engine" }); + } + const { resolved } = resolveProfileChoice(profileChoice); const approved = await finalConfirmation(stdin, stdout, [ `Installation root: ${root}`, `Runtime image version: not installed -> ${runtimeVersion}`, + ...(engineChoice === "podman" ? ["Container engine: podman"] : []), `Profile: ${profileChoice === "auto" ? `auto (${resolved})` : profileChoice}`, `Access mode: ${accessChoice}`, `Companions: ${companionsChoice ? "enabled" : "disabled"}`, `Telemetry: ${telemetryChoice ? "enabled" : "disabled"}`, - `Docker installation: ${dockerChoice ? "automatic if missing" : "not included"}`, + engineChoice === "podman" + ? "Podman engine: detected and ready" + : `Docker installation: ${dockerChoice ? "automatic if missing" : "not included"}`, `Open browser afterwards: ${openBrowserChoice ? "yes" : "no"}`, - "Future refreshes preserve data, workspaces, credentials, secrets, and persistent Docker volumes." + engineChoice === "podman" + ? "Future refreshes preserve data, workspaces, credentials, secrets, and persistent Podman volumes." + : "Future refreshes preserve data, workspaces, credentials, secrets, and persistent Docker volumes." ]); if (!approved) { return { exit: 0 }; @@ -1135,6 +1239,7 @@ async function planInteractiveSetup({ await offerUnattendedContinuation(root, runtimeVersion, stdin, stdout, { profile: profileChoice, accessMode: accessChoice, + engine: engineChoice, companionsEnabled: companionsChoice, telemetry: telemetryChoice, installId, @@ -1144,6 +1249,7 @@ async function planInteractiveSetup({ return { requestedProfile: profileChoice, requestedAccessMode: accessChoice, + requestedEngine: engineChoice, noOpen: !openBrowserChoice, companionsEnabled: companionsChoice, telemetryEnabled: telemetryChoice, @@ -1221,6 +1327,7 @@ async function planInteractiveSetup({ return { requestedProfile: parsed.requestedProfile, requestedAccessMode: parsed.requestedAccessMode, + requestedEngine: parsed.requestedEngine, noOpen: parsed.noOpen, companionsEnabled: parsed.companionsEnabled, telemetryEnabled: false @@ -1243,6 +1350,12 @@ function hasRequestedConfigChange(rawExisting, parsed) { if (parsed.requestedProfile !== "auto" && rawExisting.profile !== parsed.requestedProfile) { return true; } + if ( + parsed.requestedEngine !== undefined && + (rawExisting.engine || "docker") !== parsed.requestedEngine + ) { + return true; + } return false; } @@ -1280,7 +1393,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) { @@ -1293,25 +1406,28 @@ async function repairRuntime({ root, config, platform, stdin, stdout, stderr, ex "Repair configuration and recreate the current runtime without deleting data.", ...plan.actions.map((action) => action.detail) ])) return 1; - const prerequisites = await ensureDocker({platform, arch, env, stdin, stdout, stderr, execute, sleep: sleep || wait, - installArgs: {root,requestedProfile:repaired.profile,requestedAccessMode:repaired.accessMode,noOpen:true,autoConfirm:true}}); + const prerequisites = repaired.engine === "podman" + ? await ensurePodman({platform, arch, env, stdin, stdout, stderr, execute, profile: repaired.profile, + installArgs: {root,requestedProfile:repaired.profile,requestedAccessMode:repaired.accessMode,noOpen:true,autoConfirm:true}}) + : 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; + if (await verifyStorage({ root, config, existingConfig: config, operation: "repair", inspectResources, execute, stdout, stderr, platform }) !== 0) return 1; await saveConfig(root, config); await writeRuntimeFiles(root, config); const pullCode = await withHeadlessDockerConfig( platform, - composeCommand("pull", root, { profile: config.profile, companionsEnabled: config.companionsEnabled }), + composeCommand("pull", root, { profile: config.profile, companionsEnabled: config.companionsEnabled, engine: config.engine }), (pullSpec) => execute(pullSpec, { stdin, stdout, stderr }) ); if (pullCode !== 0) { writeWindowsCredentialHint(platform, stderr); return pullCode; } - if (await verifyStorage({ root, config, existingConfig: config, operation: "repair", inspectResources, execute, stdout, stderr }) !== 0) return 1; + if (await verifyStorage({ root, config, existingConfig: config, operation: "repair", inspectResources, execute, stdout, stderr, platform }) !== 0) return 1; const upCode = await execute( - composeCommand("repair", root, { profile: config.profile, companionsEnabled: config.companionsEnabled }), + composeCommand("repair", root, { profile: config.profile, companionsEnabled: config.companionsEnabled, engine: config.engine }), { stdin, stdout, stderr } ); if (upCode !== 0) { @@ -1372,11 +1488,11 @@ async function performUpdate({ writeWindowsCredentialHint(platform, stderr); throw new Error(`Image pull failed with Docker exit ${pullCode}.`); } - if (await verifyStorage({ root, config: updated, existingConfig: config, operation: "upgrade", inspectResources, execute, stdout, stderr }) !== 0) return 1; + if (await verifyStorage({ root, config: updated, existingConfig: config, operation: "upgrade", inspectResources, execute, stdout, stderr, platform }) !== 0) return 1; checkpoint = await createQuiescedCheckpoint(root, config, {stdin, stdout, stderr, execute, platform}); await commitInstallation(root, updated, { persistConfig: false }); const upCode = await execute( - composeCommand("up", root, { profile: updated.profile, companionsEnabled: updated.companionsEnabled }), + composeCommand("up", root, { profile: updated.profile, companionsEnabled: updated.companionsEnabled, engine: updated.engine }), { stdin, stdout, stderr } ); if (upCode !== 0) { @@ -1454,7 +1570,7 @@ async function collectFiles(directory, logicalDirectory) { // the database, files, memory and Temporal histories are one moment and CLI sessions survive (external review // 2026-10-16). Compose skips services that are not running. async function backupWithWritersPaused(execute, root, config, io) { - const options = { profile: config.profile, companionsEnabled: config.companionsEnabled }; + const options = { profile: config.profile, companionsEnabled: config.companionsEnabled, engine: config.engine }; io.stdout.write("Pausing SpaceApp writers for the backup; the app and CLI sessions resume afterwards...\n"); const pauseCode = await execute(composeCommand("pauseWriters", root, options), io); if (pauseCode !== 0) return pauseCode; @@ -1502,6 +1618,10 @@ async function createCheckpoint(root, config, { stdin, stdout, stderr, execute, "compose.workspaces.yml", "compose.host-access.yml" ]; + try { + await access(join(root, "compose.podman.yml")); + fileNames.push("compose.podman.yml"); + } catch {} for (const fileName of fileNames) { await copyFile(join(root, fileName), join(path, fileName)); manifest.files.push(await manifestEntry(join(path, fileName), fileName)); @@ -1517,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: config.engine }), { stdin: null, stdout: dumpStream, stderr } ); } finally { @@ -1603,7 +1723,8 @@ async function reportInstallDiagnostics({ stdin, stdout, stderr, - execute + execute, + engine }) { stderr.write("Collecting SpaceApp service status and recent logs before rollback...\n"); for (const action of ["status", "logs"]) { @@ -1613,14 +1734,15 @@ async function reportInstallDiagnostics({ composeCommand(action, root, { profile, stateRoot, - lines: 200 + lines: 200, + engine }), { stdin, stdout, stderr }, { platform, stderr } ); if (code !== 0) { stderr.write( - `SpaceApp could not collect ${action} diagnostics (Docker exit ${code}).\n` + `SpaceApp could not collect ${action} diagnostics (${engine === "podman" ? "Podman" : "Docker"} exit ${code}).\n` ); } } catch (error) { @@ -1641,7 +1763,8 @@ async function restoreRuntimeAfterFailedInstall({ stdin, stdout, stderr, - execute + execute, + engine }) { const runtimeExecute = (spec) => executeWithDockerDiagnostics( execute, @@ -1655,7 +1778,8 @@ async function restoreRuntimeAfterFailedInstall({ const restoreCode = await runtimeExecute( composeCommand("up", root, { profile: existingConfig.profile, - companionsEnabled: existingConfig.companionsEnabled + companionsEnabled: existingConfig.companionsEnabled, + engine: existingConfig.engine }) ); if (restoreCode === 0) { @@ -1674,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?.engine ?? engine }) ); if (stopCode !== 0) { @@ -1692,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) { @@ -1722,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 ( @@ -1731,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) { @@ -1931,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: config.engine, service }), { stdin, stdout, stderr } ); if (syncCode !== 0) { @@ -1947,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: config.engine, service }), { stdin, stdout, stderr } ); if (syncCode !== 0) { @@ -2046,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, provider, service, engine: config.engine }), { stdin, stdout, stderr }); } async function ownerCommand(args, { root, config, stdin, stdout, stderr, execute }) { if (args.length === 1 && args[0] === "rotate-setup-token") { const token = randomBytes(32).toString("base64url"); - const code = await execute(composeCommand("rotateOwnerSetupToken", root, { profile: config.profile, companionsEnabled: config.companionsEnabled }), { + const code = await execute(composeCommand("rotateOwnerSetupToken", root, { profile: config.profile, companionsEnabled: config.companionsEnabled, engine: config.engine }), { stdin, stdout, stderr, @@ -2073,7 +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: config.engine }), { stdin, stdout, stderr, @@ -2090,8 +2229,15 @@ async function reinstallCommand({ stderr, execute, request, - sleep + sleep, + requestedEngine }) { + const existingEngine = config.engine || "docker"; + if (requestedEngine && requestedEngine !== existingEngine) { + throw new Error( + `Switching container engine from "${existingEngine}" to "${requestedEngine}" is not supported on an existing installation. Back up your data with "${UNIVERSAL_COMMAND} backup", reinstall with --engine ${requestedEngine}, and restore with "${UNIVERSAL_COMMAND} restore".` + ); + } const approved = await requireChangeApproval(stdin, stdout, stderr, [ `Clean setup SpaceApp at ${root}`, "This recreates managed files, templates, and runtime containers.", @@ -2101,7 +2247,7 @@ async function reinstallCommand({ return 0; } const downCode = await execute( - composeCommand("down", root, { profile: config.profile, companionsEnabled: config.companionsEnabled }), + composeCommand("down", root, { profile: config.profile, companionsEnabled: config.companionsEnabled, engine: config.engine }), { stdin, stdout, stderr } ); if (downCode !== 0) { @@ -2109,7 +2255,7 @@ async function reinstallCommand({ } await writeRuntimeFiles(root, config); const upCode = await execute( - composeCommand("up", root, { profile: config.profile, companionsEnabled: config.companionsEnabled }), + composeCommand("up", root, { profile: config.profile, companionsEnabled: config.companionsEnabled, engine: config.engine }), { stdin, stdout, stderr } ); if (upCode !== 0) { @@ -2166,6 +2312,7 @@ async function supportBundleCommand({ version: config.version, profile: config.profile, accessMode: config.accessMode, + engine: config.engine ?? "docker", companionsEnabled: config.companionsEnabled, bindHost: config.bindHost, port: config.port, @@ -2173,10 +2320,51 @@ async function supportBundleCommand({ } : null }, + engine: null, services: {}, health: {} }; + const engine = config?.engine ?? "docker"; + let engineFacts = { + name: engine, + version: null, + rootless: null, + cgroupVersion: null, + limitsDelegated: null + }; + if (engine === "podman") { + try { + const raw = await captureProbe(execute, { command: "podman", args: ["info", "--format", "json"] }); + if (raw) { + const info = JSON.parse(raw); + const controllers = info.host?.cgroupControllers || []; + engineFacts = { + name: "podman", + version: info.version?.Version ?? null, + rootless: info.host?.security?.rootless ?? true, + cgroupVersion: info.host?.cgroupVersion || "v2", + limitsDelegated: Array.isArray(controllers) + ? (controllers.includes("cpu") && controllers.includes("memory")) + : true + }; + } + } catch {} + } else { + try { + const v = await captureProbe(execute, { command: "docker", args: ["version", "--format", "{{.Server.Version}}"] }); + const cg = await captureProbe(execute, { command: "docker", args: ["info", "--format", "{{.CgroupVersion}}"] }); + engineFacts = { + name: "docker", + version: v || null, + rootless: false, + cgroupVersion: cg || null, + limitsDelegated: true + }; + } catch {} + } + bundle.engine = engineFacts; + if (config) { const url = `http://${config.bindHost}:${config.port}`; if (typeof request === "function") { @@ -2241,7 +2429,7 @@ async function factoryResetCommand({ } const purgeCode = await execute( - composeCommand("purge", root, { profile: config.profile, companionsEnabled: config.companionsEnabled }), + composeCommand("purge", root, { profile: config.profile, companionsEnabled: config.companionsEnabled, engine: config.engine }), { stdin, stdout, stderr } ); if (purgeCode !== 0) { @@ -2253,9 +2441,10 @@ async function factoryResetCommand({ await rm(join(root, "config.json"), { force: true }); await rm(join(root, "runtime.env"), { force: true }); await rm(join(root, "compose.yml"), { force: true }); + await rm(join(root, "compose.podman.yml"), { force: true }); } catch {} - const result = await initializeInstallation(root, { version: runtimeVersion }); + const result = await initializeInstallation(root, { version: runtimeVersion, engine: config?.engine }); stdout.write(`SpaceApp factory reset complete. Clean installation initialized at ${root}\n`); if (result.setupToken) { stdout.write(`One-time setup token: ${result.setupToken}\n`); @@ -2264,7 +2453,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]`); } @@ -2295,10 +2484,10 @@ async function updateCommand(args, { root, config, version, platform, stdin, std return 0; } if (choice === "doctor") { - return doctor({ root, platform, stdout, stderr, execute, stdin, inspectResources: inspectSystemResources }); + return doctor({ root, platform, stdout, stderr, execute, stdin, inspectResources: inspectSystemResources, engine: config.engine }); } } - 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) { @@ -2337,13 +2526,16 @@ async function updateCommand(args, { root, config, version, platform, stdin, std return 0; } } - const prerequisites = await ensureDocker({ platform, arch, env, stdin, stdout, stderr, execute, - installArgs: { root, requestedProfile: config.profile, noOpen: true, autoConfirm: true } }); + const prerequisites = config.engine === "podman" + ? await ensurePodman({ platform, arch, env, stdin, stdout, stderr, execute, profile: config.profile, + installArgs: { root, requestedProfile: config.profile, requestedEngine: config.engine, noOpen: true, autoConfirm: true } }) + : await ensureDocker({ platform, arch, env, stdin, stdout, stderr, execute, + installArgs: { root, requestedProfile: config.profile, requestedEngine: config.engine, 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" }); + const storagePlan = await inspectStoragePlan({ root, config: { ...config, version: targetVersion }, existingConfig: config, resources, execute, operation: "upgrade", platform }); if (await doctor({ root, platform, stdout, stderr, execute, stdin, inspectResources, resources, - profile: config.profile, dockerReady: true, operation: "upgrade", storagePlan }) !== 0) return 1; + profile: config.profile, dockerReady: true, operation: "upgrade", storagePlan, engine: config.engine }) !== 0) return 1; return performUpdate({ root, config, @@ -2360,9 +2552,9 @@ async function updateCommand(args, { root, config, version, platform, stdin, std }); } -async function verifyStorage({ root, config, existingConfig, operation, inspectResources, execute, stdout, stderr }) { +async function verifyStorage({ root, config, existingConfig, operation, inspectResources, execute, stdout, stderr, platform = process.platform }) { const resources = await inspectResources(root); - const plan = await inspectStoragePlan({ root, config, existingConfig, operation, resources, execute }); + const plan = await inspectStoragePlan({ root, config, existingConfig, operation, resources, execute, platform }); const checks = storageChecks(plan, resources); for (const check of checks) (check.ok ? stdout : stderr).write(`${check.ok ? "PASS" : "FAIL"} ${check.name}: ${check.detail}\n`); if (checks.some((check) => !check.ok)) { @@ -2384,13 +2576,15 @@ async function doctor({ profile, operation, storagePlan, - dockerReady = false + dockerReady = false, + engine }) { const configuration = profile ? { ok: true, detail: "Prepared installation configuration" } : await loadConfig(root).then(() => ({ ok: true, detail: root })).catch((error) => ({ ok: false, detail: `${error.message}. Run install for a new setup, or doctor --fix for an existing setup.` })); const detectedResources = resources ?? await inspectResources(root); + const existingConfig = await loadConfig(root).catch(() => null); + const resolvedEngine = engine ?? existingConfig?.engine ?? "docker"; let resolvedProfile = profile; if (!resolvedProfile) { - const existingConfig = await loadConfig(root).catch(() => null); if (existingConfig?.profile) { resolvedProfile = existingConfig.profile; } else if (detectedResources?.totalMemoryBytes) { @@ -2409,6 +2603,79 @@ async function doctor({ ...resourceChecks.filter((check) => !storagePlan || check.name !== "Free disk"), ...(storagePlan ? storageChecks(storagePlan, detectedResources) : []) ]; + + 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", "--format", "json"] } + ]) { + const code = dockerReady + ? 0 + : await execute(probe, { stdin, stdout: null, stderr: null }); + podmanResults.push({ ...probe, code }); + } + const [podmanCli, podmanCompose, podmanEngine] = podmanResults; + let podmanInfo = null; + if (podmanEngine.code === 0) { + try { + const raw = await captureProbe(execute, { command: "podman", args: ["info", "--format", "json"] }); + if (raw) podmanInfo = JSON.parse(raw); + } catch {} + } + const isRootless = podmanInfo?.host?.security?.rootless ?? true; + const cgroupVersion = podmanInfo?.host?.cgroupVersion || "v2"; + const controllers = podmanInfo?.host?.cgroupControllers; + const limitsDelegated = Array.isArray(controllers) + ? (controllers.includes("cpu") && controllers.includes("memory")) + : true; + + checks.push({ + name: podmanCli.name, + ok: podmanCli.code === 0, + detail: podmanCli.code === 0 + ? (podmanInfo?.version?.Version ? `version ${podmanInfo.version.Version}` : "available") + : podmanCli.code === 127 + ? "not found on PATH" + : `unavailable (exit ${podmanCli.code})` + }); + checks.push({ + name: podmanCompose.name, + ok: podmanCompose.code === 0, + detail: podmanCompose.code === 0 + ? "available" + : podmanCli.code !== 0 + ? "not available because Podman CLI is missing" + : `plugin unavailable (exit ${podmanCompose.code})` + }); + checks.push({ + name: podmanEngine.name, + ok: podmanEngine.code === 0, + detail: podmanEngine.code === 0 + ? `available (${isRootless ? "rootless" : "rootful"}, cgroup ${cgroupVersion})` + : podmanCli.code === 0 + ? "installed but not running or inaccessible" + : "not reachable because Podman CLI is missing" + }); + checks.push({ + name: "Resource delegation", + ok: limitsDelegated, + detail: limitsDelegated + ? "CPU and memory limits delegated" + : "CPU and memory limits are not delegated in cgroup v2; containers will run without resource limits" + }); + for (const check of checks) { + (check.ok ? stdout : stderr).write(`${check.ok ? "PASS" : "FAIL"} ${check.name}: ${check.detail}\n`); + } + if (podmanCli.code !== 0 || podmanCompose.code !== 0) { + stderr.write(`${podmanInstallHelp(platform)}\n`); + } else if (podmanEngine.code !== 0) { + stderr.write(`${podmanEngineHelp(platform)}\n`); + } + return checks.every((check) => check.ok) ? 0 : 1; + } + const dockerResults = []; for (const probe of [ { name: "Docker CLI", command: "docker", args: ["--version"] }, @@ -2459,6 +2726,13 @@ async function doctor({ return checks.every((check) => check.ok) ? 0 : 1; } +function podmanEngineHelp(platform) { + if (platform === "darwin") { + return `Run "podman machine start", then run "${UNIVERSAL_COMMAND} doctor" again.`; + } + return `Start the Podman system service or verify rootless user socket, then run "${UNIVERSAL_COMMAND} doctor" again.`; +} + async function waitForApplicationReady({ url, request, @@ -2562,6 +2836,10 @@ async function executeWithDockerDiagnostics(execute, spec, io, { platform, stder stderr.write( `SpaceApp could not find the Docker CLI. ${dockerInstallHelp(platform)}\n` ); + } else if (spec.command === "podman" && code === 127) { + stderr.write( + `SpaceApp could not find the Podman CLI. ${podmanInstallHelp(platform)}\n` + ); } return code; } @@ -2633,6 +2911,7 @@ function spawnTrustedCommand(spec, options) { case "docker": return spawn("docker", args, options); case "hdiutil": return spawn("hdiutil", args, options); case "open": return spawn("open", args, options); + case "podman": return spawn("podman", args, options); case "powershell.exe": return spawn("powershell.exe", windowsPowerShellArgs(spec.operation), options); case "sg": return spawn("sg", args, options); @@ -2736,12 +3015,12 @@ Usage: ${UNIVERSAL_COMMAND} spaceapp Optional global launcher init Create a local SpaceApp installation - install [--profile auto|small|medium|large|light|standard] [--access isolated|host-root] [--with-companions] [--no-open] + install [--profile auto|small|medium|large|light|standard] [--access isolated|host-root] [--engine docker|podman] [--with-companions] [--no-open] Install prerequisites, initialize, and start - reinstall [--keep-data] [--profile auto|small|medium|large|light|standard] [--access isolated|host-root] [--with-companions] [--no-open] + reinstall [--keep-data] [--profile auto|small|medium|large|light|standard] [--access isolated|host-root] [--engine docker|podman] [--with-companions] [--no-open] Clean reinstallation preserving data and credentials repair [--dry-run] Verify and repair configuration and runtime files - up | down | status | logs Manage the Docker application + up | down | status | logs Manage the container application open Open the local web application doctor [--fix] Diagnose; --fix repairs existing config and runtime support-bundle Generate a diagnostic bundle JSON for support diff --git a/packages/run-spaceapp/src/index.mjs b/packages/run-spaceapp/src/index.mjs index fad1eb208..865e52dd8 100644 --- a/packages/run-spaceapp/src/index.mjs +++ b/packages/run-spaceapp/src/index.mjs @@ -124,6 +124,7 @@ const CONFIG_KEYS = new Set([ "port", "telemetry", "profile", + "engine", "accessMode", "workspaces", "companionsEnabled", @@ -304,17 +305,26 @@ export function installResourceChecks(resources, profile = "small", { requiredDi ]; } +export const SUPPORTED_ENGINES = Object.freeze(["docker", "podman"]); + export function createDefaultConfig({ version, profile = "light", accessMode = "isolated", - companionsEnabled = false + companionsEnabled = false, + engine }) { assertVersion(version); if (!INSTALL_PROFILES.includes(profile)) { throw new Error("Default config requires a resolved installation profile."); } const resolvedAccessMode = resolveInstallAccessMode(accessMode); + if (engine !== undefined && !SUPPORTED_ENGINES.includes(engine)) { + throw new Error("engine must be docker or podman."); + } + if (engine === "podman" && resolvedAccessMode === "host-root") { + throw new Error("host-root access is not supported with the podman engine."); + } return { schemaVersion: CONFIG_SCHEMA_VERSION, version, @@ -326,6 +336,7 @@ export function createDefaultConfig({ accessMode: resolvedAccessMode, companionsEnabled, optionalModules: "none", + ...(engine !== undefined ? { engine } : {}), workspaces: [] }; } @@ -361,9 +372,15 @@ export function validateConfig(config) { if (!INSTALL_PROFILES.includes(config.profile)) { throw new Error("profile must be small, medium, large, light, or standard."); } + if (config.engine !== undefined && !SUPPORTED_ENGINES.includes(config.engine)) { + throw new Error("engine must be docker or podman."); + } if (config.accessMode !== "isolated" && config.accessMode !== "host-root") { throw new Error("accessMode must be isolated or host-root."); } + if (config.engine === "podman" && config.accessMode === "host-root") { + throw new Error("host-root access is not supported with the podman engine."); + } if (typeof config.companionsEnabled !== "boolean") { throw new Error("companionsEnabled must be boolean."); } @@ -673,11 +690,18 @@ const CLI_SANDBOX_OPTIONS = [" security_opt:", " - seccomp=./seccomp-cli export function renderWorkspaceCompose(config, { cliSandbox = false } = {}) { validateConfig(config); + const isPodman = config.engine === "podman"; const mounts = config.workspaces.flatMap((workspace) => [ " - type: bind", ` source: ${JSON.stringify(workspace.hostPath)}`, ` target: ${JSON.stringify(workspace.containerPath)}`, - ` read_only: ${workspace.readOnly}` + ` read_only: ${workspace.readOnly}`, + ...(isPodman + ? [ + " bind:", + ' selinux: "z"' + ] + : []) ]); const service = mounts.length > 0 ? [" volumes:", ...mounts] @@ -752,6 +776,20 @@ export function renderCliHosts(config) { [user.id, `/run/spaceapp-cli-users/${userSlug(user.id)}/host.sock`])), null, 2)}\n`; } +export function renderPodmanCompose(config = { engine: "podman" }) { + if (config && config.engine !== "podman") return ""; + return [ + "services:", + " spaceapp-core:", + ' userns_mode: "keep-id:uid=10001,gid=10001"', + " spaceapp-cli:", + ' userns_mode: "keep-id:uid=10001,gid=10001"', + " spaceapp-browser:", + ' userns_mode: "keep-id:uid=10001,gid=10001"', + "" + ].join("\n"); +} + export function renderHostAccessCompose(config) { validateConfig(config); if (config.accessMode === "isolated") { @@ -787,6 +825,7 @@ export function composeCommand(action, root, options = {}) { validateHome(stateRoot); const profile = options.profile ?? "standard"; const settings = profileRuntimeSettings(profile); + const engine = options.engine ?? (options.config?.engine || "docker"); const base = [ "compose", "--project-name", composeProjectName(root), @@ -795,10 +834,12 @@ export function composeCommand(action, root, options = {}) { "-f", join(stateRoot, "compose.yml"), "-f", join(stateRoot, "compose.workspaces.yml"), "-f", join(stateRoot, "compose.host-access.yml"), + ...(engine === "podman" ? ["-f", join(stateRoot, "compose.podman.yml")] : []), ...(settings.browserEnabled ? ["--profile", "standard"] : []), ...(settings.temporalEnabled ? ["--profile", "workflows"] : []), ...(options.companionsEnabled ? ["--profile", "companions"] : []) ]; + const backupRestoreUser = engine === "podman" ? "10001:10001" : "0:0"; const actions = { up: ["up", "-d", "--remove-orphans"], down: ["down"], @@ -834,7 +875,7 @@ export function composeCommand(action, root, options = {}) { ], // The backup runs in a one-off container while the writers are paused (backupWithWritersPaused in cli.mjs). backup: [ - "run", "--rm", "--no-deps", "--user", "0:0", + "run", "--rm", "--no-deps", "--user", backupRestoreUser, "--env", "SPACE_DATABASE_URL_FILE=/run/secrets/database-url", "--entrypoint", "node", "spaceapp-core", "scripts/portable-backup.mjs" ], @@ -887,7 +928,7 @@ export function composeCommand(action, root, options = {}) { "--rm", "--no-deps", "--user", - "0:0", + backupRestoreUser, "--env", "SPACE_DATABASE_URL_FILE=/run/secrets/database-url", "--entrypoint", @@ -905,7 +946,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) { @@ -950,11 +991,19 @@ export async function writeRuntimeFiles(root, config) { await atomicWrite(join(root, "cli-hosts.json"), renderCliHosts(config), 0o644); for (const user of config.users ?? []) await mkdir(providerSecretsRoot(root, user.id), { recursive: true, mode: 0o700 }); await atomicWrite(join(root, "compose.host-access.yml"), renderHostAccessCompose(config)); + if (config.engine === "podman") { + await atomicWrite(join(root, "compose.podman.yml"), renderPodmanCompose(config)); + } else { + try { + await rm(join(root, "compose.podman.yml"), { force: true }); + } catch {} + } } export async function prepareInstallation(root, { version, profile, + engine, accessMode, companionsEnabled }) { @@ -971,6 +1020,12 @@ export async function prepareInstallation(root, { let config; try { config = await loadConfig(root); + const existingEngine = config.engine || "docker"; + if (engine !== undefined && engine !== existingEngine) { + throw new Error( + `Switching container engine from "${existingEngine}" to "${engine}" is not supported on an existing installation. Back up your data with "${UNIVERSAL_COMMAND} backup", reinstall with --engine ${engine}, and restore with "${UNIVERSAL_COMMAND} restore".` + ); + } } catch (error) { if (error?.code !== "ENOENT") { throw error; @@ -978,6 +1033,7 @@ export async function prepareInstallation(root, { config = createDefaultConfig({ version, profile: resolvedProfile ?? "light", + engine: engine ?? "docker", accessMode: resolveInstallAccessMode(accessMode), companionsEnabled }); @@ -992,6 +1048,9 @@ export async function prepareInstallation(root, { if (resolvedProfile !== undefined) { config = { ...config, profile: resolvedProfile }; } + if (engine !== undefined) { + config = { ...config, engine }; + } const resolvedAccessMode = resolveInstallAccessMode(accessMode, config.accessMode); if (config.accessMode !== resolvedAccessMode) { config = { ...config, accessMode: resolvedAccessMode }; diff --git a/packages/run-spaceapp/src/prerequisites.mjs b/packages/run-spaceapp/src/prerequisites.mjs index 42bb242da..1ac934835 100644 --- a/packages/run-spaceapp/src/prerequisites.mjs +++ b/packages/run-spaceapp/src/prerequisites.mjs @@ -1184,3 +1184,192 @@ function launchDetachedCommand(spec) { function wait(milliseconds) { return new Promise((resolve) => setTimeout(resolve, milliseconds)); } + +export async function podmanIsReady(execute, { platform = process.platform } = {}) { + for (const spec of [ + { command: "podman", args: ["--version"] }, + { command: "podman", args: ["compose", "version"] }, + { command: "podman", args: ["info"] } + ]) { + if (await execute(spec, { stdout: null, stderr: null }) !== 0) { + return false; + } + } + return true; +} + +export async function detectAvailableEngines(execute) { + const [docker, podman] = await Promise.all([ + dockerIsReady(execute), + podmanIsReady(execute) + ]); + return { docker, podman }; +} + +export function podmanInstallHelp(platform) { + if (platform === "darwin") { + return [ + "Podman (v5 or newer) and a Compose provider (docker-compose v2 or podman-compose) are required.", + "Install Podman on macOS using Homebrew:", + " brew install podman docker-compose", + "Initialize and start the Podman machine:", + " podman machine init --cpus 4 --memory 8192 --disk-size 60", + " podman machine start" + ].join("\n"); + } + if (platform === "linux") { + return [ + "Podman (v5 or newer) and a Compose provider (docker-compose v2 or podman-compose) are required.", + "Install Podman and Compose using your distribution's package manager:", + " Fedora/RHEL/CentOS: sudo dnf install -y podman docker-compose", + " Ubuntu/Debian: sudo apt-get update && sudo apt-get install -y podman docker-compose-v2", + " Arch Linux: sudo pacman -S --noconfirm podman docker-compose" + ].join("\n"); + } + return "Podman on Windows is not supported. Please use Docker Desktop."; +} + +export async function checkPodmanMachineResources(execute, { profile = "small" } = {}) { + const requirements = { + small: { cpuCount: 4, memoryBytes: 7 * 1024 ** 3, freshDiskBytes: 6.5 * 1024 ** 3 }, + medium: { cpuCount: 4, memoryBytes: 11 * 1024 ** 3, freshDiskBytes: 8 * 1024 ** 3 }, + large: { cpuCount: 8, memoryBytes: 15 * 1024 ** 3, freshDiskBytes: 11 * 1024 ** 3 }, + light: { cpuCount: 4, memoryBytes: 7 * 1024 ** 3, freshDiskBytes: 8 * 1024 ** 3 }, + standard: { cpuCount: 4, memoryBytes: 7 * 1024 ** 3, freshDiskBytes: 11 * 1024 ** 3 } + }[profile] || { cpuCount: 4, memoryBytes: 7 * 1024 ** 3, freshDiskBytes: 6.5 * 1024 ** 3 }; + + let output = ""; + const stdout = new (await import("node:stream")).Writable({ + write(chunk, _encoding, done) { + output += chunk.toString(); + done(); + } + }); + + try { + const code = await execute( + { command: "podman", args: ["machine", "inspect"] }, + { stdout, stderr: null } + ); + if (code !== 0) return { ok: true }; + const machines = JSON.parse(output); + const machine = Array.isArray(machines) ? machines.find((m) => m.Running || m.State === "running") || machines[0] : machines; + if (!machine) return { ok: true }; + + const cpus = machine.Resources?.CPUs ?? machine.CPUs ?? 4; + let memoryMB = machine.Resources?.Memory ?? (typeof machine.Memory === "number" ? machine.Memory : 4096); + if (memoryMB > 0 && memoryMB < 128) { + memoryMB = memoryMB * 1024; + } + const memoryBytes = memoryMB * 1024 * 1024; + let diskBytes = machine.Resources?.DiskSize ?? (typeof machine.DiskSize === "number" ? machine.DiskSize : 60 * 1024 ** 3); + if (diskBytes > 0 && diskBytes < 10000) { + diskBytes = diskBytes * 1024 ** 3; + } + + const cpuOk = cpus >= requirements.cpuCount; + const memOk = memoryBytes >= requirements.memoryBytes; + const diskOk = diskBytes >= requirements.freshDiskBytes; + const ok = cpuOk && memOk && diskOk; + + let resizeHelp = ""; + if (!ok) { + const targetCpus = Math.max(cpus, requirements.cpuCount); + const targetMemMB = Math.max(memoryMB, Math.ceil(requirements.memoryBytes / (1024 * 1024))); + const targetDiskGB = Math.max(Math.ceil(diskBytes / (1024 ** 3)), Math.ceil(requirements.freshDiskBytes / (1024 ** 3))); + resizeHelp = `\nTo resize your Podman machine for profile "${profile}":\n podman machine stop\n podman machine set --cpus ${targetCpus} --memory ${targetMemMB} --disk-size ${targetDiskGB}\n podman machine start`; + } + + return { + ok, + cpus, + memoryBytes, + diskBytes, + detail: ok ? "Podman machine resources sufficient" : `Insufficient Podman machine resources (${cpus} CPUs, ${Math.round(memoryBytes / (1024 ** 3))} GiB RAM, ${Math.round(diskBytes / (1024 ** 3))} GiB Disk).${resizeHelp}` + }; + } catch { + return { ok: true }; + } +} + +export async function ensurePodmanAvailable({ + platform = process.platform, + arch = process.arch, + env = process.env, + stdin = process.stdin, + stdout = process.stdout, + stderr = process.stderr, + execute, + profile = "small" +}) { + if (platform === "win32") { + stderr.write("Podman on Windows is not supported. Please use Docker Desktop.\n"); + return { code: 1, reexecuted: false }; + } + const ready = await podmanIsReady(execute, { platform }); + if (!ready) { + stderr.write(`${podmanInstallHelp(platform)}\n`); + return { code: 1, reexecuted: false }; + } + + if (platform === "darwin") { + const resourceCheck = await checkPodmanMachineResources(execute, { profile }); + if (!resourceCheck.ok) { + stderr.write(`${resourceCheck.detail}\n`); + return { code: 1, reexecuted: false }; + } + } + + return { code: 0, reexecuted: false }; +} + +export async function ensureEngineAvailable({ + engine = "docker", + platform = process.platform, + arch = process.arch, + env = process.env, + stdin = process.stdin, + stdout = process.stdout, + stderr = process.stderr, + execute, + launch, + download, + pathExists, + sleep, + osRelease, + installArgs, + scheduleResume, + confirmRestart, + profile = "small" +}) { + if (engine === "podman") { + return ensurePodmanAvailable({ + platform, + arch, + env, + stdin, + stdout, + stderr, + execute, + profile + }); + } + return ensureDockerAvailable({ + platform, + arch, + env, + stdin, + stdout, + stderr, + execute, + launch, + download, + pathExists, + sleep, + osRelease, + installArgs, + scheduleResume, + confirmRestart + }); +} + diff --git a/packages/run-spaceapp/src/storage.mjs b/packages/run-spaceapp/src/storage.mjs index c8b4cfadc..5ff5e948a 100644 --- a/packages/run-spaceapp/src/storage.mjs +++ b/packages/run-spaceapp/src/storage.mjs @@ -32,7 +32,7 @@ async function configurationBytes(root) { } } await walk(join(root, "secrets")); - for (const file of ["config.json", "runtime.env", "compose.yml", "compose.workspaces.yml", "compose.host-access.yml"]) { + for (const file of ["config.json", "runtime.env", "compose.yml", "compose.workspaces.yml", "compose.host-access.yml", "compose.podman.yml"]) { bytes += (await stat(join(root, file)).catch(() => ({ size: 0 }))).size; } return bytes; @@ -95,8 +95,9 @@ async function installedImageDigests(root, version) { return digests; } -export async function inspectStoragePlan({ root, config, existingConfig, resources, execute, operation, fetch = globalThis.fetch }) { - const dockerInfo = await captureProbe(execute, { command: "docker", args: ["info", "--format", "{{json .}}"] }); +export async function inspectStoragePlan({ root, config, existingConfig, resources, execute, operation, platform = process.platform, fetch = globalThis.fetch }) { + const engineName = config.engine || "docker"; + const dockerInfo = 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. */ } const cachedLayers = new Set(); @@ -104,7 +105,7 @@ export async function inspectStoragePlan({ root, config, existingConfig, resourc const existingDigests = await installedImageDigests(root, existingConfig.version); for (const type of ["core", "cli", "browser"]) { for (const ref of localImageRefs(type, existingConfig.version, existingDigests[type])) { - const output = await captureProbe(execute, { command: "docker", args: ["image", "inspect", "--format", "{{json .RootFS.Layers}}", ref] }); + const output = await captureProbe(execute, { command: engineName, args: ["image", "inspect", "--format", "{{json .RootFS.Layers}}", ref] }); try { for (const layer of JSON.parse(output)) cachedLayers.add(layer); break; } catch {} } } @@ -126,15 +127,15 @@ export async function inspectStoragePlan({ root, config, existingConfig, resourc const refs = ["core", "cli", "browser"].includes(type) ? localImageRefs(type, config.version) : [image]; let cached = null; for (const ref of refs) { - cached = await captureProbe(execute, { command: "docker", args: ["image", "inspect", "--format", "{{.Id}}", ref] }); - if (/^sha256:[a-f0-9]{64}$/.test(cached ?? "")) break; + cached = await captureProbe(execute, { command: engineName, args: ["image", "inspect", "--format", "{{.Id}}", ref] }); + if (/^(sha256:)?[a-f0-9]{64}$/.test(cached ?? "")) break; } - if (!/^sha256:[a-f0-9]{64}$/.test(cached ?? "")) { + if (!/^(sha256:)?[a-f0-9]{64}$/.test(cached ?? "")) { missingImages.push(image); let reserve = imageReserve[type] ?? GiB; if (engine && existingConfig && cachedLayers.size && typeof fetch === "function") { try { - const metadata = await registryLayers(image, /arm|aarch64/.test(engine.Architecture ?? "") ? "arm64" : "amd64", fetch); + const metadata = await registryLayers(image, /arm|aarch64/.test(engine.Architecture ?? engine.host?.arch ?? "") ? "arm64" : "amd64", fetch); if (metadata) { reserve = estimateMissingLayers(metadata.layers, metadata.diffIds, cachedLayers); sharedLayersEstimated = true; } } catch { /* Offline/private registry: retain the full conservative reserve. */ } } @@ -154,16 +155,33 @@ export async function inspectStoragePlan({ root, config, existingConfig, resourc const free = await captureProbe(execute, composeCommand("storageFree", root, existingConfig)); if (/^\d+$/.test(free ?? "")) dockerFreeDiskBytes = Number(free); } - // Linux can measure the Docker filesystem directly even on a fresh install. - if (engine?.DockerRootDir && process.platform === "linux") { + // Linux can measure the engine storage filesystem directly even on a fresh install. + const storageRootDir = engine?.DockerRootDir || engine?.store?.graphRoot; + if (storageRootDir && platform === "linux") { const { inspectSystemResources } = await import("./index.mjs"); - try { dockerFreeDiskBytes = (await inspectSystemResources(engine.DockerRootDir)).freeDiskBytes; } catch {} + try { dockerFreeDiskBytes = (await inspectSystemResources(storageRootDir)).freeDiskBytes; } catch {} } - return { ...plan, missingImages, sharedLayersEstimated, dockerFreeDiskBytes, + // On macOS with Podman, probe available space inside the selected machine. + if (engineName === "podman" && platform === "darwin" && dockerFreeDiskBytes === null) { + try { + const dfOutput = await captureProbe(execute, { command: "podman", args: ["machine", "ssh", "df -k /var/lib/containers/storage 2>/dev/null || df -k /"] }); + if (dfOutput) { + const lines = dfOutput.trim().split(/\r?\n/); + if (lines.length > 1) { + const parts = lines[1].trim().split(/\s+/); + const availableKb = Number(parts[3]); + if (!Number.isNaN(availableKb) && availableKb > 0) { + dockerFreeDiskBytes = availableKb * 1024; + } + } + } + } catch {} + } + return { ...plan, engineName, platform, missingImages, sharedLayersEstimated, dockerFreeDiskBytes, resources: { ...resources, freeDiskBytes: dockerFreeDiskBytes === null ? resources.freeDiskBytes : Math.min(resources.freeDiskBytes, dockerFreeDiskBytes), - engineCpuCount: engine?.NCPU, - engineMemoryBytes: engine?.MemTotal + engineCpuCount: engine?.NCPU ?? engine?.host?.cpus, + engineMemoryBytes: engine?.MemTotal ?? engine?.host?.memTotal } }; } diff --git a/packages/run-spaceapp/tests/cli.test.mjs b/packages/run-spaceapp/tests/cli.test.mjs index b6e435cb6..c5b5b2e1e 100644 --- a/packages/run-spaceapp/tests/cli.test.mjs +++ b/packages/run-spaceapp/tests/cli.test.mjs @@ -127,7 +127,7 @@ function ttyStdin(...answers) { return stream; } -const FRESH_APPROVALS = ["", "", "", "", "", "y"]; +const FRESH_APPROVALS = ["", "", "", "", "", "", "y"]; const FRESH_APPROVALS_WINDOWS = ["", "", "", "", "", "y", ""]; const eightGigabyteClassLinuxGuest = Object.freeze({ @@ -1464,7 +1464,7 @@ test("macOS authorization failure stops the install before SpaceApp image pulls" arch: "x64", stdout: stdout.stream, stderr: stderr.stream, - stdin: ttyStdin("", "", "", "", "", "y", "yes"), + stdin: ttyStdin(...FRESH_APPROVALS), inspectResources: async () => eightGigabyteClassLinuxGuest, prepareDockerPath: async () => null, ensureDocker: async (options) => ensureDockerAvailable({ diff --git a/packages/run-spaceapp/tests/podman.test.mjs b/packages/run-spaceapp/tests/podman.test.mjs new file mode 100644 index 000000000..26da404fa --- /dev/null +++ b/packages/run-spaceapp/tests/podman.test.mjs @@ -0,0 +1,545 @@ +import assert from "node:assert/strict"; +import { mkdir, mkdtemp, readFile, stat, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { Writable } from "node:stream"; +import test from "node:test"; +import { run } from "../src/cli.mjs"; +import { + composeCommand, + createDefaultConfig, + renderPodmanCompose, + renderWorkspaceCompose, + validateConfig +} from "../src/index.mjs"; +import { + checkPodmanMachineResources, + detectAvailableEngines, + podmanIsReady +} from "../src/prerequisites.mjs"; +import { inspectStoragePlan } from "../src/storage.mjs"; + +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; + return { + isTTY: true, + setRawMode() {}, + resume() {}, + pause() {}, + setEncoding() {}, + [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 }; + } + }; + } + }; +} + +const FRESH_APPROVALS = ["", "", "", "", "", "", "y"]; + +test("validateConfig and createDefaultConfig enforce engine validity and disallow podman with host-root", () => { + const dockerConfig = createDefaultConfig({ version: "1.0.0", engine: "docker" }); + assert.equal(dockerConfig.engine, "docker"); + assert.doesNotThrow(() => validateConfig(dockerConfig)); + + const podmanConfig = createDefaultConfig({ version: "1.0.0", engine: "podman" }); + assert.equal(podmanConfig.engine, "podman"); + assert.doesNotThrow(() => validateConfig(podmanConfig)); + + assert.throws( + () => createDefaultConfig({ version: "1.0.0", engine: "nerdctl" }), + /engine must be docker or podman/ + ); + + assert.throws( + () => createDefaultConfig({ version: "1.0.0", engine: "podman", accessMode: "host-root" }), + /host-root access is not supported with the podman engine/i + ); + + const invalidAccessPodman = { ...podmanConfig, accessMode: "host-root" }; + assert.throws( + () => validateConfig(invalidAccessPodman), + /host-root access is not supported with the podman engine/i + ); +}); + +test("renderPodmanCompose generates keep-id userns_mode for unprivileged rootless services", () => { + const composeYaml = renderPodmanCompose(); + assert.match(composeYaml, /userns_mode:\s*"keep-id:uid=10001,gid=10001"/); + assert.match(composeYaml, /spaceapp-core:/); + assert.match(composeYaml, /spaceapp-cli:/); + assert.match(composeYaml, /spaceapp-browser:/); +}); + +test("renderWorkspaceCompose injects SELinux relabeling (:z) when engine is podman", () => { + const dockerWorkspaces = renderWorkspaceCompose({ + ...createDefaultConfig({ version: "1.0.0", engine: "docker" }), + workspaces: [{ + id: "project-12345678", + name: "project", + hostPath: "/home/user/project", + containerPath: "/workspaces/project", + readOnly: false + }] + }); + assert.doesNotMatch(dockerWorkspaces, /selinux:\s*"z"/); + + const podmanWorkspaces = renderWorkspaceCompose({ + ...createDefaultConfig({ version: "1.0.0", engine: "podman" }), + workspaces: [{ + id: "project-12345678", + name: "project", + hostPath: "/home/user/project", + containerPath: "/workspaces/project", + readOnly: false + }] + }); + assert.match(podmanWorkspaces, /bind:\s*\n\s+selinux:\s*"z"/); +}); + +test("composeCommand generates podman compose invocations with compose.podman.yml", () => { + const root = join(tmpdir(), "spaceapp-test-podman-compose"); + const command = composeCommand("up", root, { engine: "podman", profile: "small" }); + assert.equal(command.command, "podman"); + assert.equal(command.args[0], "compose"); + assert.ok(command.args.includes(join(root, "compose.podman.yml"))); + assert.ok(command.args.includes("up")); +}); + +test("checkPodmanMachineResources verifies macOS podman machine resources and formats resize guidance", async () => { + const sufficientExecute = async (spec, io) => { + if (spec.command === "podman" && spec.args?.[0] === "machine" && spec.args?.[1] === "inspect") { + io.stdout.write(JSON.stringify([{ + Running: true, + Resources: { CPUs: 8, Memory: 16384, DiskSize: 100 } + }])); + return 0; + } + return 1; + }; + const sufficient = await checkPodmanMachineResources(sufficientExecute, { profile: "large" }); + assert.equal(sufficient.ok, true); + + const deficientExecute = async (spec, io) => { + if (spec.command === "podman" && spec.args?.[0] === "machine" && spec.args?.[1] === "inspect") { + io.stdout.write(JSON.stringify([{ + Running: true, + Resources: { CPUs: 2, Memory: 2048, DiskSize: 20 } + }])); + return 0; + } + return 1; + }; + const deficient = await checkPodmanMachineResources(deficientExecute, { profile: "medium" }); + assert.equal(deficient.ok, false); + assert.match(deficient.detail, /podman machine set --cpus 4 --memory 11264/); +}); + +test("install --engine podman succeeds, configures podman engine, and creates runtime files", async () => { + const root = await mkdtemp(join(tmpdir(), "spaceapp-install-podman-")); + const stdout = capture(); + const stderr = capture(); + const calls = []; + + const options = { + env: { SPACEAPP_HOME: root }, + platform: "linux", + stdout: stdout.stream, + stderr: stderr.stream, + stdin: ttyStdin("", "", "", "", "", "y"), + inspectResources: async () => ({ + cpuCount: 8, + totalMemoryBytes: 16 * 1024 ** 3, + freeDiskBytes: 100 * 1024 ** 3 + }), + request: async (url) => { + if (url.endsWith("/readyz")) return { ok: true, json: async () => ({ ok: true }) }; + if (url.endsWith("/api/setup/status")) return { ok: true, json: async () => ({ setupRequired: false, expiresAt: null }) }; + return { ok: true }; + }, + sleep: async () => {}, + ensurePodman: async () => ({ code: 0, reexecuted: false }), + execute: async (spec) => { + calls.push(spec); + return 0; + } + }; + + const code = await run(["install", "--engine", "podman", "--profile", "small", "--no-open"], options); + assert.equal(code, 0); + + const config = JSON.parse(await readFile(join(root, "config.json"), "utf8")); + assert.equal(config.engine, "podman"); + assert.equal(config.profile, "small"); + + const composePodmanContent = await readFile(join(root, "compose.podman.yml"), "utf8"); + assert.match(composePodmanContent, /userns_mode:\s*"keep-id:uid=10001,gid=10001"/); + + const pullCall = calls.find((c) => c.command === "podman" && c.args?.includes("pull")); + assert.ok(pullCall, "podman compose pull should have been executed"); + assert.ok(pullCall.args.some((arg) => arg.endsWith("compose.podman.yml"))); + + const upCall = calls.find((c) => c.command === "podman" && c.args?.includes("up")); + assert.ok(upCall, "podman compose up should have been executed"); + assert.ok(upCall.args.some((arg) => arg.endsWith("compose.podman.yml"))); +}); + +test("install --engine podman refuses --access host-root with a clear error message", async () => { + const root = await mkdtemp(join(tmpdir(), "spaceapp-hostroot-podman-")); + const stdout = capture(); + const stderr = capture(); + + const options = { + env: { SPACEAPP_HOME: root }, + platform: "linux", + stdout: stdout.stream, + stderr: stderr.stream, + stdin: ttyStdin("", "", "", "", "", "y"), + inspectResources: async () => ({ + cpuCount: 8, + totalMemoryBytes: 16 * 1024 ** 3, + freeDiskBytes: 100 * 1024 ** 3 + }), + execute: async () => 0 + }; + + await assert.rejects( + async () => { + await run(["install", "--engine", "podman", "--access", "host-root", "--no-open"], options); + }, + /host-root access is not supported with the podman engine/i + ); +}); + +test("switching engines on existing installation is refused with a migration guidance error", async () => { + const root = await mkdtemp(join(tmpdir(), "spaceapp-switch-engine-")); + const stdout = capture(); + const stderr = capture(); + + await writeFile( + join(root, "config.json"), + JSON.stringify(createDefaultConfig({ version: "1.0.0", engine: "docker" })), + "utf8" + ); + + const options = { + env: { SPACEAPP_HOME: root }, + platform: "linux", + stdout: stdout.stream, + stderr: stderr.stream, + stdin: ttyStdin("y"), + inspectResources: async () => ({ + cpuCount: 8, + totalMemoryBytes: 16 * 1024 ** 3, + freeDiskBytes: 100 * 1024 ** 3 + }), + execute: async () => 0 + }; + + await assert.rejects( + async () => { + await run(["install", "--engine", "podman", "--no-open"], options); + }, + /Switching container engine from "docker" to "podman" is not supported on an existing installation/i + ); +}); + +test("doctor and support-bundle report podman facts (rootless, cgroup v2, limits delegation)", async () => { + const root = await mkdtemp(join(tmpdir(), "spaceapp-doctor-podman-")); + const stdout = capture(); + const stderr = capture(); + + await writeFile( + join(root, "config.json"), + JSON.stringify(createDefaultConfig({ version: "1.0.0", engine: "podman" })), + "utf8" + ); + + const options = { + env: { SPACEAPP_HOME: root }, + platform: "linux", + stdout: stdout.stream, + stderr: stderr.stream, + stdin: ttyStdin(), + inspectResources: async () => ({ + cpuCount: 8, + totalMemoryBytes: 16 * 1024 ** 3, + freeDiskBytes: 100 * 1024 ** 3 + }), + execute: async (spec, io) => { + if (spec.command === "podman" && spec.args?.includes("info")) { + io?.stdout?.write(JSON.stringify({ + version: { Version: "5.0.3" }, + host: { + security: { rootless: true }, + cgroupVersion: "v2", + cgroupControllers: ["cpu", "memory", "pids"] + } + })); + return 0; + } + return 0; + } + }; + + const doctorCode = await run(["doctor"], options); + assert.equal(doctorCode, 0); + assert.match(stdout.value(), /PASS Podman CLI: version 5\.0\.3/); + assert.match(stdout.value(), /PASS Podman Compose: available/); + assert.match(stdout.value(), /PASS Podman Engine: available \(rootless, cgroup v2\)/); + assert.match(stdout.value(), /PASS Resource delegation: CPU and memory limits delegated/); + + const bundlePath = join(root, "bundle.json"); + const bundleCode = await run(["support-bundle", "--out", bundlePath], options); + assert.equal(bundleCode, 0); + const bundle = JSON.parse(await readFile(bundlePath, "utf8")); + assert.equal(bundle.engine?.name, "podman"); + assert.equal(bundle.engine?.rootless, true); + assert.equal(bundle.engine?.cgroupVersion, "v2"); + assert.equal(bundle.engine?.limitsDelegated, true); +}); + +test("factory-reset preserves configured engine", async () => { + const root = await mkdtemp(join(tmpdir(), "spaceapp-factory-reset-podman-")); + const stdout = capture(); + const stderr = capture(); + + await writeFile( + join(root, "config.json"), + JSON.stringify(createDefaultConfig({ version: "1.0.0", engine: "podman" })), + "utf8" + ); + await writeFile(join(root, "compose.podman.yml"), "services: {}\n", "utf8"); + + const options = { + env: { SPACEAPP_HOME: root }, + platform: "linux", + stdout: stdout.stream, + stderr: stderr.stream, + stdin: ttyStdin("FACTORY-RESET"), + execute: async () => 0 + }; + + const code = await run(["factory-reset"], options); + assert.equal(code, 0); + assert.match(stdout.value(), /SpaceApp factory reset complete/); + + const resetConfig = JSON.parse(await readFile(join(root, "config.json"), "utf8")); + assert.equal(resetConfig.engine, "podman"); +}); + +test("inspectStoragePlan measures Podman machine capacity on macOS", async () => { + const root = await mkdtemp(join(tmpdir(), "spaceapp-storage-podman-mac-")); + const config = createDefaultConfig({ version: "1.0.0", engine: "podman", profile: "small" }); + const execute = async (spec, io) => { + if (spec.command === "podman" && spec.args?.[0] === "machine" && spec.args?.[1] === "ssh") { + io.stdout.write("Filesystem 1K-blocks Used Available Use% Mounted on\n/dev/vda4 67108864 1048576 66060288 2% /var/lib/containers/storage\n"); + return 0; + } + if (spec.command === "podman" && spec.args?.[0] === "info") { + io.stdout.write(JSON.stringify({ + host: { cpus: 8, memTotal: 16 * 1024 ** 3 }, + store: { graphRoot: "/var/lib/containers/storage" } + })); + return 0; + } + return 0; + }; + + const plan = await inspectStoragePlan({ + root, + config, + existingConfig: null, + resources: { cpuCount: 8, totalMemoryBytes: 16 * 1024 ** 3, freeDiskBytes: 100 * 1024 ** 3 }, + execute, + operation: "fresh", + platform: "darwin" + }); + assert.equal(plan.dockerFreeDiskBytes, 66060288 * 1024); + assert.equal(plan.resources.engineCpuCount, 8); +}); + +test("doctor inspects podman engine when config.json specifies engine: podman", async () => { + const root = await mkdtemp(join(tmpdir(), "spaceapp-doctor-podman-")); + const stdout = capture(); + const stderr = capture(); + const calls = []; + + await writeFile( + join(root, "config.json"), + JSON.stringify(createDefaultConfig({ version: "1.0.0", engine: "podman", profile: "small" })), + "utf8" + ); + + const options = { + env: { SPACEAPP_HOME: root }, + platform: "linux", + stdout: stdout.stream, + stderr: stderr.stream, + inspectResources: async () => ({ + cpuCount: 8, + totalMemoryBytes: 16 * 1024 ** 3, + freeDiskBytes: 100 * 1024 ** 3 + }), + execute: async (spec) => { + calls.push(spec); + return 0; + } + }; + + const code = await run(["doctor"], options); + assert.equal(code, 0); + assert.match(stdout.value(), /podman/i); + assert.ok(calls.some((c) => c.command === "podman")); +}); + +test("reinstall --engine refuses switching engines on existing installation with migration guidance", async () => { + const root = await mkdtemp(join(tmpdir(), "spaceapp-reinstall-podman-")); + const stdout = capture(); + const stderr = capture(); + + await writeFile( + join(root, "config.json"), + JSON.stringify(createDefaultConfig({ version: "1.0.0", engine: "docker", profile: "small" })), + "utf8" + ); + + const options = { + env: { SPACEAPP_HOME: root }, + platform: "linux", + stdout: stdout.stream, + stderr: stderr.stream, + stdin: ttyStdin("y"), + execute: async () => 0 + }; + + await assert.rejects( + () => run(["reinstall", "--engine", "podman"], options), + /Switching container engine from "docker" to "podman" is not supported/ + ); + + const unmodified = JSON.parse(await readFile(join(root, "config.json"), "utf8")); + assert.equal(unmodified.engine, "docker"); +}); + +test("interactive install selects podman engine via answers", async () => { + const root = await mkdtemp(join(tmpdir(), "spaceapp-interactive-podman-")); + const stdout = capture(); + const stderr = capture(); + const calls = []; + + const options = { + env: { SPACEAPP_HOME: root }, + platform: "linux", + stdout: stdout.stream, + stderr: stderr.stream, + inspectResources: async () => ({ + cpuCount: 8, + totalMemoryBytes: 16 * 1024 ** 3, + freeDiskBytes: 100 * 1024 ** 3 + }), + request: async (url) => new Response(JSON.stringify(url.endsWith("/readyz") ? { ok: true } : { setupRequired: false, expiresAt: null }), { status: 200, headers: { "content-type": "application/json" } }), + sleep: async () => {}, + ensurePodman: async () => ({ code: 0, reexecuted: false }), + execute: async (spec) => { + calls.push(spec); + return 0; + } + }; + + const code = await run([ + "install", + "--non-interactive", + "--answers", + JSON.stringify({ + confirm: true, + profile: "small", + access: "isolated", + engine: "podman", + companions: false, + telemetry: false, + open: false + }) + ], options); + assert.equal(code, 0); + const config = JSON.parse(await readFile(join(root, "config.json"), "utf8")); + assert.equal(config.engine, "podman"); +}); + +test("composeCommand uses keep-id user 10001:10001 for backup and restore under podman and 0:0 under docker", () => { + const root = join(tmpdir(), "spaceapp-test-podman-backup-user"); + const backupId = "spaceapp-backup-20261017T120000000Z"; + + const podmanBackup = composeCommand("backup", root, { engine: "podman", profile: "small" }); + assert.equal(podmanBackup.command, "podman"); + assert.ok(podmanBackup.args.includes("10001:10001")); + + const podmanRestore = composeCommand("restore", root, { engine: "podman", profile: "small", backupId }); + assert.equal(podmanRestore.command, "podman"); + assert.ok(podmanRestore.args.includes("10001:10001")); + + const dockerBackup = composeCommand("backup", root, { engine: "docker", profile: "small" }); + assert.equal(dockerBackup.command, "docker"); + assert.ok(dockerBackup.args.includes("0:0")); +}); + +test("install offers Podman interactively when Docker is unavailable and Podman is ready", async () => { + const root = await mkdtemp(join(tmpdir(), "spaceapp-fallback-podman-")); + const stdout = capture(); + const stderr = capture(); + const calls = []; + + const options = { + env: { SPACEAPP_HOME: root }, + platform: "linux", + stdout: stdout.stream, + stderr: stderr.stream, + stdin: ttyStdin(...FRESH_APPROVALS, "y"), + inspectResources: async () => ({ + cpuCount: 8, + totalMemoryBytes: 16 * 1024 ** 3, + freeDiskBytes: 100 * 1024 ** 3 + }), + request: async (url) => ({ + ok: true, + json: async () => (url.endsWith("/readyz") ? { ok: true } : { setupRequired: false, expiresAt: null }) + }), + sleep: async () => {}, + ensureDocker: async () => ({ code: 1, reexecuted: false }), + ensurePodman: async () => ({ code: 0, reexecuted: false }), + execute: async (spec) => { + calls.push(spec); + return 0; + } + }; + + const code = await run(["install", "--profile", "small", "--no-open"], options); + assert.equal(code, 0); + assert.match(stdout.value(), /Docker was not detected, but Podman is available/); + const config = JSON.parse(await readFile(join(root, "config.json"), "utf8")); + assert.equal(config.engine, "podman"); +}); + + + diff --git a/packages/run-spaceapp/tests/wizard.test.mjs b/packages/run-spaceapp/tests/wizard.test.mjs index 7dbb5a89c..a31dc08e0 100644 --- a/packages/run-spaceapp/tests/wizard.test.mjs +++ b/packages/run-spaceapp/tests/wizard.test.mjs @@ -153,7 +153,7 @@ test("fresh install cancelled at final confirmation creates no installation stat platform: "linux", stdout: stdout.stream, stderr: stderr.stream, - stdin: ttyStdin("", "", "", "", "", "n"), + stdin: ttyStdin("", "", "", "", "", "", "n"), inspectResources: async () => eightGigabyteClassLinuxGuest, ensureDocker: async () => ({ code: 0, reexecuted: false }), prepareDockerPath: async () => null,