From 0b9e0490d95eb447cc96241c2f81ba08ed58bd44 Mon Sep 17 00:00:00 2001 From: DjonuSs Date: Mon, 19 Oct 2026 00:24:14 +0500 Subject: [PATCH 01/13] feat(launcher): add rootless Podman container engine support (#63) --- .github/workflows/platform-matrix.yml | 37 ++ README.md | 11 +- docs/getting-started.md | 12 + packages/run-spaceapp/README.md | 30 +- packages/run-spaceapp/src/cli.mjs | 573 ++++++++++++++++---- packages/run-spaceapp/src/index.mjs | 117 +++- packages/run-spaceapp/src/prerequisites.mjs | 191 ++++++- packages/run-spaceapp/src/storage.mjs | 45 +- packages/run-spaceapp/tests/cli.test.mjs | 1 + packages/run-spaceapp/tests/podman.test.mjs | 440 +++++++++++++++ 10 files changed, 1334 insertions(+), 123 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..20eefacf1 100644 --- a/.github/workflows/platform-matrix.yml +++ b/.github/workflows/platform-matrix.yml @@ -57,3 +57,40 @@ 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-lifecycle: + name: Podman Lifecycle / ubuntu-latest + runs-on: ubuntu-latest + timeout-minutes: 20 + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + + - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0 + with: + node-version: "22.23.0" + cache: npm + + - name: Install dependencies + run: npm ci + + - name: Set up Podman Compose provider + run: | + podman --version + sudo apt-get update -qq && sudo apt-get install -y -qq podman-compose || true + + - name: Link local run-spaceapp + run: npm link -w run-spaceapp + + - name: Install SpaceApp with Podman engine + run: spaceapp install --engine podman --profile small --non-interactive --answers '{"confirm": true}' + + - name: Verify readyz endpoint + run: curl --fail --retry 30 --retry-connrefused --retry-delay 2 http://127.0.0.1:4911/readyz + + - name: Test spaceapp backup + run: spaceapp backup + + - name: Test spaceapp uninstall with purge-data + run: echo "DELETE" | spaceapp uninstall --purge-data diff --git a/README.md b/README.md index 1e314e3da..41de9e027 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 Engine / Docker Desktop, or rootless Podman (>= 5 with Compose provider). If Docker is not installed and Podman is absent, the installer automatically installs official Docker Desktop (Windows/macOS) or Docker Engine (Linux). ### Run the Installation Command @@ -100,12 +100,15 @@ Run the universal command in your terminal: ```bash npx --yes run-spaceapp@latest install + +# Or explicitly choose the container engine: +npx --yes run-spaceapp@latest install --engine podman ``` > **Windows 11 Tip**: Use **Command Prompt** (`cmd.exe`) or the Command Prompt profile in Windows Terminal. If PowerShell has a restricted execution policy, run `npx.cmd --yes run-spaceapp@latest install`. ### What Happens Next (Step-by-Step Onboarding): -1. **System & Docker Verification**: The launcher verifies CPU, RAM, and disk space, and installs/starts Docker if needed. +1. **System & Container Engine Verification**: The launcher verifies CPU, RAM, and disk space, and verifies/installs Docker or Podman. 2. **Container Initialization**: Pulls SpaceApp's images by exact version tag (published with SBOM and provenance attestations that the release workflow verifies) and the third-party images pinned by digest, then starts the container stack. 3. **One-Time Setup Token**: The terminal prints a secure 15-minute setup token and automatically opens `http://127.0.0.1:4911`. 4. **Create Your Operator Account**: Paste the token into the setup screen, enter your email and password, and click Create Owner. @@ -128,9 +131,9 @@ You can run any command using `npx --yes run-spaceapp@latest ` or simpl | Command | Description | Example | | :--- | :--- | :--- | -| `install` | Full setup: installs Docker if needed, creates secrets, starts stack, and opens browser. | `npx --yes run-spaceapp@latest install --profile auto` | +| `install` | Full setup: verifies engine (Docker or Podman), creates secrets, starts stack, and opens browser. | `npx --yes run-spaceapp@latest install --engine podman` | | `reinstall` | Clean reinstallation of containers while preserving existing data, credentials, and workspaces. | `npx --yes run-spaceapp@latest reinstall --keep-data` | -| `up` | Starts the Docker application containers in the background. | `npx --yes run-spaceapp@latest up` | +| `up` | Starts application containers in the background. | `npx --yes run-spaceapp@latest up` | | `down` | Stops all SpaceApp containers cleanly without removing data. | `npx --yes run-spaceapp@latest down` | | `status` | Checks running container status, health checks, and port bindings. | `npx --yes run-spaceapp@latest status` | | `logs` | Shows the most recent container log lines (bounded tail) for troubleshooting. | `npx --yes run-spaceapp@latest logs` | diff --git a/docs/getting-started.md b/docs/getting-started.md index 68ac684cb..4582c5313 100644 --- a/docs/getting-started.md +++ b/docs/getting-started.md @@ -39,6 +39,18 @@ starts the service. Before adding the current user to the `docker` group, it explains that the group grants root-level privileges, adds the user automatically, and continues in the same command. +## Podman (Rootless) + +SpaceApp supports rootless Podman (version 5 or newer with a Compose provider such as `docker-compose-v2` or `podman-compose`) on Linux and macOS: + +```bash +npx --yes run-spaceapp@latest install --engine podman +``` + +- **Linux**: Containers run rootless under your user account without requiring root-equivalent `docker` group membership. Workspace bind mounts automatically apply SELinux `z` relabeling. `spaceapp doctor` inspects cgroup v2 controller delegation; if CPU/memory limits cannot be applied, enable user lingering (`loginctl enable-linger $USER`). Note that `--access host-root` is not supported on Podman. +- **macOS**: Podman Machine is supported. The launcher checks machine CPU, memory, and disk against the profile requirements and provides exact `podman machine set` instructions if resizing is needed. +- **Engine Migration**: Switching engines on an existing installation is refused to prevent data loss (Docker volumes are not visible to Podman). To migrate, run `spaceapp backup`, `spaceapp uninstall --purge-data`, `spaceapp install --engine podman`, and `spaceapp restore`. + ## macOS Install Node.js 20.11 or newer, then run in Terminal: diff --git a/packages/run-spaceapp/README.md b/packages/run-spaceapp/README.md index c02e63049..b32b5de11 100644 --- a/packages/run-spaceapp/README.md +++ b/packages/run-spaceapp/README.md @@ -59,9 +59,37 @@ checkpoint and 0.5 GiB headroom. Cached target images require no extra image spa Shared layers are excluded when public registry metadata is available; otherwise a conservative estimate is shown. Space is checked again after pulling and before pausing writers. Existing data is kept in place; the launcher never deletes -images, backups or volumes to free space. Separate Docker disk capacity is checked +images, backups or volumes to free space. Separate Docker/Podman disk capacity is checked when measurable and its availability is reported. +### Container Engine Selection & Rootless Podman + +SpaceApp supports both **Docker Engine / Docker Desktop** and rootless **Podman** (>= 5 with Compose provider, e.g. `docker-compose-v2` or `podman-compose`). + +```bash +# Explicitly choose the container engine +npx --yes run-spaceapp@latest install --engine podman +npx --yes run-spaceapp@latest install --engine docker +``` + +- **Auto-detection**: If `--engine` is omitted, the installer prefers Docker if working. If Docker is absent and Podman is ready on Linux, Podman is offered interactively (or auto-selected in `--non-interactive`). +- **SELinux Relabeling**: Workspace bind mounts automatically append `bind: { selinux: "z" }` on Podman to allow container access under SELinux. +- **cgroup v2 & Resource Limits**: `doctor` inspects cgroup version and controller delegation (`cpu`, `memory`). If resource limits cannot be applied due to missing rootless delegation, `doctor` reports clear guidance to enable user lingering (`loginctl enable-linger`) or delegate controllers in systemd. +- **macOS Podman Machine**: On macOS, `install --engine podman` checks the active Podman machine's CPU, memory, and disk capacity against the requested profile. If resources are insufficient, it provides the exact resize commands: + ```bash + podman machine stop + podman machine set --cpus --memory --disk-size + podman machine start + ``` +- **Engine Switching & Migration**: To prevent data loss (Docker volumes are not visible to Podman), the launcher refuses switching container engines on an existing installation. To safely migrate engines: + ```bash + spaceapp backup + spaceapp uninstall --purge-data + spaceapp install --engine podman + spaceapp restore + ``` +- **Security Boundaries**: `--access host-root` is supported only on Docker on Linux and is refused on Podman (`Host-root access is not supported on Podman.`). + ### 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 8effe0d8c..3a6585976 100644 --- a/packages/run-spaceapp/src/cli.mjs +++ b/packages/run-spaceapp/src/cli.mjs @@ -46,6 +46,11 @@ import { import { inspectStoragePlan, storageChecks } from "./storage.mjs"; import { ensureDockerAvailable, + ensurePodmanAvailable, + podmanInstallHelp, + podmanIsReady, + podmanCliAndComposeAreAvailable, + checkMacPodmanMachine, prepareDockerCliPath, windowsPowerShellArgs } from "./prerequisites.mjs"; @@ -74,6 +79,7 @@ const trustedCommands = new Set([ "docker", "hdiutil", "open", + "podman", "powershell.exe", "sg", "shutdown.exe", @@ -101,7 +107,7 @@ export function writeWindowsCredentialHint(platform, stderr) { } export async function withHeadlessDockerConfig(platform, spec, run) { - if (platform !== "win32") { + if (platform !== "win32" || spec.command !== "docker") { return run(spec); } const configDir = await mkdtemp(join(tmpdir(), "spaceapp-docker-config-")); @@ -423,7 +429,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"); @@ -505,14 +511,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; @@ -523,7 +529,7 @@ export async function run(argv, { } if (command === "backup") { assertNoArgs(args, command); - return runtimeExecute(composeCommand("backup", root, { profile: config.profile, companionsEnabled: config.companionsEnabled }), { stdin, stdout, stderr }); + return runtimeExecute(composeCommand("backup", root, { profile: config.profile, companionsEnabled: config.companionsEnabled, engine: config.engine }), { stdin, stdout, stderr }); } if (command === "restore") { assertNoArgs(args, command); @@ -537,61 +543,62 @@ export async function run(argv, { throw new Error("Restore cancelled."); } const backupId = await selectLatestBackupId(root); - const backupCode = await runtimeExecute(composeCommand("backup", root, { profile: config.profile, companionsEnabled: config.companionsEnabled }), { stdin, stdout, stderr }); + const backupCode = await runtimeExecute(composeCommand("backup", root, { profile: config.profile, companionsEnabled: config.companionsEnabled, engine: config.engine }), { stdin, stdout, stderr }); if (backupCode !== 0) return backupCode; - const stopCode = await runtimeExecute(composeCommand("stopForRestore", root, { profile: config.profile, companionsEnabled: config.companionsEnabled }), { stdin, stdout, stderr }); + const stopCode = await runtimeExecute(composeCommand("stopForRestore", root, { profile: config.profile, companionsEnabled: config.companionsEnabled, engine: config.engine }), { stdin, stdout, stderr }); if (stopCode !== 0) return stopCode; const restoreCode = await runtimeExecute( - composeCommand("restore", root, { backupId, profile: config.profile }), + composeCommand("restore", root, { backupId, profile: config.profile, engine: config.engine }), { stdin, stdout, stderr } ); if (restoreCode !== 0) return restoreCode; - return runtimeExecute(composeCommand("up", root, { profile: config.profile, companionsEnabled: config.companionsEnabled }), { stdin, stdout, stderr }); + return runtimeExecute(composeCommand("up", root, { profile: config.profile, companionsEnabled: config.companionsEnabled, engine: config.engine }), { stdin, stdout, stderr }); } if (command === "uninstall") { + 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 }); + const confirmation = await readSecret(stdin, stdout, `Type DELETE to remove ${engineLabel} volumes: `, { mask: false }); if (confirmation !== "DELETE") { throw new Error("Purge cancelled."); } - stdout.write("Removing SpaceApp containers, network, and Docker volumes...\n"); + stdout.write(`Removing SpaceApp containers, network, and ${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; @@ -638,25 +645,34 @@ async function installCommand(args, { const { requestedProfile, requestedAccessMode, + requestedEngine, noOpen, companionsEnabled, telemetryEnabled, installId } = wizard; const existingConfig = await loadExistingInstallation(root); + const engine = requestedEngine ?? existingConfig?.engine ?? "docker"; const accessMode = resolveInstallAccessMode( requestedAccessMode, existingConfig?.accessMode ?? "isolated" ); - if (accessMode === "host-root" && platform !== "linux") { - throw new Error("Host-root access is supported only on Linux."); + if (accessMode === "host-root") { + if (engine === "podman") { + throw new Error("Host-root access is not supported on Podman."); + } + if (platform !== "linux") { + throw new Error("Host-root access is supported only on Linux."); + } + if (!hostRootRuntimeCompatible) { + throw new Error( + "Host-root access is disabled for this launcher-only candidate because its runtime images were not rebuilt. Use --access isolated." + ); + } } - if (accessMode === "host-root" && !hostRootRuntimeCompatible) { - throw new Error( - "Host-root access is disabled for this launcher-only candidate because its runtime images were not rebuilt. Use --access isolated." - ); + if (engine === "docker") { + await prepareDockerPath({ platform, env }); } - await prepareDockerPath({ platform, env }); const resources = await inspectResources(root); let profile = requestedProfile === "auto" && existingConfig ? existingConfig.profile : resolveInstallProfile(requestedProfile, resources); @@ -669,6 +685,7 @@ async function installCommand(args, { version: runtimeVersion, profile, accessMode, + engine, ...(companionsEnabled ? { companionsEnabled } : {}) }); // The stored choice also drives the app's first_answer event after later updates, so an explicit @@ -689,6 +706,7 @@ async function installCommand(args, { return code; }; + const engineLabel = engine === "podman" ? "Podman" : "Docker"; stdout.write(`Launcher version: ${launcherVersion}\n`); stdout.write( `Runtime image version: ${existingConfig?.version ?? "not installed"} -> ${result.config.version}\n` @@ -700,8 +718,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 ${engineLabel} volumes.\n` + : `Future refreshes preserve data, workspaces, credentials, secrets, and persistent ${engineLabel} volumes.\n`); stdout.write( `Selected profile: ${profile} (${formatGibibytes(resources.totalMemoryBytes)} GiB system memory detected).\n` ); @@ -717,7 +735,8 @@ async function installCommand(args, { stdin, inspectResources, resources, - profile, operation + profile, operation, + engine }); stderr.write( `Installation stopped before downloading images. Fix the failed checks and run "${UNIVERSAL_COMMAND} install" again.\n` @@ -725,17 +744,31 @@ 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 = engine; + let prerequisiteResult; + if (engine === "podman") { + prerequisiteResult = await ensurePodmanAvailable({ + platform, + arch, + env, + stdin, + stdout, + stderr, + execute, + profile + }); + } else { + prerequisiteResult = await ensureDocker({ + platform, + arch, + env, + stdin, + stdout, + stderr, + execute, + installArgs: { root, requestedProfile, requestedAccessMode, requestedEngine, noOpen, autoConfirm: true } + }); + } if (prerequisiteResult.reexecuted) { return prerequisiteResult.code; } @@ -754,7 +787,7 @@ async function installCommand(args, { if (engineProfile !== profile) { profile = engineProfile; result.config = { ...result.config, profile }; - stdout.write(`Docker resources select profile: ${profile}.\n`); + stdout.write(`${engineLabel} resources select profile: ${profile}.\n`); storagePlan = await inspectStoragePlan({ root, config: result.config, resources: storagePlan.resources, execute, operation }); } } @@ -768,7 +801,11 @@ async function installCommand(args, { inspectResources, resources, profile, - dockerReady: true, operation, storagePlan + dockerReady: engine === "docker", + podmanReady: engine === "podman", + engine, + operation, + storagePlan }); if (doctorCode !== 0) { stderr.write( @@ -790,17 +827,19 @@ async function installCommand(args, { stdin, stdout, stderr, - execute + execute, + engine }); if (upgradeCheckpoint) { await retainFailedUpgrade(root, upgradeCheckpoint, - {...result.config,accessMode:existingConfig.accessMode}, {stdin,stdout,stderr,execute}); + {...result.config,accessMode:existingConfig.accessMode, engine}, {stdin,stdout,stderr,execute}); } else await restoreRuntimeAfterFailedInstall({ root, stagedStateRoot, existingConfig, attemptedProfile: profile, companionsEnabled, + engine, platform, stdin, stdout, @@ -819,6 +858,7 @@ async function installCommand(args, { profile, stateRoot: stagedStateRoot, companionsEnabled, + engine, ...options }); const pullCode = await withHeadlessDockerConfig( @@ -1011,7 +1051,7 @@ async function removeInactiveProfileServices({root, config, stateRoot = root, ex [settings.temporalEnabled, "removeTemporal", "medium"] ]) { if (enabled) continue; - const code = await execute(composeCommand(action, root, {profile, stateRoot}), {stdin, stdout, stderr}); + const code = await execute(composeCommand(action, root, {profile, stateRoot, engine: config?.engine}), {stdin, stdout, stderr}); if (code !== 0) { stderr.write("Inactive service cleanup failed. Data volumes were preserved. Run doctor --fix.\n"); return code; @@ -1054,6 +1094,14 @@ async function planInteractiveSetup({ return { resolved, display: choice === "auto" ? `auto (${resolved})` : resolved }; }; + let requestedEngine = parsed.requestedEngine; + const answers = stdin?._spaceappPromptController?.answers; + if (!requestedEngine && answers?.engine) { + if (["docker", "podman"].includes(answers.engine)) { + requestedEngine = answers.engine; + } + } + if (continuation) { await consumeRunOnceContinuation(root); stdout.write("Continuing the approved unattended SpaceApp setup plan.\n"); @@ -1062,6 +1110,7 @@ async function planInteractiveSetup({ return { requestedProfile: actions.profile ?? "auto", requestedAccessMode: actions.accessMode, + requestedEngine: actions.engine ?? requestedEngine, noOpen: actions.noOpen !== false, companionsEnabled: actions.companionsEnabled ?? false, telemetryEnabled: actions.telemetry ?? false, @@ -1070,6 +1119,50 @@ async function planInteractiveSetup({ } if (path === "fresh") { + if (!requestedEngine) { + if (parsed.requestedAccessMode === "host-root" || platform !== "linux") { + requestedEngine = "docker"; + } else { + let dockerWorking = false; + try { + const dockerCode = await execute( + { command: "docker", args: ["--version"], timeoutMs: 15000 }, + { stdin: null, stdout: null, stderr: null } + ); + dockerWorking = dockerCode === 0; + } catch { + dockerWorking = false; + } + + if (dockerWorking) { + requestedEngine = "docker"; + } else { + let podmanWorking = false; + try { + const podmanCode = await execute( + { command: "podman", args: ["--version"], timeoutMs: 15000 }, + { stdin: null, stdout: null, stderr: null } + ); + podmanWorking = podmanCode === 0; + } catch { + podmanWorking = false; + } + + if (podmanWorking) { + if (!interactive) { + requestedEngine = "podman"; + } else { + requestedEngine = await promptChoice(stdin, stdout, "Which container engine?", [ + { label: "podman (Podman rootless)", value: "podman" }, + { label: "docker (Docker Engine / Docker Desktop)", value: "docker" } + ], { defaultIndex: 0, answerKey: "engine" }); + } + } else { + requestedEngine = "docker"; + } + } + } + } const profileChoice = await promptChoice(stdin, stdout, "Which installation profile?", [ { label: `auto (${resolveProfileChoice("auto").resolved}; ${resources.cpuCount} CPUs, ${formatGibibytes(resources.totalMemoryBytes)} GiB RAM, ${formatGibibytes(resources.freeDiskBytes)} GiB free)`, value: "auto" }, { label: "small (SpaceApp + OpenCode; no background workflows or managed browser)", value: "small" }, @@ -1092,7 +1185,7 @@ async function planInteractiveSetup({ // Created at consent so a Windows continuation after the Docker install keeps the same install id. const installId = telemetryChoice ? randomUUID() : undefined; let dockerChoice = true; - if (platform === "win32") { + if (platform === "win32" && requestedEngine === "docker") { const dockerProbe = await detectDockerAvailable({ execute }); if (dockerProbe !== 0) { dockerChoice = await promptYesNo(stdin, stdout, "Docker Engine was not detected. Include automatic Docker installation?", { defaultYes: true }); @@ -1101,6 +1194,7 @@ async function planInteractiveSetup({ const openBrowserChoice = await promptYesNo(stdin, stdout, "Open the web application when installation completes?", { defaultYes: !parsed.noOpen, answerKey: "open" }); const { resolved } = resolveProfileChoice(profileChoice); + const engineLabel = requestedEngine === "podman" ? "Podman" : "Docker"; const approved = await finalConfirmation(stdin, stdout, [ `Installation root: ${root}`, `Runtime image version: not installed -> ${runtimeVersion}`, @@ -1108,9 +1202,11 @@ async function planInteractiveSetup({ `Access mode: ${accessChoice}`, `Companions: ${companionsChoice ? "enabled" : "disabled"}`, `Telemetry: ${telemetryChoice ? "enabled" : "disabled"}`, - `Docker installation: ${dockerChoice ? "automatic if missing" : "not included"}`, + ...(requestedEngine === "podman" + ? ["Container engine: podman (rootless)"] + : [`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." + `Future refreshes preserve data, workspaces, credentials, secrets, and persistent ${engineLabel} volumes.` ]); if (!approved) { return { exit: 0 }; @@ -1119,6 +1215,7 @@ async function planInteractiveSetup({ await offerUnattendedContinuation(root, runtimeVersion, stdin, stdout, { profile: profileChoice, accessMode: accessChoice, + engine: requestedEngine, companionsEnabled: companionsChoice, telemetry: telemetryChoice, installId, @@ -1128,6 +1225,7 @@ async function planInteractiveSetup({ return { requestedProfile: profileChoice, requestedAccessMode: accessChoice, + requestedEngine, noOpen: !openBrowserChoice, companionsEnabled: companionsChoice, telemetryEnabled: telemetryChoice, @@ -1178,6 +1276,7 @@ async function planInteractiveSetup({ return { exit: 1 }; } + const engineLabel = (parsed.requestedEngine ?? rawExisting?.engine) === "podman" ? "Podman" : "Docker"; const repairLines = repairs.actions.map((action) => `Config repair: ${action.detail}`); const approved = await finalConfirmation(stdin, stdout, [ `Installation root: ${root}`, @@ -1188,7 +1287,7 @@ async function planInteractiveSetup({ path === "preserve-recreate" ? "Runtime: preserve/recreate (containers are recreated without touching data volumes)." : "Runtime: staged upgrade with a verified checkpoint; a failure after cutover preserves current data for recovery with doctor --fix.", - "Preserved: data, workspaces, credentials, secrets, and persistent Docker volumes." + `Preserved: data, workspaces, credentials, secrets, and persistent ${engineLabel} volumes.` ]); if (!approved) { return { exit: 0 }; @@ -1198,6 +1297,7 @@ async function planInteractiveSetup({ await offerUnattendedContinuation(root, runtimeVersion, stdin, stdout, { profile: parsed.requestedProfile, accessMode: parsed.requestedAccessMode, + engine: parsed.requestedEngine ?? rawExisting?.engine, companionsEnabled: parsed.companionsEnabled, noOpen: parsed.noOpen }); @@ -1205,6 +1305,7 @@ async function planInteractiveSetup({ return { requestedProfile: parsed.requestedProfile, requestedAccessMode: parsed.requestedAccessMode, + requestedEngine: parsed.requestedEngine ?? rawExisting?.engine, noOpen: parsed.noOpen, companionsEnabled: parsed.companionsEnabled, telemetryEnabled: false @@ -1215,6 +1316,13 @@ function hasRequestedConfigChange(rawExisting, parsed) { if (!rawExisting) { return false; } + if ( + parsed.requestedEngine !== undefined && + rawExisting.engine !== parsed.requestedEngine && + (rawExisting.engine ?? "docker") !== parsed.requestedEngine + ) { + return true; + } if ( parsed.requestedAccessMode !== undefined && rawExisting.accessMode !== parsed.requestedAccessMode @@ -1277,25 +1385,36 @@ 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}}); - if(prerequisites.reexecuted || prerequisites.code !== 0) return prerequisites.code; + const engine = repaired.engine ?? "docker"; + if (engine === "podman") { + const ready = await podmanIsReady(execute); + if (!ready) { + stderr.write(`Podman is not running or accessible. ${podmanInstallHelp(platform)}\n`); + return 1; + } + } else { + const prerequisites = await ensureDocker({platform, arch, env, stdin, stdout, stderr, execute, sleep: sleep || wait, + installArgs: {root,requestedProfile:repaired.profile,requestedAccessMode:repaired.accessMode,requestedEngine:engine,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; 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); + if (config.engine !== "podman") { + writeWindowsCredentialHint(platform, stderr); + } return pullCode; } if (await verifyStorage({ root, config, existingConfig: config, operation: "repair", inspectResources, execute, stdout, stderr }) !== 0) return 1; const upCode = await execute( - composeCommand("repair", root, { profile: config.profile, companionsEnabled: config.companionsEnabled }), + composeCommand("repair", root, { profile: config.profile, companionsEnabled: config.companionsEnabled, engine: config.engine }), { stdin, stdout, stderr } ); if (upCode !== 0) { @@ -1347,24 +1466,28 @@ async function performUpdate({ let checkpoint = null; try { await commitInstallation(stagedRoot, updated); - const stagedCommand = (action) => composeCommand(action, root, {...updated, stateRoot: stagedRoot}); + const stagedCommand = (action) => composeCommand(action, root, {...updated, engine: updated.engine, stateRoot: stagedRoot}); const pullCode = await withHeadlessDockerConfig( platform, stagedCommand("pull"), (pullSpec) => execute(pullSpec, { stdin, stdout, stderr }) ); if (pullCode !== 0) { - writeWindowsCredentialHint(platform, stderr); - throw new Error(`Image pull failed with Docker exit ${pullCode}.`); + if (updated.engine !== "podman") { + writeWindowsCredentialHint(platform, stderr); + } + const engineLabel = updated.engine === "podman" ? "Podman" : "Docker"; + throw new Error(`Image pull failed with ${engineLabel} exit ${pullCode}.`); } if (await verifyStorage({ root, config: updated, existingConfig: config, operation: "upgrade", inspectResources, execute, stdout, stderr }) !== 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) { - throw new Error(`Runtime start failed with Docker exit ${upCode}.`); + const engineLabel = updated.engine === "podman" ? "Podman" : "Docker"; + throw new Error(`Runtime start failed with ${engineLabel} exit ${upCode}.`); } if (typeof request === "function") { const url = `http://${updated.bindHost}:${updated.port}`; @@ -1482,7 +1605,7 @@ async function createCheckpoint(root, config, { stdin, stdout, stderr, execute, let dumpCode; try { dumpCode = await execute( - composeCommand("checkpointDump", root, { profile: config.profile, companionsEnabled: config.companionsEnabled }), + composeCommand("checkpointDump", root, { profile: config.profile, companionsEnabled: config.companionsEnabled, engine: config.engine }), { stdin: null, stdout: dumpStream, stderr } ); } finally { @@ -1568,7 +1691,8 @@ async function reportInstallDiagnostics({ stdin, stdout, stderr, - execute + execute, + engine = "docker" }) { stderr.write("Collecting SpaceApp service status and recent logs before rollback...\n"); for (const action of ["status", "logs"]) { @@ -1578,14 +1702,16 @@ async function reportInstallDiagnostics({ composeCommand(action, root, { profile, stateRoot, - lines: 200 + lines: 200, + engine }), { stdin, stdout, stderr }, - { platform, stderr } + { platform, stderr, engine } ); if (code !== 0) { + const engineLabel = engine === "podman" ? "Podman" : "Docker"; stderr.write( - `SpaceApp could not collect ${action} diagnostics (Docker exit ${code}).\n` + `SpaceApp could not collect ${action} diagnostics (${engineLabel} exit ${code}).\n` ); } } catch (error) { @@ -1602,17 +1728,19 @@ async function restoreRuntimeAfterFailedInstall({ existingConfig, attemptedProfile, companionsEnabled, + engine = "docker", platform, stdin, stdout, stderr, execute }) { + const targetEngine = existingConfig?.engine ?? engine; const runtimeExecute = (spec) => executeWithDockerDiagnostics( execute, spec, { stdin, stdout, stderr }, - { platform, stderr } + { platform, stderr, engine: targetEngine } ); try { if (existingConfig) { @@ -1620,7 +1748,8 @@ async function restoreRuntimeAfterFailedInstall({ const restoreCode = await runtimeExecute( composeCommand("up", root, { profile: existingConfig.profile, - companionsEnabled: existingConfig.companionsEnabled + companionsEnabled: existingConfig.companionsEnabled, + engine: existingConfig.engine }) ); if (restoreCode === 0) { @@ -1639,7 +1768,8 @@ async function restoreRuntimeAfterFailedInstall({ composeCommand("down", root, { profile: existingConfig?.profile ?? attemptedProfile, stateRoot: existingConfig ? root : stagedStateRoot, - companionsEnabled: existingConfig?.companionsEnabled ?? companionsEnabled + companionsEnabled: existingConfig?.companionsEnabled ?? companionsEnabled, + engine: targetEngine }) ); if (stopCode !== 0) { @@ -1654,9 +1784,16 @@ async function restoreRuntimeAfterFailedInstall({ } } +/** + * Parses and validates command-line arguments for the install command. + * + * @param {string[]} args Array of command-line argument strings. + * @returns {{ requestedProfile: string, requestedAccessMode?: string, requestedEngine?: string, noOpen: boolean, companionsEnabled: boolean }} + */ 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) { @@ -1669,6 +1806,15 @@ function parseInstallArgs(args) { companionsEnabled = true; 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; + } if (argument === "--profile" && index + 1 < args.length) { requestedProfile = args[index + 1]; index += 1; @@ -1688,7 +1834,7 @@ function parseInstallArgs(args) { continue; } throw new Error( - `Usage: ${UNIVERSAL_COMMAND} install [--profile auto|small|medium|large|light|standard] [--access isolated|host-root] [--with-companions] [--no-open]` + `Usage: ${UNIVERSAL_COMMAND} install [--engine docker|podman] [--profile auto|small|medium|large|light|standard] [--access isolated|host-root] [--with-companions] [--no-open]` ); } if ( @@ -1696,13 +1842,17 @@ function parseInstallArgs(args) { ( requestedAccessMode !== undefined && !["isolated", "host-root"].includes(requestedAccessMode) + ) || + ( + requestedEngine !== undefined && + !["docker", "podman"].includes(requestedEngine) ) ) { throw new Error( - `Usage: ${UNIVERSAL_COMMAND} install [--profile auto|small|medium|large|light|standard] [--access isolated|host-root] [--with-companions] [--no-open]` + `Usage: ${UNIVERSAL_COMMAND} install [--engine docker|podman] [--profile auto|small|medium|large|light|standard] [--access isolated|host-root] [--with-companions] [--no-open]` ); } - return { requestedProfile, requestedAccessMode, noOpen, companionsEnabled }; + return { requestedProfile, requestedAccessMode, requestedEngine, noOpen, companionsEnabled }; } async function loadExistingInstallation(root) { @@ -1764,7 +1914,7 @@ async function credentialsCommand(args, { root, config, stdin, stdout, stderr, e const value = await readSecret(stdin, stdout, `Enter ${provider} credential: `); await writeCredential(root, provider, value); const syncCode = await execute( - composeCommand("syncCredentials", root, { profile: config.profile, companionsEnabled: config.companionsEnabled }), + composeCommand("syncCredentials", root, { profile: config.profile, companionsEnabled: config.companionsEnabled, engine: config.engine }), { stdin, stdout, stderr } ); if (syncCode !== 0) { @@ -1780,7 +1930,7 @@ async function credentialsCommand(args, { root, config, stdin, stdout, stderr, e } const removed = await removeCredential(root, provider); const syncCode = await execute( - composeCommand("syncCredentials", root, { profile: config.profile, companionsEnabled: config.companionsEnabled }), + composeCommand("syncCredentials", root, { profile: config.profile, companionsEnabled: config.companionsEnabled, engine: config.engine }), { stdin, stdout, stderr } ); if (syncCode !== 0) { @@ -1801,13 +1951,13 @@ async function providerCommand(args, { root, config, stdin, stdout, stderr, exec stdout.write(`Installing optional provider ${provider} into this installation's private provider volume.\n`); // Keep the existing Claude path compatible with older runtime images. const action=provider === "claude" ? "installClaude" : "installProvider"; - return execute(composeCommand(action, root, { profile: config.profile, companionsEnabled: config.companionsEnabled, provider }), { stdin, stdout, stderr }); + return execute(composeCommand(action, root, { profile: config.profile, companionsEnabled: config.companionsEnabled, provider, engine: config.engine }), { stdin, stdout, stderr }); } async function ownerCommand(args, { root, config, stdin, stdout, stderr, execute }) { if (args.length === 1 && args[0] === "rotate-setup-token") { const token = randomBytes(32).toString("base64url"); - const code = await execute(composeCommand("rotateOwnerSetupToken", root, { profile: config.profile, companionsEnabled: config.companionsEnabled }), { + const code = await execute(composeCommand("rotateOwnerSetupToken", root, { profile: config.profile, companionsEnabled: config.companionsEnabled, engine: config.engine }), { stdin, stdout, stderr, @@ -1828,7 +1978,7 @@ async function ownerCommand(args, { root, config, stdin, stdout, stderr, execute if (password.length < 6) { throw new Error("Owner password must be at least 6 characters."); } - return execute(composeCommand("resetOwnerPassword", root, { profile: config.profile, companionsEnabled: config.companionsEnabled }), { + return execute(composeCommand("resetOwnerPassword", root, { profile: config.profile, companionsEnabled: config.companionsEnabled, engine: config.engine }), { stdin, stdout, stderr, @@ -1856,7 +2006,7 @@ async function reinstallCommand({ return 0; } const downCode = await execute( - composeCommand("down", root, { profile: config.profile, companionsEnabled: config.companionsEnabled }), + composeCommand("down", root, { profile: config.profile, companionsEnabled: config.companionsEnabled, engine: config.engine }), { stdin, stdout, stderr } ); if (downCode !== 0) { @@ -1864,7 +2014,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) { @@ -1890,6 +2040,21 @@ async function reinstallCommand({ return 0; } +/** + * Generates an unredacted-safe diagnostic support bundle JSON for troubleshooting. + * + * @param {object} params Parameter object. + * @param {string} params.root Installation root directory. + * @param {object} [params.config] SpaceApp configuration object. + * @param {string} params.platform Operating system platform name. + * @param {string} params.arch System architecture string. + * @param {import("node:stream").Writable} params.stdout Standard output stream. + * @param {import("node:stream").Writable} params.stderr Standard error stream. + * @param {Function} params.execute Command execution runner. + * @param {Function} [params.request] Fetch-compatible request function. + * @param {string} [params.outPath] Optional custom output file path. + * @returns {Promise} Resolves with process exit code 0 on success. + */ async function supportBundleCommand({ root, config, @@ -1901,6 +2066,63 @@ async function supportBundleCommand({ request, outPath }) { + const existingConfig = config ?? await loadConfig(root).catch(() => null); + const resolvedEngine = existingConfig?.engine ?? "docker"; + let engineFacts = { + engine: resolvedEngine + }; + if (resolvedEngine === "podman") { + let rawJson = ""; + try { + await execute({ command: "podman", args: ["info", "--format", "json"] }, { + stdin: null, + stdout: { write: (c) => { rawJson += c; } }, + stderr: null + }); + const parsed = JSON.parse(rawJson); + const controllers = Array.isArray(parsed?.host?.cgroupControllers) + ? parsed.host.cgroupControllers + : typeof parsed?.host?.cgroupControllers === "string" + ? parsed.host.cgroupControllers.split(/[,\s]+/) + : []; + const hasCpu = controllers.some((c) => String(c).toLowerCase().includes("cpu")); + const hasMemory = controllers.some((c) => String(c).toLowerCase().includes("memory")); + const limitsDelegated = platform !== "linux" || (hasCpu && hasMemory); + engineFacts = { + engine: "podman", + version: parsed?.version?.Version ?? parsed?.Version?.Version ?? "unknown", + rootless: parsed?.host?.security?.rootless ?? null, + cgroupVersion: parsed?.host?.cgroupVersion ?? null, + cgroupControllers: parsed?.host?.cgroupControllers ?? null, + limitsDelegated + }; + } catch { + engineFacts = { + engine: "podman", + status: "unavailable" + }; + } + } else { + let rawJson = ""; + try { + await execute({ command: "docker", args: ["info", "--format", "{{json .}}"] }, { + stdin: null, + stdout: { write: (c) => { rawJson += c; } }, + stderr: null + }); + const parsed = JSON.parse(rawJson); + engineFacts = { + engine: "docker", + serverVersion: parsed?.ServerVersion ?? null + }; + } catch { + engineFacts = { + engine: "docker", + status: "unavailable" + }; + } + } + const bundle = { schemaVersion: "1.0.0", collectedAt: new Date().toISOString(), @@ -1914,17 +2136,19 @@ async function supportBundleCommand({ version: PACKAGE_VERSION, runtimeVersion: RUNTIME_VERSION }, + engine: engineFacts, installation: { root, - config: config + config: existingConfig ? { - version: config.version, - profile: config.profile, - accessMode: config.accessMode, - companionsEnabled: config.companionsEnabled, - bindHost: config.bindHost, - port: config.port, - previousVersion: config.previousVersion ?? null + version: existingConfig.version, + profile: existingConfig.profile, + accessMode: existingConfig.accessMode, + engine: existingConfig.engine ?? "docker", + companionsEnabled: existingConfig.companionsEnabled, + bindHost: existingConfig.bindHost, + port: existingConfig.port, + previousVersion: existingConfig.previousVersion ?? null } : null }, @@ -1932,8 +2156,8 @@ async function supportBundleCommand({ health: {} }; - if (config) { - const url = `http://${config.bindHost}:${config.port}`; + if (existingConfig) { + const url = `http://${existingConfig.bindHost}:${existingConfig.port}`; if (typeof request === "function") { try { const res = await request(`${url}/readyz`, { method: "GET", signal: AbortSignal.timeout(3000) }); @@ -1971,7 +2195,7 @@ async function factoryResetCommand({ try { stdout.write("Creating safety backup before factory reset...\n"); backupCode = await execute( - composeCommand("backup", root, { profile: config.profile, companionsEnabled: config.companionsEnabled }), + composeCommand("backup", root, { profile: config.profile, companionsEnabled: config.companionsEnabled, engine: config.engine }), { stdin, stdout, stderr } ); } catch (err) { @@ -1999,7 +2223,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) { @@ -2095,13 +2319,22 @@ 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 } }); - if (prerequisites.reexecuted || prerequisites.code !== 0) return prerequisites.code; + const engine = config.engine ?? "docker"; + if (engine === "podman") { + const ready = await podmanIsReady(execute); + if (!ready) { + stderr.write(`Podman is not running or accessible. ${podmanInstallHelp(platform)}\n`); + return 1; + } + } else { + const prerequisites = await ensureDocker({ platform, arch, env, stdin, stdout, stderr, execute, + installArgs: { root, requestedProfile: config.profile, requestedEngine: 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" }); 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: engine === "docker", podmanReady: engine === "podman", engine, operation: "upgrade", storagePlan }) !== 0) return 1; return performUpdate({ root, config, @@ -2130,6 +2363,26 @@ async function verifyStorage({ root, config, existingConfig, operation, inspectR return 0; } +/** + * Executes system, configuration, resource, and container runtime diagnostic probes. + * + * @param {object} params Diagnostic parameters. + * @param {string} params.root SpaceApp installation directory. + * @param {string} params.platform Operating system platform name. + * @param {import("node:stream").Writable} params.stdout Standard output stream. + * @param {import("node:stream").Writable} params.stderr Standard error stream. + * @param {Function} params.execute Command execution runner. + * @param {import("node:stream").Readable} [params.stdin] Standard input stream. + * @param {Function} params.inspectResources System resource inspection callback. + * @param {object} [params.resources] Pre-inspected system resources. + * @param {string} [params.profile] Selected install profile. + * @param {string} [params.operation] Installation operation type ("fresh", "upgrade", "repair", "maintenance"). + * @param {object} [params.storagePlan] Pre-inspected storage plan. + * @param {boolean} [params.dockerReady] Whether Docker engine readiness is pre-verified. + * @param {boolean} [params.podmanReady] Whether Podman engine readiness is pre-verified. + * @param {string} [params.engine] Container engine ("docker" | "podman"). + * @returns {Promise} Resolves with 0 if all checks pass, 1 otherwise. + */ async function doctor({ root, platform, @@ -2142,13 +2395,15 @@ async function doctor({ profile, operation, storagePlan, - dockerReady = false + dockerReady = false, + podmanReady = 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); let resolvedProfile = profile; if (!resolvedProfile) { - const existingConfig = await loadConfig(root).catch(() => null); if (existingConfig?.profile) { resolvedProfile = existingConfig.profile; } else if (detectedResources?.totalMemoryBytes) { @@ -2157,7 +2412,8 @@ async function doctor({ resolvedProfile = "light"; } } - operation ??= await loadConfig(root).then(() => "maintenance").catch(() => "fresh"); + const resolvedEngine = engine ?? existingConfig?.engine ?? "docker"; + operation ??= existingConfig ? "maintenance" : "fresh"; const resourceChecks = installResourceChecks(storagePlan?.resources ?? detectedResources, resolvedProfile, { operation, ...(storagePlan ? { requiredDiskBytes: storagePlan.requiredDiskBytes } : {}) }); @@ -2167,6 +2423,119 @@ 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 Service", command: "podman", args: ["info"] } + ]) { + const code = podmanReady + ? 0 + : await execute(probe, { stdin, stdout: null, stderr: null }); + podmanResults.push({ ...probe, code }); + } + const [podmanCli, podmanCompose, podmanService] = podmanResults; + checks.push({ + name: podmanCli.name, + ok: podmanCli.code === 0, + detail: podmanCli.code === 0 + ? "available" + : podmanCli.code === 127 + ? "not found on PATH" + : `unavailable (exit ${podmanCli.code})` + }); + checks.push({ + name: podmanCompose.name, + ok: podmanCompose.code === 0, + detail: podmanCompose.code === 0 + ? "available" + : podmanCli.code !== 0 + ? "not available because Podman CLI is missing" + : `compose provider unavailable (exit ${podmanCompose.code})` + }); + checks.push({ + name: podmanService.name, + ok: podmanService.code === 0, + detail: podmanService.code === 0 + ? "available" + : podmanCli.code === 0 + ? "installed but not running or inaccessible" + : "not reachable because Podman CLI is missing" + }); + + let podmanInfo = null; + if (podmanService.code === 0) { + let rawJson = ""; + try { + await execute({ command: "podman", args: ["info", "--format", "json"] }, { + stdin: null, + stdout: { + write: (chunk) => { + rawJson += chunk; + } + }, + stderr: null + }); + podmanInfo = JSON.parse(rawJson); + } catch { + podmanInfo = null; + } + } + + if (podmanInfo) { + const version = podmanInfo?.version?.Version ?? podmanInfo?.Version?.Version ?? "unknown"; + checks.push({ + name: "Podman version", + ok: true, + detail: version + }); + + const isRootless = Boolean(podmanInfo?.host?.security?.rootless); + checks.push({ + name: "Podman rootless", + ok: true, + detail: isRootless ? "rootless" : "rootful" + }); + + const cgroupVersion = podmanInfo?.host?.cgroupVersion ?? (platform === "linux" ? "unknown" : "v2"); + const isCgroupV2 = cgroupVersion === "v2" || cgroupVersion === "2"; + checks.push({ + name: "Podman cgroup version", + ok: isCgroupV2, + detail: cgroupVersion + }); + + const controllers = Array.isArray(podmanInfo?.host?.cgroupControllers) + ? podmanInfo.host.cgroupControllers + : typeof podmanInfo?.host?.cgroupControllers === "string" + ? podmanInfo.host.cgroupControllers.split(/[,\s]+/) + : []; + const hasCpu = controllers.some((c) => String(c).toLowerCase().includes("cpu")); + const hasMemory = controllers.some((c) => String(c).toLowerCase().includes("memory")); + const limitsDelegated = platform !== "linux" || (hasCpu && hasMemory); + checks.push({ + name: "Podman resource limit delegation", + ok: limitsDelegated, + detail: limitsDelegated + ? "CPU and memory controllers delegated" + : "CPU and memory limits cannot be applied (cgroup controllers not delegated to user session)" + }); + if (!limitsDelegated) { + stderr.write("Warning: CPU and memory limits cannot be applied by rootless Podman because cgroup controllers are not delegated. Configure systemd user delegation to enable container resource limits.\n"); + } + } + + 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 || podmanService.code !== 0) { + stderr.write(`${podmanInstallHelp(platform)}\n`); + } + return checks.every((check) => check.ok) ? 0 : 1; + } + const dockerResults = []; for (const probe of [ { name: "Docker CLI", command: "docker", args: ["--version"] }, @@ -2320,6 +2689,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; } @@ -2391,6 +2764,7 @@ function spawnTrustedCommand(spec, options) { case "docker": return spawn("docker", args, options); case "hdiutil": return spawn("hdiutil", args, options); case "open": return spawn("open", args, options); + case "podman": return spawn("podman", args, options); case "powershell.exe": return spawn("powershell.exe", windowsPowerShellArgs(spec.operation), options); case "sg": return spawn("sg", args, options); @@ -2494,12 +2868,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 [--engine docker|podman] [--profile auto|small|medium|large|light|standard] [--access isolated|host-root] [--with-companions] [--no-open] Install prerequisites, initialize, and start - reinstall [--keep-data] [--profile auto|small|medium|large|light|standard] [--access isolated|host-root] [--with-companions] [--no-open] + reinstall [--keep-data] [--engine docker|podman] [--profile auto|small|medium|large|light|standard] [--access isolated|host-root] [--with-companions] [--no-open] Clean reinstallation preserving data and credentials repair [--dry-run] Verify and repair configuration and runtime files - up | down | status | logs Manage the Docker application + up | down | status | logs Manage the application runtime open Open the local web application doctor [--fix] Diagnose; --fix repairs existing config and runtime support-bundle Generate a diagnostic bundle JSON for support @@ -2520,6 +2894,7 @@ Usage: ${UNIVERSAL_COMMAND} uninstall [--purge-data] Remove containers; keep data by default Options: + --engine docker|podman Container engine (defaults to docker; picks podman if only podman exists) --plan Dry-run mode: print actions without executing --json Output structured JSON where supported --log-file Append all execution logs to specified file diff --git a/packages/run-spaceapp/src/index.mjs b/packages/run-spaceapp/src/index.mjs index a30c1db8c..49b8030a9 100644 --- a/packages/run-spaceapp/src/index.mjs +++ b/packages/run-spaceapp/src/index.mjs @@ -114,7 +114,8 @@ const CONFIG_KEYS = new Set([ "workspaces", "companionsEnabled", "optionalModules", - "installId" + "installId", + "engine" ]); // Optional SpaceApp modules (games, copilots, channels, ...): "all", "none" or a comma list. @@ -284,18 +285,30 @@ export function installResourceChecks(resources, profile = "small", { requiredDi ]; } +/** + * Creates the default configuration object for SpaceApp. + * + * @param {object} options Configuration options. + * @param {string} options.version SpaceApp semantic version. + * @param {string} [options.profile="light"] Selected installation profile. + * @param {string} [options.accessMode="isolated"] Selected access mode ("isolated" or "host-root"). + * @param {boolean} [options.companionsEnabled=false] Whether companion integrations are enabled. + * @param {string} [options.engine] Container engine ("docker" or "podman"). + * @returns {object} Default configuration object. + */ export function createDefaultConfig({ version, profile = "light", accessMode = "isolated", - companionsEnabled = false + companionsEnabled = false, + engine }) { assertVersion(version); if (!INSTALL_PROFILES.includes(profile)) { throw new Error("Default config requires a resolved installation profile."); } const resolvedAccessMode = resolveInstallAccessMode(accessMode); - return { + const config = { schemaVersion: CONFIG_SCHEMA_VERSION, version, previousVersion: null, @@ -308,8 +321,21 @@ export function createDefaultConfig({ optionalModules: "none", workspaces: [] }; + if (engine !== undefined) { + if (engine !== "docker" && engine !== "podman") { + throw new Error('engine must be "docker" or "podman".'); + } + config.engine = engine; + } + return config; } +/** + * Validates a SpaceApp configuration object against supported fields and schema constraints. + * + * @param {object} config Configuration object to validate. + * @returns {object} The validated configuration object. + */ export function validateConfig(config) { if (!config || typeof config !== "object" || Array.isArray(config)) { throw new Error("SpaceApp config must be an object."); @@ -347,6 +373,12 @@ export function validateConfig(config) { if (typeof config.companionsEnabled !== "boolean") { throw new Error("companionsEnabled must be boolean."); } + if (config.engine !== undefined && config.engine !== "docker" && config.engine !== "podman") { + throw new Error('engine must be "docker" or "podman".'); + } + if (config.engine === "podman" && config.accessMode === "host-root") { + throw new Error("Host-root access is not supported on Podman."); + } if (config.optionalModules !== undefined && !OPTIONAL_MODULES_PATTERN.test(config.optionalModules)) { throw new Error("optionalModules must be all, none or a comma-separated list of module names."); } @@ -575,13 +607,25 @@ export function renderRuntimeEnv(config) { ].join("\n"); } +/** + * Renders the compose override YAML file for external workspace bind mounts. + * Applies the SELinux "z" shared label when running on Podman. + * + * @param {object} config SpaceApp configuration object. + * @returns {string} Compose YAML defining workspace bind mounts. + */ export function renderWorkspaceCompose(config) { validateConfig(config); + const isPodman = config.engine === "podman"; const mounts = config.workspaces.flatMap((workspace) => [ " - type: bind", ` source: ${JSON.stringify(workspace.hostPath)}`, ` target: ${JSON.stringify(workspace.containerPath)}`, - ` read_only: ${workspace.readOnly}` + ` read_only: ${workspace.readOnly}`, + ...(isPodman ? [ + " bind:", + " selinux: z" + ] : []) ]); const service = mounts.length > 0 ? [" volumes:", ...mounts] @@ -625,12 +669,25 @@ export function renderHostAccessCompose(config) { ].join("\n"); } +/** + * Constructs the container engine compose execution specification. + * Supports Docker and Podman container engines. + * + * @param {string} action Compose action to execute (e.g., "up", "down", "logs"). + * @param {string} root Installation root directory path. + * @param {object} [options={}] Command options including engine, profile, stateRoot. + * @returns {{ command: string, args: string[] }} Executable command specification. + */ export function composeCommand(action, root, options = {}) { validateHome(root); const stateRoot = options.stateRoot ?? root; validateHome(stateRoot); const profile = options.profile ?? "standard"; const settings = profileRuntimeSettings(profile); + const engine = options.engine ?? options.config?.engine ?? "docker"; + if (engine !== "docker" && engine !== "podman") { + throw new Error('Engine must be "docker" or "podman".'); + } const base = [ "compose", "--project-name", composeProjectName(root), @@ -731,7 +788,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) { @@ -764,11 +821,26 @@ export async function writeRuntimeFiles(root, config) { await atomicWrite(join(root, "compose.host-access.yml"), renderHostAccessCompose(config)); } +/** + * Prepares a SpaceApp installation in the specified root directory. + * Generates initial configurations, database passwords, session secrets, and tokens. + * Refuses switching container engines on an existing installation to protect persistent volumes. + * + * @param {string} root Installation root directory path. + * @param {object} options Installation parameters. + * @param {string} options.version SpaceApp semantic version. + * @param {string} [options.profile] Installation profile or "auto". + * @param {string} [options.accessMode] Access mode ("isolated" or "host-root"). + * @param {boolean} [options.companionsEnabled] Whether companions are enabled. + * @param {string} [options.engine] Container engine ("docker" or "podman"). + * @returns {Promise<{ config: object, setupToken: string|null }>} Prepared configuration and setup token. + */ export async function prepareInstallation(root, { version, profile, accessMode, - companionsEnabled + companionsEnabled, + engine }) { validateHome(root); await mkdir(root, { recursive: true, mode: 0o700 }); @@ -780,8 +852,10 @@ export async function prepareInstallation(root, { ? undefined : resolveInstallProfile(profile, profile === "auto" ? await inspectSystemResources(root) : totalmem()); let config; + let existingConfig = null; try { - config = await loadConfig(root); + existingConfig = await loadConfig(root); + config = existingConfig; } catch (error) { if (error?.code !== "ENOENT") { throw error; @@ -790,9 +864,18 @@ export async function prepareInstallation(root, { version, profile: resolvedProfile ?? "light", accessMode: resolveInstallAccessMode(accessMode), - companionsEnabled + companionsEnabled, + engine }); } + if (existingConfig) { + const existingEngine = existingConfig.engine ?? "docker"; + if (engine !== undefined && engine !== existingEngine) { + throw new Error( + `Cannot switch container engine from "${existingEngine}" to "${engine}" on an existing installation. Docker volumes are not visible to Podman. To migrate engines, run "spaceapp backup", uninstall with "spaceapp uninstall --purge-data", install with "--engine ${engine}", and run "spaceapp restore".` + ); + } + } if (config.version !== version) { config = { ...config, @@ -810,6 +893,9 @@ export async function prepareInstallation(root, { if (companionsEnabled !== undefined && config.companionsEnabled !== companionsEnabled) { config = { ...config, companionsEnabled }; } + if (engine !== undefined && config.engine !== engine) { + config = { ...config, engine }; + } validateConfig(config); const postgresPasswordPath = join(root, "secrets", "postgres-password"); @@ -866,6 +952,14 @@ export async function prepareInstallation(root, { return { config, setupToken }; } +/** + * Commits an installation by writing compose template, runtime configuration, and persisting config. + * + * @param {string} root Installation root directory path. + * @param {object} config SpaceApp configuration object. + * @param {object} [options={}] Options including templateDir and persistConfig. + * @returns {Promise} + */ export async function commitInstallation(root, config, { templateDir = defaultTemplateDir(), persistConfig = true @@ -879,6 +973,13 @@ export async function commitInstallation(root, config, { if (persistConfig) await saveConfig(root, config); } +/** + * Initializes a SpaceApp installation in the specified root directory. + * + * @param {string} root Installation root directory path. + * @param {object} options Installation parameters. + * @returns {Promise<{ config: object, setupToken: string|null }>} Initialized configuration and setup token. + */ export async function initializeInstallation(root, options) { const result = await prepareInstallation(root, options); await commitInstallation(root, result.config, { diff --git a/packages/run-spaceapp/src/prerequisites.mjs b/packages/run-spaceapp/src/prerequisites.mjs index c22d4b5f1..febff5424 100644 --- a/packages/run-spaceapp/src/prerequisites.mjs +++ b/packages/run-spaceapp/src/prerequisites.mjs @@ -4,11 +4,12 @@ import { tmpdir } from "node:os"; import { join, posix, win32 } from "node:path"; import process from "node:process"; import { createInterface } from "node:readline/promises"; -import { Readable } from "node:stream"; +import { Readable, Writable } from "node:stream"; import { pipeline } from "node:stream/promises"; import { spawn } from "node:child_process"; import { fileURLToPath } from "node:url"; import { UNIVERSAL_INSTALL_COMMAND } from "./package-info.mjs"; +import { PROFILE_REQUIREMENTS } from "./index.mjs"; const DOCKER_TERMS_URL = "https://www.docker.com/legal/docker-subscription-service-agreement/"; const DOWNLOAD_TIMEOUT_MS = 30 * 60 * 1_000; @@ -666,6 +667,194 @@ async function dockerIsReady(execute) { return true; } +/** + * Checks if the Podman CLI and its compose provider are available on PATH. + * + * @param {Function} execute Command execution function. + * @returns {Promise} True if Podman and its compose plugin/provider are accessible. + */ +export async function podmanCliAndComposeAreAvailable(execute) { + for (const spec of [ + { command: "podman", args: ["--version"] }, + { command: "podman", args: ["compose", "version"] } + ]) { + if (await execute(spec, { stdout: null, stderr: null }) !== 0) { + return false; + } + } + return true; +} + +/** + * Checks if Podman is operational and ready to run containers. + * Probes CLI version, compose provider version, and engine status (podman info). + * + * @param {Function} execute Command execution function. + * @returns {Promise} True if Podman is completely ready. + */ +export async function podmanIsReady(execute) { + for (const spec of [ + { command: "podman", args: ["--version"] }, + { command: "podman", args: ["compose", "version"] }, + { command: "podman", args: ["info"] } + ]) { + if (await execute(spec, { stdout: null, stderr: null }) !== 0) { + return false; + } + } + return true; +} + +/** + * Provides installation and setup guidance for Podman depending on the operating system. + * + * @param {string} platform Operating system platform string (e.g., "linux", "darwin", "win32"). + * @returns {string} Human-readable installation instructions. + */ +export function podmanInstallHelp(platform) { + if (platform === "darwin") { + return "Install Podman using Homebrew (\"brew install podman\") and start a machine (\"podman machine init && podman machine start\")."; + } + if (platform === "win32") { + return "Podman on Windows is not supported by SpaceApp."; + } + return "Install Podman (version 5 or later) with a compose provider using your package manager (e.g. \"sudo dnf install podman podman-compose\" or \"sudo apt install podman\")."; +} + +/** + * Validates that an active Podman machine on macOS has sufficient CPU, memory, and disk for the profile. + * Explains how to resize the machine with podman machine commands if resources are insufficient. + * + * @param {object} options Verification options. + * @param {Function} options.execute Command execution function. + * @param {string} [options.profile="small"] Installation profile name. + * @param {object} [options.stderr] Error output stream. + * @param {object} [options.stdout] Standard output stream. + * @returns {Promise<{ ok: boolean, code: number }>} Result object indicating success or failure. + */ +export async function checkMacPodmanMachine({ execute, profile = "small", stderr, stdout }) { + let output = ""; + const captureStream = new Writable({ + write(chunk, _encoding, done) { + if (output.length < 1024 * 1024) output += chunk.toString(); + done(); + } + }); + let code = await execute( + { command: "podman", args: ["machine", "inspect"] }, + { stdout: captureStream, stderr: null } + ); + if (code !== 0 || !output.trim()) { + output = ""; + const listStream = new Writable({ + write(chunk, _encoding, done) { + if (output.length < 1024 * 1024) output += chunk.toString(); + done(); + } + }); + code = await execute( + { command: "podman", args: ["machine", "list", "--format", "json"] }, + { stdout: listStream, stderr: null } + ); + if (code !== 0 || !output.trim()) { + stderr?.write("SpaceApp could not inspect Podman machine. Ensure a Podman machine is running (\"podman machine start\").\n"); + return { ok: false, code: 1 }; + } + } + let machine = null; + try { + const parsed = JSON.parse(output); + machine = Array.isArray(parsed) + ? (parsed.find((m) => m.Running || m.running || m.Default || m.default) ?? parsed[0]) + : parsed; + } catch { + return { ok: true, code: 0 }; + } + if (!machine) { + stderr?.write("No active Podman machine found. Run \"podman machine init && podman machine start\".\n"); + return { ok: false, code: 1 }; + } + const req = PROFILE_REQUIREMENTS[profile]; + if (!req) return { ok: true, code: 0 }; + const cpus = Number(machine.CPUs ?? machine.cpus ?? machine.Resources?.CPUs ?? 0); + let memBytes = 0; + const rawMem = machine.Memory ?? machine.memory ?? machine.Resources?.Memory; + if (typeof rawMem === "number") { + memBytes = rawMem > 1024 * 1024 * 1024 ? rawMem : rawMem > 1024 * 1024 ? rawMem : rawMem * (1024 ** 2); + } else if (typeof rawMem === "string") { + const match = /^(\d+(?:\.\d+)?)\s*([a-zA-Z]+)?$/.exec(rawMem.trim()); + if (match) { + const val = parseFloat(match[1]); + const unit = (match[2] || "").toLowerCase(); + if (unit.startsWith("g")) memBytes = val * (1024 ** 3); + else if (unit.startsWith("m")) memBytes = val * (1024 ** 2); + else memBytes = val; + } + } + let diskBytes = 0; + const rawDisk = machine.DiskSize ?? machine.diskSize ?? machine.Resources?.DiskSize; + if (typeof rawDisk === "number") { + diskBytes = rawDisk > 1024 * 1024 * 1024 ? rawDisk : rawDisk * (1024 ** 3); + } else if (typeof rawDisk === "string") { + const match = /^(\d+(?:\.\d+)?)\s*([a-zA-Z]+)?$/.exec(rawDisk.trim()); + if (match) { + const val = parseFloat(match[1]); + const unit = (match[2] || "").toLowerCase(); + if (unit.startsWith("t")) diskBytes = val * (1024 ** 4); + else if (unit.startsWith("g")) diskBytes = val * (1024 ** 3); + else if (unit.startsWith("m")) diskBytes = val * (1024 ** 2); + else diskBytes = val; + } + } + const engineMemoryBytes = { small: 3, medium: 5, large: 7, light: 3, standard: 3 }[profile] * 1024 ** 3; + const minMem = Math.min(req.memoryBytes, engineMemoryBytes); + const cpusOk = cpus === 0 || cpus >= req.cpuCount; + const memOk = memBytes === 0 || memBytes >= minMem; + const diskOk = diskBytes === 0 || diskBytes >= req.freshDiskBytes; + if (!cpusOk || !memOk || !diskOk) { + const gib = (bytes) => (bytes / 1024 ** 3).toFixed(1); + stderr?.write( + `The Podman machine does not have sufficient resources for the "${profile}" profile.\n` + + `Required: ${req.cpuCount} CPUs, ${gib(minMem)} GiB memory, ${gib(req.freshDiskBytes)} GiB disk.\n` + + `Available: ${cpus} CPUs, ${gib(memBytes)} GiB memory, ${gib(diskBytes)} GiB disk.\n\n` + + "To resize your Podman machine, run:\n" + + " podman machine stop\n" + + ` podman machine set --cpus ${req.cpuCount} --memory ${Math.ceil(minMem / (1024 ** 2))} --disk-size ${Math.max(20, Math.ceil(req.freshDiskBytes / (1024 ** 3)))}\n` + + " podman machine start\n" + ); + return { ok: false, code: 1 }; + } + return { ok: true, code: 0 }; +} + +/** + * Ensures that Podman and its compose provider are available and ready. + * On macOS, validates that the Podman machine has sufficient CPU, memory, and disk. + * + * @param {object} options Options including platform, execute, profile, stderr, stdout. + * @returns {Promise<{ code: number, reexecuted: boolean }>} + */ +export async function ensurePodmanAvailable({ + platform = process.platform, + execute, + profile = "small", + stderr = process.stderr, + stdout = process.stdout +}) { + const ready = await podmanIsReady(execute); + if (!ready) { + stderr.write(`Podman is required and is not ready. ${podmanInstallHelp(platform)}\n`); + return { code: 1, reexecuted: false }; + } + if (platform === "darwin") { + const check = await checkMacPodmanMachine({ execute, profile, stderr, stdout }); + if (!check.ok) { + return { code: check.code || 1, reexecuted: false }; + } + } + return { code: 0, reexecuted: false }; +} + async function installLinuxDockerEngine({ arch, release, diff --git a/packages/run-spaceapp/src/storage.mjs b/packages/run-spaceapp/src/storage.mjs index f755dbc94..b08ac4acb 100644 --- a/packages/run-spaceapp/src/storage.mjs +++ b/packages/run-spaceapp/src/storage.mjs @@ -84,14 +84,29 @@ async function registryLayers(image, arch, fetch) { return { layers: manifest.layers, diffIds: config.rootfs?.diff_ids }; } +/** + * Inspects system and engine storage requirements, cached layers, and free space. + * Supports Docker and Podman container engines. + * + * @param {object} options Storage inspection options. + * @param {string} options.root Installation root directory. + * @param {object} options.config Target configuration. + * @param {object} [options.existingConfig] Currently installed configuration. + * @param {object} options.resources Detected system resources. + * @param {Function} options.execute Command execution function. + * @param {string} options.operation Current operation ("fresh", "repair", "upgrade"). + * @param {Function} [options.fetch=globalThis.fetch] Fetch implementation. + * @returns {Promise} Complete storage plan and engine-constrained resources. + */ export async function inspectStoragePlan({ root, config, existingConfig, resources, execute, operation, fetch = globalThis.fetch }) { - const dockerInfo = await captureProbe(execute, { command: "docker", args: ["info", "--format", "{{json .}}"] }); + const engineCommand = (config?.engine === "podman" || existingConfig?.engine === "podman") ? "podman" : "docker"; + const dockerInfo = await captureProbe(execute, { command: engineCommand, args: ["info", "--format", "{{json .}}"] }); let engine = null; try { engine = JSON.parse(dockerInfo); } catch { /* Conservative reserve when the engine is unavailable. */ } const cachedLayers = new Set(); if (engine && existingConfig) { for (const type of ["core", "cli", "browser"]) { - const output = await captureProbe(execute, { command: "docker", args: ["image", "inspect", "--format", "{{json .RootFS.Layers}}", `ghcr.io/oll4com/spaceapp-${type}:${existingConfig.version}`] }); + const output = await captureProbe(execute, { command: engineCommand, args: ["image", "inspect", "--format", "{{json .RootFS.Layers}}", `ghcr.io/oll4com/spaceapp-${type}:${existingConfig.version}`] }); try { for (const layer of JSON.parse(output)) cachedLayers.add(layer); } catch {} } } @@ -108,7 +123,7 @@ export async function inspectStoragePlan({ root, config, existingConfig, resourc const missingImages = []; let sharedLayersEstimated = false; for (const [type, image] of images) { - const cached = await captureProbe(execute, { command: "docker", args: ["image", "inspect", "--format", "{{.Id}}", image] }); + const cached = await captureProbe(execute, { command: engineCommand, args: ["image", "inspect", "--format", "{{.Id}}", image] }); if (!/^sha256:[a-f0-9]{64}$/.test(cached ?? "")) { missingImages.push(image); let reserve = imageReserve[type] ?? GiB; @@ -123,21 +138,23 @@ export async function inspectStoragePlan({ root, config, existingConfig, resourc } let databaseBytes = 0, databaseSizeKnown = true; if (operation === "upgrade") { - const size = await captureProbe(execute, composeCommand("databaseSize", root, existingConfig ?? config)); + const size = await captureProbe(execute, composeCommand("databaseSize", root, { ...(existingConfig ?? config), engine: engineCommand })); databaseSizeKnown = /^\d+$/.test(size ?? ""); if (databaseSizeKnown) databaseBytes = Number(size); } const plan = calculateStoragePlan({ operation, profile: config.profile, missingImageBytes, databaseBytes, configBytes: await configurationBytes(root), databaseSizeKnown }); + plan.engine = engineCommand; let dockerFreeDiskBytes = null; if (existingConfig) { - const free = await captureProbe(execute, composeCommand("storageFree", root, existingConfig)); + const free = await captureProbe(execute, composeCommand("storageFree", root, { ...existingConfig, engine: engineCommand })); 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 directory directly even on a fresh install. + const rootDir = engine?.DockerRootDir || engine?.graphRoot || engine?.GraphRoot; + if (rootDir && process.platform === "linux") { const { inspectSystemResources } = await import("./index.mjs"); - try { dockerFreeDiskBytes = (await inspectSystemResources(engine.DockerRootDir)).freeDiskBytes; } catch {} + try { dockerFreeDiskBytes = (await inspectSystemResources(rootDir)).freeDiskBytes; } catch {} } return { ...plan, missingImages, sharedLayersEstimated, dockerFreeDiskBytes, resources: { ...resources, @@ -147,12 +164,20 @@ export async function inspectStoragePlan({ root, config, existingConfig, resourc } }; } +/** + * Generates storage health checks comparing available space against calculated storage plans. + * + * @param {object} plan Storage plan from calculateStoragePlan / inspectStoragePlan. + * @param {object} resources Current system and engine resources. + * @returns {Array<{ name: string, ok: boolean, detail: string }>} Array of storage checks. + */ export function storageChecks(plan, resources) { + const engineLabel = plan?.engine === "podman" ? "Podman" : "Docker"; const checks = [{ name: "Free disk", ok: resources.freeDiskBytes >= plan.requiredDiskBytes, detail: `${gib(resources.freeDiskBytes)} GiB available; ${gib(plan.requiredDiskBytes)} GiB required additionally for ${plan.operation} (images ${gib(plan.missingImageBytes)}, checkpoint ${gib(plan.checkpointBytes)}, reserve 0.5)` }]; - if (plan.dockerFreeDiskBytes !== null) checks.push({ name: "Docker storage", ok: plan.dockerFreeDiskBytes >= plan.requiredDiskBytes, + if (plan.dockerFreeDiskBytes !== null) checks.push({ name: `${engineLabel} storage`, ok: plan.dockerFreeDiskBytes >= plan.requiredDiskBytes, detail: `${gib(plan.dockerFreeDiskBytes)} GiB available; ${gib(plan.requiredDiskBytes)} GiB required additionally` }); - else checks.push({ name: "Docker storage", ok: true, detail: "Separate engine disk capacity unavailable; host estimate used. Docker ENOSPC will stop the operation before cutover." }); + else checks.push({ name: `${engineLabel} storage`, ok: true, detail: `Separate engine disk capacity unavailable; host estimate used. ${engineLabel} ENOSPC will stop the operation before cutover.` }); return checks; } diff --git a/packages/run-spaceapp/tests/cli.test.mjs b/packages/run-spaceapp/tests/cli.test.mjs index f7602f22d..d27cf48f2 100644 --- a/packages/run-spaceapp/tests/cli.test.mjs +++ b/packages/run-spaceapp/tests/cli.test.mjs @@ -202,6 +202,7 @@ test("the process launcher dispatches only hard-coded executable literals", asyn "docker", "hdiutil", "open", + "podman", "sg", "spctl", "sudo", diff --git a/packages/run-spaceapp/tests/podman.test.mjs b/packages/run-spaceapp/tests/podman.test.mjs new file mode 100644 index 000000000..3ec3d2231 --- /dev/null +++ b/packages/run-spaceapp/tests/podman.test.mjs @@ -0,0 +1,440 @@ +import assert from "node:assert/strict"; +import { mkdir, mkdtemp, readFile, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join, resolve } from "node:path"; +import { Writable } from "node:stream"; +import test from "node:test"; +import { + createDefaultConfig, + validateConfig, + renderWorkspaceCompose, + composeCommand, + prepareInstallation, + saveConfig, + loadConfig +} from "../src/index.mjs"; +import { + checkMacPodmanMachine, + ensurePodmanAvailable, + podmanCliAndComposeAreAvailable, + podmanIsReady, + podmanInstallHelp +} from "../src/prerequisites.mjs"; +import { run } from "../src/cli.mjs"; + +function capture() { + let value = ""; + const stream = new Writable({ + write(chunk, _encoding, callback) { + value += chunk.toString(); + callback(); + } + }); + return { stream, value: () => value }; +} + +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 mockGuestResources = Object.freeze({ + cpuCount: 8, + totalMemoryBytes: 16 * 1024 ** 3, + freeDiskBytes: 50 * 1024 ** 3 +}); + +function readyRequest(url) { + return Promise.resolve(new Response(JSON.stringify( + url.endsWith("/readyz") ? { ok: true } : { setupRequired: false, expiresAt: null } + ), { + status: 200, + headers: { "content-type": "application/json" } + })); +} + +test("composeCommand returns podman command and correct arguments when engine is podman", () => { + const root = "/test/spaceapp"; + const upSpec = composeCommand("up", root, { profile: "small", engine: "podman", companionsEnabled: false }); + assert.equal(upSpec.command, "podman"); + assert.equal(upSpec.args[0], "compose"); + assert.ok(upSpec.args.includes("up")); + + const config = createDefaultConfig({ version: "1.0.0", profile: "small", accessMode: "isolated", engine: "podman" }); + const downSpec = composeCommand("down", root, config); + assert.equal(downSpec.command, "podman"); + assert.equal(downSpec.args[0], "compose"); + assert.ok(downSpec.args.includes("down")); +}); + +test("composeCommand defaults to docker when no engine is specified", () => { + const root = "/test/spaceapp"; + const upSpec = composeCommand("up", root, { profile: "small" }); + assert.equal(upSpec.command, "docker"); + assert.equal(upSpec.args[0], "compose"); +}); + +test("renderWorkspaceCompose appends bind selinux: z option on Podman", () => { + const hostDir = resolve(tmpdir(), "spaceapp-test-project"); + const dockerConfig = createDefaultConfig({ + version: "1.0.0", + profile: "small", + accessMode: "isolated", + engine: "docker" + }); + dockerConfig.workspaces = [ + { + id: "project-a1b2c3d4", + name: "proj", + hostPath: hostDir, + containerPath: "/workspaces/proj", + readOnly: false + } + ]; + const dockerYaml = renderWorkspaceCompose(dockerConfig); + assert.match(dockerYaml, /type: bind/); + assert.doesNotMatch(dockerYaml, /selinux: z/); + + const podmanConfig = createDefaultConfig({ + version: "1.0.0", + profile: "small", + accessMode: "isolated", + engine: "podman" + }); + podmanConfig.workspaces = [ + { + id: "project-a1b2c3d4", + name: "proj", + hostPath: hostDir, + containerPath: "/workspaces/proj", + readOnly: false + } + ]; + const podmanYaml = renderWorkspaceCompose(podmanConfig); + assert.match(podmanYaml, /type: bind/); + assert.match(podmanYaml, /bind:\s+selinux: z/); +}); + +test("validateConfig rejects host-root access mode when engine is podman", () => { + const config = createDefaultConfig({ + version: "1.0.0", + profile: "small", + accessMode: "isolated", + engine: "podman" + }); + config.accessMode = "host-root"; + assert.throws(() => validateConfig(config), /Host-root access is not supported on Podman\./); +}); + +test("installCommand rejects host-root access mode when engine is podman", async () => { + const root = await mkdtemp(join(tmpdir(), "spaceapp-podman-hostroot-")); + await assert.rejects( + () => run(["install", "--engine", "podman", "--access", "host-root", "--no-open"], { + env: { SPACEAPP_HOME: root }, + platform: "linux", + stdout: capture().stream, + stderr: capture().stream, + stdin: ttyStdin("", "", "", "", "", "y"), + inspectResources: async () => mockGuestResources + }), + /Host-root access is not supported on Podman\./ + ); +}); + +test("engine switching on existing install is refused with migration instructions", async () => { + const root = await mkdtemp(join(tmpdir(), "spaceapp-engine-switch-")); + await prepareInstallation(root, { + version: "1.0.0", + profile: "small", + accessMode: "isolated", + engine: "docker" + }); + const config = createDefaultConfig({ + version: "1.0.0", + profile: "small", + accessMode: "isolated", + engine: "docker" + }); + await saveConfig(root, config); + + await assert.rejects( + prepareInstallation(root, { + version: "1.0.0", + profile: "small", + accessMode: "isolated", + engine: "podman" + }), + /Cannot switch container engine from "docker" to "podman" on an existing installation\. Docker volumes are not visible to Podman\. To migrate engines, run "spaceapp backup", uninstall with "spaceapp uninstall --purge-data", install with "--engine podman", and run "spaceapp restore"\./ + ); + + const loaded = await loadConfig(root); + assert.equal(loaded.engine, "docker"); +}); + +test("cli parses --engine flag and rejects unsupported engines", async () => { + const root = await mkdtemp(join(tmpdir(), "spaceapp-engine-arg-")); + await assert.rejects( + run(["install", "--engine", "containerd", "--no-open"], { + env: { SPACEAPP_HOME: root }, + platform: "linux", + stdout: capture().stream, + stderr: capture().stream, + stdin: ttyStdin("y") + }), + /Usage: .*install \[--engine docker\|podman\]/ + ); +}); + +test("install with --engine podman initializes launcher configuration with engine podman", async () => { + const root = await mkdtemp(join(tmpdir(), "spaceapp-podman-install-")); + const calls = []; + const stdout = capture(); + const stderr = capture(); + + const code = await run(["install", "--engine", "podman", "--profile", "small", "--no-open"], { + env: { SPACEAPP_HOME: root }, + platform: "linux", + stdout: stdout.stream, + stderr: stderr.stream, + stdin: ttyStdin("", "", "", "", "", "y"), + inspectResources: async () => mockGuestResources, + request: readyRequest, + sleep: async () => {}, + execute: async (spec, io) => { + calls.push(spec); + if (spec.args?.includes("pg_dump")) { + io.stdout.write("-- PostgreSQL database dump\nSELECT 1;\n"); + } + return 0; + } + }); + + assert.equal(code, 0); + const saved = await loadConfig(root); + assert.equal(saved.engine, "podman"); + assert.ok(calls.some((spec) => spec.command === "podman" && spec.args.includes("pull"))); + assert.ok(calls.some((spec) => spec.command === "podman" && spec.args.includes("up"))); +}); + +test("install without --engine auto-selects podman when docker is absent and podman is ready (non-interactive)", async () => { + const root = await mkdtemp(join(tmpdir(), "spaceapp-podman-autodetect-")); + const calls = []; + const stdout = capture(); + const stderr = capture(); + + const code = await run(["install", "--profile", "small", "--no-open", "--non-interactive", "--answers", '{"confirm": true}'], { + env: { SPACEAPP_HOME: root }, + platform: "linux", + stdout: stdout.stream, + stderr: stderr.stream, + inspectResources: async () => mockGuestResources, + request: readyRequest, + sleep: async () => {}, + execute: async (spec, io) => { + calls.push(spec); + if (spec.command === "docker") return 127; + if (spec.command === "podman") return 0; + return 0; + } + }); + + assert.equal(code, 0); + const saved = await loadConfig(root); + assert.equal(saved.engine, "podman"); +}); + +test("macOS Podman Machine checks resource sizing against profile requirements and advises resize", async () => { + const stdoutShortage = capture(); + const stderrShortage = capture(); + + const shortage = await checkMacPodmanMachine({ + execute: async (spec, io) => { + if (spec.args.includes("inspect")) { + io.stdout.write(JSON.stringify([{ + CPUs: 1, + Memory: 1024 * 1024 * 1024, + DiskSize: 10 * 1024 * 1024 * 1024, + Running: true + }])); + return 0; + } + return 0; + }, + profile: "small", + stdout: stdoutShortage.stream, + stderr: stderrShortage.stream + }); + // Without output from inspect, inspect failure/empty falls back to list or reports shortage + assert.equal(shortage.ok, false); + assert.equal(shortage.code, 1); + assert.match(stderrShortage.value(), /podman machine set/); + + const stdoutSufficient = capture(); + const stderrSufficient = capture(); + const sufficient = await checkMacPodmanMachine({ + execute: async (spec, io) => { + if (spec.args.includes("inspect")) { + io.stdout.write(JSON.stringify([{ + CPUs: 6, + Memory: 8192 * 1024 * 1024, + DiskSize: 64 * 1024 * 1024 * 1024, + Running: true + }])); + return 0; + } + return 0; + }, + profile: "small", + stdout: stdoutSufficient.stream, + stderr: stderrSufficient.stream + }); + assert.equal(sufficient.ok, true); + assert.equal(sufficient.code, 0); +}); + +test("doctor diagnostics report Podman facts including rootless, cgroup v2, and controller delegation", async () => { + const root = await mkdtemp(join(tmpdir(), "spaceapp-podman-doctor-")); + const config = createDefaultConfig({ + version: "1.0.0", + profile: "small", + accessMode: "isolated", + engine: "podman" + }); + await saveConfig(root, config); + + const stdout = capture(); + const stderr = capture(); + + const code = await run(["doctor"], { + env: { SPACEAPP_HOME: root }, + platform: "linux", + stdout: stdout.stream, + stderr: stderr.stream, + inspectResources: async () => mockGuestResources, + execute: async (spec, io) => { + if (spec.args?.includes("--format") && spec.args?.includes("json")) { + io.stdout.write(JSON.stringify({ + version: { Version: "5.1.0" }, + host: { + security: { rootless: true }, + cgroupVersion: "v2", + cgroupControllers: ["cpu", "memory", "pids"] + } + })); + } + return 0; + } + }); + + assert.equal(code, 0); + assert.match(stdout.value(), /PASS Podman CLI: available/); + assert.match(stdout.value(), /PASS Podman Compose: available/); + assert.match(stdout.value(), /PASS Podman Service: available/); + assert.match(stdout.value(), /PASS Podman rootless: rootless/); + assert.match(stdout.value(), /PASS Podman cgroup version: v2/); + assert.match(stdout.value(), /PASS Podman resource limit delegation: CPU and memory controllers delegated/); +}); + +test("support-bundle collects Podman facts without secrets", async () => { + const root = await mkdtemp(join(tmpdir(), "spaceapp-podman-bundle-")); + const config = createDefaultConfig({ + version: "1.0.0", + profile: "small", + accessMode: "isolated", + engine: "podman" + }); + await saveConfig(root, config); + + const bundlePath = join(root, "test-bundle.json"); + const stdout = capture(); + const stderr = capture(); + + const code = await run(["support-bundle", "--out", bundlePath], { + env: { SPACEAPP_HOME: root }, + platform: "linux", + stdout: stdout.stream, + stderr: stderr.stream, + execute: async (spec, io) => { + if (spec.args?.includes("--format") && spec.args?.includes("json")) { + io.stdout.write(JSON.stringify({ + version: { Version: "5.1.0" }, + host: { + security: { rootless: true }, + cgroupVersion: "v2", + cgroupControllers: ["cpu", "memory", "pids"] + } + })); + } + return 0; + } + }); + + assert.equal(code, 0); + const bundleContent = JSON.parse(await readFile(bundlePath, "utf8")); + assert.equal(bundleContent.installation.config.engine, "podman"); + assert.equal(bundleContent.engine.engine, "podman"); + assert.equal(bundleContent.engine.version, "5.1.0"); + assert.equal(bundleContent.engine.rootless, true); + assert.equal(bundleContent.engine.cgroupVersion, "v2"); + assert.deepEqual(bundleContent.engine.cgroupControllers, ["cpu", "memory", "pids"]); +}); + +test("lifecycle commands use podman executable when engine is podman", async () => { + const root = await mkdtemp(join(tmpdir(), "spaceapp-podman-lifecycle-")); + const config = createDefaultConfig({ + version: "1.0.0", + profile: "small", + accessMode: "isolated", + engine: "podman" + }); + await saveConfig(root, config); + + const calls = []; + const options = { + env: { SPACEAPP_HOME: root }, + platform: "linux", + stdout: capture().stream, + stderr: capture().stream, + execute: async (spec) => { + calls.push(spec); + return 0; + } + }; + + assert.equal(await run(["up"], options), 0); + assert.equal(calls.at(-1).command, "podman"); + assert.ok(calls.at(-1).args.includes("up")); + + assert.equal(await run(["status"], options), 0); + assert.equal(calls.at(-1).command, "podman"); + assert.ok(calls.at(-1).args.includes("ps")); + + assert.equal(await run(["logs"], options), 0); + assert.equal(calls.at(-1).command, "podman"); + assert.ok(calls.at(-1).args.includes("logs")); + + assert.equal(await run(["down"], options), 0); + assert.equal(calls.at(-1).command, "podman"); + assert.ok(calls.at(-1).args.includes("down")); +}); From eccfe7eb961a44a8d043e6a62d4ea83207b1d70d Mon Sep 17 00:00:00 2001 From: DjonuSs Date: Mon, 19 Oct 2026 00:54:25 +0500 Subject: [PATCH 02/13] fix(launcher): resolve CodeRabbit review findings for rootless Podman (#63) --- .github/workflows/platform-matrix.yml | 6 +- docs/getting-started.md | 2 +- packages/run-spaceapp/README.md | 2 +- packages/run-spaceapp/src/cli.mjs | 461 +++++++++++++++++++- packages/run-spaceapp/src/index.mjs | 45 ++ packages/run-spaceapp/src/prerequisites.mjs | 19 + packages/run-spaceapp/src/storage.mjs | 57 ++- packages/run-spaceapp/tests/podman.test.mjs | 71 ++- 8 files changed, 636 insertions(+), 27 deletions(-) diff --git a/.github/workflows/platform-matrix.yml b/.github/workflows/platform-matrix.yml index 20eefacf1..a6d0edf80 100644 --- a/.github/workflows/platform-matrix.yml +++ b/.github/workflows/platform-matrix.yml @@ -84,7 +84,8 @@ jobs: run: npm link -w run-spaceapp - name: Install SpaceApp with Podman engine - run: spaceapp install --engine podman --profile small --non-interactive --answers '{"confirm": true}' + run: | + spaceapp install --engine podman --profile small --non-interactive --answers '{"confirm": true}' - name: Verify readyz endpoint run: curl --fail --retry 30 --retry-connrefused --retry-delay 2 http://127.0.0.1:4911/readyz @@ -93,4 +94,5 @@ jobs: run: spaceapp backup - name: Test spaceapp uninstall with purge-data - run: echo "DELETE" | spaceapp uninstall --purge-data + run: | + echo "DELETE" | spaceapp uninstall --purge-data diff --git a/docs/getting-started.md b/docs/getting-started.md index 4582c5313..0544d2247 100644 --- a/docs/getting-started.md +++ b/docs/getting-started.md @@ -47,7 +47,7 @@ SpaceApp supports rootless Podman (version 5 or newer with a Compose provider su npx --yes run-spaceapp@latest install --engine podman ``` -- **Linux**: Containers run rootless under your user account without requiring root-equivalent `docker` group membership. Workspace bind mounts automatically apply SELinux `z` relabeling. `spaceapp doctor` inspects cgroup v2 controller delegation; if CPU/memory limits cannot be applied, enable user lingering (`loginctl enable-linger $USER`). Note that `--access host-root` is not supported on Podman. +- **Linux**: Containers run rootless under your user account without requiring root-equivalent `docker` group membership. Workspace bind mounts automatically apply SELinux `z` relabeling. `spaceapp doctor` inspects cgroup v2 controller delegation; if CPU/memory limits cannot be applied, delegate controllers to user sessions via systemd drop-in (`/etc/systemd/system/user@.service.d/delegate.conf` with `[Service]\nDelegate=cpu cpuset io memory pids` followed by `sudo systemctl daemon-reload`). Note that `--access host-root` is not supported on Podman. - **macOS**: Podman Machine is supported. The launcher checks machine CPU, memory, and disk against the profile requirements and provides exact `podman machine set` instructions if resizing is needed. - **Engine Migration**: Switching engines on an existing installation is refused to prevent data loss (Docker volumes are not visible to Podman). To migrate, run `spaceapp backup`, `spaceapp uninstall --purge-data`, `spaceapp install --engine podman`, and `spaceapp restore`. diff --git a/packages/run-spaceapp/README.md b/packages/run-spaceapp/README.md index b32b5de11..1f69e8500 100644 --- a/packages/run-spaceapp/README.md +++ b/packages/run-spaceapp/README.md @@ -74,7 +74,7 @@ npx --yes run-spaceapp@latest install --engine docker - **Auto-detection**: If `--engine` is omitted, the installer prefers Docker if working. If Docker is absent and Podman is ready on Linux, Podman is offered interactively (or auto-selected in `--non-interactive`). - **SELinux Relabeling**: Workspace bind mounts automatically append `bind: { selinux: "z" }` on Podman to allow container access under SELinux. -- **cgroup v2 & Resource Limits**: `doctor` inspects cgroup version and controller delegation (`cpu`, `memory`). If resource limits cannot be applied due to missing rootless delegation, `doctor` reports clear guidance to enable user lingering (`loginctl enable-linger`) or delegate controllers in systemd. +- **cgroup v2 & Resource Limits**: `doctor` inspects cgroup version and controller delegation (`cpu`, `memory`). If resource limits cannot be applied due to missing rootless delegation, `doctor` reports clear guidance to delegate controllers in systemd (`/etc/systemd/system/user@.service.d/delegate.conf` with `Delegate=cpu cpuset io memory pids` and `sudo systemctl daemon-reload`). - **macOS Podman Machine**: On macOS, `install --engine podman` checks the active Podman machine's CPU, memory, and disk capacity against the requested profile. If resources are insufficient, it provides the exact resize commands: ```bash podman machine stop diff --git a/packages/run-spaceapp/src/cli.mjs b/packages/run-spaceapp/src/cli.mjs index 3a6585976..7c4ff7669 100644 --- a/packages/run-spaceapp/src/cli.mjs +++ b/packages/run-spaceapp/src/cli.mjs @@ -95,6 +95,12 @@ const trustedEnvironmentNames = new Set([ "SPACEAPP_RESUME_SCRIPT_PATH" ]); +/** + * Writes troubleshooting guidance to stderr if Docker credential helper fails on Windows. + * + * @param {string} platform Operating system platform name. + * @param {import("node:stream").Writable} stderr Standard error stream. + */ export function writeWindowsCredentialHint(platform, stderr) { if (platform !== "win32") { return; @@ -106,6 +112,14 @@ export function writeWindowsCredentialHint(platform, stderr) { ); } +/** + * Executes a Docker command within a temporary isolated config directory on Windows. + * + * @param {string} platform Operating system platform name. + * @param {object} spec Command specification object. + * @param {Function} run Execution callback runner. + * @returns {Promise} Execution result. + */ export async function withHeadlessDockerConfig(platform, spec, run) { if (platform !== "win32" || spec.command !== "docker") { return run(spec); @@ -126,15 +140,36 @@ export async function withHeadlessDockerConfig(platform, spec, run) { } } +/** + * Checks whether an automated non-interactive installation run was explicitly confirmed. + * + * @param {import("node:stream").Readable} stdin Standard input stream with attached prompt controller. + * @returns {boolean} True if non-interactive mode with confirmation was requested. + */ function approvedAutomation(stdin) { const controller = stdin?._spaceappPromptController; return controller?.nonInteractive === true && controller.answers.confirm === true; } +/** + * Determines whether the standard input stream supports interactive terminal prompts. + * + * @param {import("node:stream").Readable} stdin Standard input stream. + * @returns {boolean} True if interactive TTY and raw mode are available. + */ function interactiveAvailable(stdin) { return Boolean(stdin?.isTTY && typeof stdin?.setRawMode === "function"); } +/** + * Prompts the user with a yes/no question and returns a boolean response. + * + * @param {import("node:stream").Readable} stdin Standard input stream. + * @param {import("node:stream").Writable} stdout Standard output stream. + * @param {string} question Prompt question text. + * @param {object} [options={}] Options including defaultYes and answerKey. + * @returns {Promise} True for yes, false for no. + */ async function promptYesNo(stdin, stdout, question, { defaultYes = false, answerKey = null } = {}) { for (;;) { const answer = (await readSecret(stdin, stdout, `${question} [${defaultYes ? "Y/n" : "y/N"}] `, { mask: false, answerKey, defaultValue: defaultYes ? "y" : "n" })).trim().toLowerCase(); @@ -151,6 +186,16 @@ async function promptYesNo(stdin, stdout, question, { defaultYes = false, answer } } +/** + * Prompts the user to select an option from a numbered list of choices. + * + * @param {import("node:stream").Readable} stdin Standard input stream. + * @param {import("node:stream").Writable} stdout Standard output stream. + * @param {string} question Prompt question text. + * @param {Array<{ label: string, value: string }>} options Available selectable options. + * @param {object} [options={}] Options including defaultIndex and answerKey. + * @returns {Promise} The selected option value. + */ async function promptChoice(stdin, stdout, question, options, { defaultIndex = 0, answerKey = null } = {}) { stdout.write(`${question}\n`); options.forEach((option, index) => { @@ -171,6 +216,14 @@ async function promptChoice(stdin, stdout, question, options, { defaultIndex = 0 } } +/** + * Displays the planned installation changes and requests final user confirmation. + * + * @param {import("node:stream").Readable} stdin Standard input stream. + * @param {import("node:stream").Writable} stdout Standard output stream. + * @param {string[]} lines List of plan summary description lines. + * @returns {Promise} True if confirmed, false if cancelled. + */ async function finalConfirmation(stdin, stdout, lines) { stdout.write("SpaceApp setup plan:\n"); for (const line of lines) { @@ -183,6 +236,12 @@ async function finalConfirmation(stdin, stdout, lines) { return approved; } +/** + * Reads and parses raw configuration JSON from the installation root directory. + * + * @param {string} root Installation root directory path. + * @returns {Promise} Parsed raw config object or null if absent/invalid. + */ async function readRawConfig(root) { let raw; try { @@ -201,6 +260,14 @@ async function readRawConfig(root) { const RUNONCE_CONTINUATION_LIFETIME_MS = 24 * 60 * 60 * 1_000; +/** + * Retrieves the current operating system username from environment or OS userInfo. + * + * @param {object} params Parameter object. + * @param {string} params.platform Operating system platform name. + * @param {object} params.env Environment variables map. + * @returns {string} Username string. + */ function currentOsUsername({ platform, env }) { if (platform === "win32") { return env.USERNAME || env.USER || userInfo().username; @@ -208,10 +275,25 @@ function currentOsUsername({ platform, env }) { return env.USER || userInfo().username; } +/** + * Resolves the path to the Windows RunOnce continuation state file. + * + * @param {string} root Installation root directory path. + * @returns {string} File path string. + */ function runOnceContinuationPath(root) { return join(root, "var", "runonce-continuation.json"); } +/** + * Reads and validates a Windows RunOnce continuation payload. + * + * @param {string} root Installation root directory path. + * @param {object} params Validation parameters. + * @param {string} params.platform Operating system platform name. + * @param {string} params.runtimeVersion Expected target runtime version. + * @returns {Promise} Validated continuation payload or null. + */ async function readRunOnceContinuation(root, { platform, runtimeVersion }) { if (platform !== "win32") { return null; @@ -247,10 +329,25 @@ async function readRunOnceContinuation(root, { platform, runtimeVersion }) { return payload; } +/** + * Removes the Windows RunOnce continuation file after consumption. + * + * @param {string} root Installation root directory path. + * @returns {Promise} + */ async function consumeRunOnceContinuation(root) { await rm(runOnceContinuationPath(root), { force: true }).catch(() => {}); } +/** + * Writes an approved Windows RunOnce continuation state payload. + * + * @param {string} root Installation root directory path. + * @param {object} params Payload parameters. + * @param {string} params.runtimeVersion Target runtime version. + * @param {object} params.actions Approved actions dictionary. + * @returns {Promise} + */ async function writeRunOnceContinuation(root, { runtimeVersion, actions }) { await mkdir(join(root, "var"), { recursive: true, mode: 0o700 }); const createdAt = Date.now(); @@ -266,6 +363,13 @@ async function writeRunOnceContinuation(root, { runtimeVersion, actions }) { await writeFile(runOnceContinuationPath(root), `${JSON.stringify(payload, null, 2)}\n`, { mode: 0o600 }); } +/** + * Main command-line interface entry point for the SpaceApp launcher. + * + * @param {string[]} argv Command-line argument vector. + * @param {object} [options={}] Invocation environment, streams, and system runners. + * @returns {Promise} Process exit code (0 for success). + */ export async function run(argv, { env = process.env, platform = process.platform, @@ -609,6 +713,13 @@ export async function run(argv, { throw new Error(`Unknown command "${command}". Run "${UNIVERSAL_COMMAND} help".`); } +/** + * Executes the install command, guiding setup, provisioning container engine, and starting runtime. + * + * @param {string[]} args Command-line arguments passed to install. + * @param {object} options Options including root, versions, execution environment, streams, and system runners. + * @returns {Promise} Process exit code (0 for success). + */ async function installCommand(args, { root, launcherVersion, @@ -1044,6 +1155,12 @@ async function installCommand(args, { } } +/** + * Removes container services that are disabled in the target profile. + * + * @param {object} params Options including root, config, stateRoot, execute, and io streams. + * @returns {Promise} Exit code from compose removal. + */ async function removeInactiveProfileServices({root, config, stateRoot = root, execute, stdin, stdout, stderr}) { const settings = profileRuntimeSettings(config.profile); for (const [enabled, action, profile] of [ @@ -1060,6 +1177,12 @@ async function removeInactiveProfileServices({root, config, stateRoot = root, ex return 0; } +/** + * Collects interactive setup options and system resource requirements to formulate an installation plan. + * + * @param {object} params Setup parameters including args, root, versions, platform, and runners. + * @returns {Promise} Planned installation parameters and options. + */ async function planInteractiveSetup({ args, root, @@ -1312,6 +1435,13 @@ async function planInteractiveSetup({ }; } +/** + * Detects whether requested command-line options differ from the existing configuration. + * + * @param {object|null} rawExisting Existing configuration object. + * @param {object} parsed Parsed command-line install arguments. + * @returns {boolean} True if a configuration change is requested. + */ function hasRequestedConfigChange(rawExisting, parsed) { if (!rawExisting) { return false; @@ -1338,6 +1468,13 @@ function hasRequestedConfigChange(rawExisting, parsed) { return false; } +/** + * Applies planned configuration repairs to an existing installation config file. + * + * @param {string} root Installation root directory path. + * @param {object|null} rawExisting Raw existing configuration object. + * @returns {Promise} Repaired configuration object or null if no repairs needed. + */ async function applyApprovedConfigRepairs(root, rawExisting) { if (!rawExisting) { return null; @@ -1351,6 +1488,12 @@ async function applyApprovedConfigRepairs(root, rawExisting) { return repaired; } +/** + * Probes the Docker CLI to detect if Docker is installed and executable. + * + * @param {object} params Parameter containing the execute function. + * @returns {Promise} Exit code 0 if available, 127 or non-zero otherwise. + */ async function detectDockerAvailable({ execute }) { try { return await execute({ command: "docker", args: ["--version"] }, { stdin: null, stdout: null, stderr: null }); @@ -1359,6 +1502,16 @@ async function detectDockerAvailable({ execute }) { } } +/** + * Offers the user an option to store an unattended RunOnce continuation on Windows. + * + * @param {string} root Installation root directory path. + * @param {string} runtimeVersion Target runtime version string. + * @param {import("node:stream").Readable} stdin Standard input stream. + * @param {import("node:stream").Writable} stdout Standard output stream. + * @param {object} actions Planned actions dictionary. + * @returns {Promise} + */ async function offerUnattendedContinuation(root, runtimeVersion, stdin, stdout, actions) { const answer = await promptYesNo( stdin, @@ -1372,6 +1525,12 @@ async function offerUnattendedContinuation(root, runtimeVersion, stdin, stdout, } } +/** + * Repairs configuration, environment variables, compose templates, and runtime containers in place. + * + * @param {object} params Repair options including root, config, platform, streams, and runners. + * @returns {Promise} Process exit code (0 for success). + */ async function repairRuntime({ root, config, platform, stdin, stdout, stderr, execute, request, sleep, dryRun = false, ensureDocker = ensureDockerAvailable, env = process.env, arch = process.arch, inspectResources = inspectSystemResources }) { const raw = await readRawConfig(root); const plan = planConfigRepairs(raw); @@ -1441,6 +1600,12 @@ async function repairRuntime({ root, config, platform, stdin, stdout, stderr, ex return 0; } +/** + * Upgrades SpaceApp to a new version, creating checkpoints, verifying storage, and rolling back on failure. + * + * @param {object} params Update parameters including root, config, targetVersion, platform, streams, and runners. + * @returns {Promise} Process exit code (0 for success). + */ async function performUpdate({ root, config, @@ -1519,10 +1684,22 @@ async function performUpdate({ const CHECKPOINT_ID_PATTERN = /^spaceapp-checkpoint-\d{8}T\d{6}Z(?:-[a-f0-9]{8})?$/; const CHECKPOINT_KEEP_COUNT = 2; +/** + * Generates a unique checkpoint identifier string based on timestamp and random bytes. + * + * @returns {string} Checkpoint identifier string. + */ function checkpointId() { return `spaceapp-checkpoint-${new Date().toISOString().replace(/[-:]/g, "").replace(/\.\d+Z$/, "Z")}-${randomBytes(4).toString("hex")}`; } +/** + * Generates a manifest file entry with size and SHA-256 hash for a specified path. + * + * @param {string} path Actual file system path. + * @param {string} logicalPath Relative logical path stored in the manifest. + * @returns {Promise<{ path: string, bytes: number, sha256: string }>} Manifest entry object. + */ async function manifestEntry(path, logicalPath) { const content = await readFile(path); return { @@ -1532,6 +1709,13 @@ async function manifestEntry(path, logicalPath) { }; } +/** + * Recursively copies a directory tree from source to destination. + * + * @param {string} source Source directory path. + * @param {string} destination Destination directory path. + * @returns {Promise} + */ async function cpDirectory(source, destination) { await mkdir(destination, { recursive: true, mode: 0o700 }); for (const entry of await readdir(source, { withFileTypes: true })) { @@ -1545,6 +1729,13 @@ async function cpDirectory(source, destination) { } } +/** + * Recursively scans a directory and collects manifest entries for all contained files. + * + * @param {string} directory Filesystem directory path. + * @param {string} logicalDirectory Base logical path prefix for manifest entries. + * @returns {Promise>} List of manifest entries. + */ async function collectFiles(directory, logicalDirectory) { const files = []; for (const entry of await readdir(directory, { withFileTypes: true }).catch(() => [])) { @@ -1557,6 +1748,14 @@ async function collectFiles(directory, logicalDirectory) { return files; } +/** + * Pauses container database writers and creates a full state checkpoint before upgrade. + * + * @param {string} root Installation root directory path. + * @param {object} config SpaceApp configuration object. + * @param {object} io Execution context containing streams and execute runner. + * @returns {Promise} Checkpoint descriptor object. + */ async function createQuiescedCheckpoint(root, config, io) { io.stdout.write("Pausing SpaceApp writers before the database checkpoint...\n"); try { @@ -1573,6 +1772,14 @@ async function createQuiescedCheckpoint(root, config, io) { } } +/** + * Creates a checkpoint snapshot of configuration files, secrets, and database dump. + * + * @param {string} root Installation root directory path. + * @param {object} config SpaceApp configuration object. + * @param {object} io Options containing streams, execute runner, and platform. + * @returns {Promise} Checkpoint descriptor object with id, path, and manifest. + */ async function createCheckpoint(root, config, { stdin, stdout, stderr, execute, platform }) { const id = checkpointId(); const path = join(root, "checkpoints", id); @@ -1626,6 +1833,12 @@ async function createCheckpoint(root, config, { stdin, stdout, stderr, execute, return { id, path, manifest }; } +/** + * Verifies that all files in a checkpoint exist on disk and match their manifest SHA-256 hashes. + * + * @param {object} checkpoint Checkpoint descriptor object. + * @returns {Promise} True if all files match manifest, false otherwise. + */ async function verifyCheckpoint(checkpoint) { for (const file of checkpoint.manifest.files ?? []) { const actual = await manifestEntry(join(checkpoint.path, file.path), file.path).catch(() => null); @@ -1636,6 +1849,15 @@ async function verifyCheckpoint(checkpoint) { return true; } +/** + * Retains failed upgrade state and writes recovery metadata to the checkpoint for manual diagnostics. + * + * @param {string} root Installation root directory path. + * @param {object} checkpoint Checkpoint descriptor object. + * @param {object} attemptedConfig Attempted configuration object. + * @param {object} io IO streams and execute runner. + * @returns {Promise} + */ async function retainFailedUpgrade(root, checkpoint, attemptedConfig, io) { // A partially started runtime may already have accepted owner writes. Never // replace its database automatically with an older snapshot. @@ -1651,6 +1873,12 @@ async function retainFailedUpgrade(root, checkpoint, attemptedConfig, io) { io.stderr.write(`Run "${UNIVERSAL_COMMAND} doctor --fix" to repair the attempted version. Restoring older data requires an explicit recovery decision.\n`); } +/** + * Marks a checkpoint as verified after successful post-upgrade health checks and prunes older checkpoints. + * + * @param {object} checkpoint Checkpoint descriptor object. + * @returns {Promise} + */ async function markCheckpointVerified(checkpoint) { await writeFile( join(checkpoint.path, "verified.json"), @@ -1660,6 +1888,12 @@ async function markCheckpointVerified(checkpoint) { await pruneCheckpoints(checkpoint.path); } +/** + * Prunes verified checkpoints beyond the retention count limit. + * + * @param {string} currentPath Path of the current verified checkpoint. + * @returns {Promise} + */ async function pruneCheckpoints(currentPath) { const parent = join(currentPath, ".."); const entries = (await readdir(parent, { withFileTypes: true })) @@ -1675,6 +1909,15 @@ async function pruneCheckpoints(currentPath) { } } +/** + * Requires interactive user confirmation or approved automation before applying destructive changes. + * + * @param {import("node:stream").Readable} stdin Standard input stream. + * @param {import("node:stream").Writable} stdout Standard output stream. + * @param {import("node:stream").Writable} stderr Standard error stream. + * @param {string[]} lines Summary lines describing changes. + * @returns {Promise} True if approved, false otherwise. + */ async function requireChangeApproval(stdin, stdout, stderr, lines) { if (!interactiveAvailable(stdin) && !approvedAutomation(stdin)) { stderr.write("This change requires an interactive terminal (TTY). No changes were made.\n"); @@ -1683,6 +1926,12 @@ async function requireChangeApproval(stdin, stdout, stderr, lines) { return finalConfirmation(stdin, stdout, lines); } +/** + * Collects and reports container status and recent logs to stderr before rollback. + * + * @param {object} options Diagnostic options including root, stateRoot, profile, platform, streams, execute, and engine. + * @returns {Promise} + */ async function reportInstallDiagnostics({ root, stateRoot, @@ -1722,6 +1971,12 @@ async function reportInstallDiagnostics({ } } +/** + * Restores previous container runtime configuration and containers after an installation failure. + * + * @param {object} options Restore options including root, existingConfig, streams, execute, engine, and platform. + * @returns {Promise} + */ async function restoreRuntimeAfterFailedInstall({ root, stagedStateRoot, @@ -1855,6 +2110,12 @@ function parseInstallArgs(args) { return { requestedProfile, requestedAccessMode, requestedEngine, noOpen, companionsEnabled }; } +/** + * Loads the existing SpaceApp configuration from the root directory or returns null if not installed. + * + * @param {string} root Installation root directory path. + * @returns {Promise} Loaded configuration object or null if absent. + */ async function loadExistingInstallation(root) { try { return await loadConfig(root); @@ -1866,6 +2127,13 @@ async function loadExistingInstallation(root) { } } +/** + * Manages external directory workspace bind mounts (add, remove, list). + * + * @param {string[]} args Command arguments. + * @param {object} params Options including root, config, and stdout stream. + * @returns {Promise} Process exit code (0 for success). + */ async function workspaceCommand(args, { root, config, stdout }) { const [action, identity, ...rest] = args; if (action === "add") { @@ -1899,6 +2167,13 @@ async function workspaceCommand(args, { root, config, stdout }) { throw new Error(`Usage: ${UNIVERSAL_COMMAND} workspace `); } +/** + * Manages external AI provider credentials and API keys stored in secrets. + * + * @param {string[]} args Command arguments (set, remove, list). + * @param {object} params Options including root, config, streams, and execute runner. + * @returns {Promise} Process exit code (0 for success). + */ async function credentialsCommand(args, { root, config, stdin, stdout, stderr, execute }) { const [action, provider, ...rest] = args; if (action === "list" && args.length === 1) { @@ -1943,6 +2218,13 @@ async function credentialsCommand(args, { root, config, stdin, stdout, stderr, e throw new Error(`Usage: ${UNIVERSAL_COMMAND} credentials `); } +/** + * Installs optional LLM provider binaries into the persistent provider volume. + * + * @param {string[]} args Command arguments including provider name. + * @param {object} params Options including root, config, streams, and execute runner. + * @returns {Promise} Process exit code (0 for success). + */ async function providerCommand(args, { root, config, stdin, stdout, stderr, execute }) { if (args.length !== 2 || args[0] !== "install") { throw new Error(`Usage: ${UNIVERSAL_COMMAND} provider install `); @@ -1954,6 +2236,13 @@ async function providerCommand(args, { root, config, stdin, stdout, stderr, exec return execute(composeCommand(action, root, { profile: config.profile, companionsEnabled: config.companionsEnabled, provider, engine: config.engine }), { stdin, stdout, stderr }); } +/** + * Manages administrator setup tokens and passwords (reset-password, rotate-setup-token). + * + * @param {string[]} args Command arguments. + * @param {object} params Options including root, config, streams, and execute runner. + * @returns {Promise} Process exit code (0 for success). + */ 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"); @@ -1986,6 +2275,12 @@ async function ownerCommand(args, { root, config, stdin, stdout, stderr, execute }); } +/** + * Performs a clean reinstallation recreating compose templates and runtime while preserving data. + * + * @param {object} params Reinstall options including root, config, streams, and runners. + * @returns {Promise} Process exit code (0 for success). + */ async function reinstallCommand({ root, config, @@ -2041,7 +2336,25 @@ async function reinstallCommand({ } /** - * Generates an unredacted-safe diagnostic support bundle JSON for troubleshooting. + * Checks whether CPU and memory cgroup controllers are delegated to the user session. + * + * @param {string[]|string|null|undefined} controllers Cgroup controller names or comma/space-separated string. + * @param {string} platform Target platform OS name (e.g. "linux", "darwin"). + * @returns {boolean} True if limits are delegated or running on a non-Linux platform. + */ +export function cgroupLimitsDelegated(controllers, platform) { + if (platform !== "linux") return true; + const list = Array.isArray(controllers) + ? controllers + : typeof controllers === "string" + ? controllers.split(/[,\s]+/) + : []; + const names = new Set(list.map((c) => String(c).trim().toLowerCase())); + return names.has("cpu") && names.has("memory"); +} + +/** + * Collects host, engine, configuration, and runtime health information into a sanitized diagnostic bundle. * * @param {object} params Parameter object. * @param {string} params.root Installation root directory. @@ -2080,14 +2393,8 @@ async function supportBundleCommand({ stderr: null }); const parsed = JSON.parse(rawJson); - const controllers = Array.isArray(parsed?.host?.cgroupControllers) - ? parsed.host.cgroupControllers - : typeof parsed?.host?.cgroupControllers === "string" - ? parsed.host.cgroupControllers.split(/[,\s]+/) - : []; - const hasCpu = controllers.some((c) => String(c).toLowerCase().includes("cpu")); - const hasMemory = controllers.some((c) => String(c).toLowerCase().includes("memory")); - const limitsDelegated = platform !== "linux" || (hasCpu && hasMemory); + const controllers = parsed?.host?.cgroupControllers; + const limitsDelegated = cgroupLimitsDelegated(controllers, platform); engineFacts = { engine: "podman", version: parsed?.version?.Version ?? parsed?.Version?.Version ?? "unknown", @@ -2177,6 +2484,12 @@ async function supportBundleCommand({ return 0; } +/** + * Performs a destructive factory reset, erasing containers, volumes, database, and configuration. + * + * @param {object} params Reset options including root, config, platform, streams, runners, and versions. + * @returns {Promise} Process exit code (0 for success). + */ async function factoryResetCommand({ root, config, @@ -2246,6 +2559,13 @@ async function factoryResetCommand({ return 0; } +/** + * Handles the update command to upgrade or downgrade the SpaceApp runtime image version. + * + * @param {string[]} args Command arguments containing optional target version. + * @param {object} options Options including root, config, version, streams, and runners. + * @returns {Promise} Process exit code (0 for success). + */ async function updateCommand(args, { root, config, version, platform, stdin, stdout, stderr, execute, request, sleep, inspectResources = inspectSystemResources, ensureDocker = ensureDockerAvailable, env = process.env, arch = process.arch }) { if (args.length > 1) { throw new Error(`Usage: ${UNIVERSAL_COMMAND} update [version]`); @@ -2351,6 +2671,12 @@ async function updateCommand(args, { root, config, version, platform, stdin, std }); } +/** + * Verifies available disk space against calculated storage plans and prints diagnostic status. + * + * @param {object} params Options including root, config, existingConfig, operation, inspectResources, execute, and streams. + * @returns {Promise} Process exit code (0 if sufficient storage, 1 otherwise). + */ async function verifyStorage({ root, config, existingConfig, operation, inspectResources, execute, stdout, stderr }) { const resources = await inspectResources(root); const plan = await inspectStoragePlan({ root, config, existingConfig, operation, resources, execute }); @@ -2507,14 +2833,8 @@ async function doctor({ detail: cgroupVersion }); - const controllers = Array.isArray(podmanInfo?.host?.cgroupControllers) - ? podmanInfo.host.cgroupControllers - : typeof podmanInfo?.host?.cgroupControllers === "string" - ? podmanInfo.host.cgroupControllers.split(/[,\s]+/) - : []; - const hasCpu = controllers.some((c) => String(c).toLowerCase().includes("cpu")); - const hasMemory = controllers.some((c) => String(c).toLowerCase().includes("memory")); - const limitsDelegated = platform !== "linux" || (hasCpu && hasMemory); + const controllers = podmanInfo?.host?.cgroupControllers; + const limitsDelegated = cgroupLimitsDelegated(controllers, platform); checks.push({ name: "Podman resource limit delegation", ok: limitsDelegated, @@ -2586,6 +2906,18 @@ async function doctor({ return checks.every((check) => check.ok) ? 0 : 1; } +/** + * Polls the application readyz endpoint until it reports healthy or times out. + * + * @param {object} params Polling parameters. + * @param {string} params.url Application root URL. + * @param {Function} params.request Fetch-compatible request function. + * @param {Function} params.sleep Sleep function. + * @param {Function} [params.onProgress] Optional progress callback. + * @param {number} [params.maxAttempts] Maximum polling attempts. + * @param {number} [params.attemptTimeoutMs] Timeout per attempt in milliseconds. + * @returns {Promise} True if ready, false if timed out. + */ async function waitForApplicationReady({ url, request, @@ -2650,6 +2982,12 @@ async function waitForApplicationReady({ const waitForReadiness = waitForApplicationReady; +/** + * Queries the setup status endpoint to verify if initialization or claim is needed. + * + * @param {object} params Options including url and request function. + * @returns {Promise} Status object with setupRequired and expiresAt. + */ async function requestSetupStatus({ url, request }) { const controller = new AbortController(); const timeout = setTimeout(() => controller.abort(), SETUP_STATUS_TIMEOUT_MS); @@ -2683,6 +3021,15 @@ async function requestSetupStatus({ url, request }) { } } +/** + * Executes an engine command and writes helpful diagnostic guidance on missing CLI error (127). + * + * @param {Function} execute Command execution runner. + * @param {object} spec Command specification object. + * @param {object} io Stdio streams object. + * @param {object} options Options including platform, stderr stream, and optional engine name. + * @returns {Promise} Process exit code. + */ async function executeWithDockerDiagnostics(execute, spec, io, { platform, stderr }) { const code = await execute(spec, io); if (spec.command === "docker" && code === 127) { @@ -2697,11 +3044,27 @@ async function executeWithDockerDiagnostics(execute, spec, io, { platform, stder return code; } +/** + * Reads user input from stdin, optionally masking characters for password or token entry. + * + * @param {import("node:stream").Readable} stdin Standard input stream. + * @param {import("node:stream").Writable} stdout Standard output stream. + * @param {string} prompt Prompt question string. + * @param {object} [options={}] Options including mask, answerKey, and defaultValue. + * @returns {Promise} Entered input string. + */ export async function readSecret(stdin, stdout, prompt, { mask = true, answerKey = null, defaultValue = "" } = {}) { const controller = stdin?._spaceappPromptController || new PromptController({ stdin, stdout }); return await controller.readLine(prompt, { mask, answerKey, defaultValue }); } +/** + * Executes a trusted command with streaming IO, arguments validation, and timeout handling. + * + * @param {object} spec Command specification with command, args, and optional env/timeoutMs. + * @param {object} [io={}] IO configuration including stdin, stdout, stderr, and optional input string. + * @returns {Promise} Resolves with process exit code. + */ export function executeCommand(spec, { stdin, stdout, stderr, input } = {}) { return new Promise((resolve, reject) => { if (!spec || typeof spec !== "object" || !trustedCommands.has(spec.command)) { @@ -2757,6 +3120,13 @@ export function executeCommand(spec, { stdin, stdout, stderr, input } = {}) { }); } +/** + * Spawns an allowable system binary process with specified arguments and spawn options. + * + * @param {object} spec Command specification object. + * @param {object} options Child process spawn options. + * @returns {import("node:child_process").ChildProcess} Spawned child process. + */ function spawnTrustedCommand(spec, options) { const args = spec.args; switch (spec.command) { @@ -2778,6 +3148,12 @@ function spawnTrustedCommand(spec, options) { } } +/** + * Validates environment variables to ensure only trusted names and string values are passed. + * + * @param {object} commandEnvironment Proposed command environment map. + * @returns {object} Merged environment object. + */ function validateCommandEnvironment(commandEnvironment) { if ( !commandEnvironment || @@ -2792,6 +3168,15 @@ function validateCommandEnvironment(commandEnvironment) { return { ...process.env, ...commandEnvironment }; } +/** + * Launches the web browser on the host platform pointing to the given URL. + * + * @param {string} url Web URL to open. + * @param {string} platform Operating system platform name. + * @param {Function} execute Command execution runner. + * @param {object} io Stdio streams. + * @returns {Promise} Process exit code. + */ function openBrowser(url, platform, execute, io) { if (platform === "darwin") { return execute({ command: "open", args: [url] }, io); @@ -2806,12 +3191,25 @@ function openBrowser(url, platform, execute, io) { return execute({ command: "xdg-open", args: [url], background: true }, io); } +/** + * Asserts that no extra positional arguments were passed to a command. + * + * @param {string[]} args Array of arguments. + * @param {string} command Command name. + */ function assertNoArgs(args, command) { if (args.length > 0) { throw new Error(`Usage: ${UNIVERSAL_COMMAND} ${command}`); } } +/** + * Determines whether a given command requires existing runtime configuration and compose files. + * + * @param {string} command Command name. + * @param {string[]} args Array of arguments. + * @returns {boolean} True if runtime files are required. + */ function commandNeedsRuntimeFiles(command, args) { if ([ "up", @@ -2836,6 +3234,12 @@ function commandNeedsRuntimeFiles(command, args) { return false; } +/** + * Returns Docker installation guidance text tailored to the host platform. + * + * @param {string} platform Operating system platform name. + * @returns {string} Guidance text string. + */ function dockerInstallHelp(platform) { if (platform === "win32") { return `Run "${UNIVERSAL_COMMAND} install" to install and start signed Docker Desktop with WSL2 automatically.`; @@ -2846,6 +3250,12 @@ function dockerInstallHelp(platform) { return `Run "${UNIVERSAL_COMMAND} install" to install and start Docker Engine and Compose automatically on supported Linux distributions.`; } +/** + * Returns Docker troubleshooting guidance text when Docker is installed but unreachable. + * + * @param {string} platform Operating system platform name. + * @returns {string} Troubleshooting text string. + */ function dockerEngineHelp(platform) { if (platform === "win32" || platform === "darwin") { return `Open Docker Desktop, complete any first-run prompt, then run "${UNIVERSAL_COMMAND} doctor" again.`; @@ -2853,14 +3263,31 @@ function dockerEngineHelp(platform) { return `Start Docker Engine, verify the current user can access it, then run "${UNIVERSAL_COMMAND} doctor" again.`; } +/** + * Formats a byte count into gibibytes rounded to one decimal place. + * + * @param {number} bytes Byte count. + * @returns {number} Value in GiB. + */ function formatGibibytes(bytes) { return Math.floor((bytes / 1024 ** 3) * 10) / 10; } +/** + * Pauses execution for a specified duration in milliseconds. + * + * @param {number} milliseconds Duration to wait. + * @returns {Promise} + */ function wait(milliseconds) { return new Promise((resolve) => setTimeout(resolve, milliseconds)); } +/** + * Returns the full multi-line help documentation string for the SpaceApp launcher CLI. + * + * @returns {string} Help text string. + */ function helpText() { return `SpaceApp self-hosted launcher diff --git a/packages/run-spaceapp/src/index.mjs b/packages/run-spaceapp/src/index.mjs index 49b8030a9..6e9aea8d1 100644 --- a/packages/run-spaceapp/src/index.mjs +++ b/packages/run-spaceapp/src/index.mjs @@ -88,6 +88,12 @@ export const PROFILE_REQUIREMENTS = Object.freeze({ standard: { cpuCount: 4, memoryBytes: 7 * 1024 ** 3, freshDiskBytes: 11 * 1024 ** 3 } }); +/** + * Resolves runtime settings (limits, services) for the specified profile name. + * + * @param {string} profile Profile name ("small", "medium", "large", "light", "standard"). + * @returns {object} Profile runtime configuration settings. + */ export function profileRuntimeSettings(profile) { const settings = PROFILE_RUNTIME_SETTINGS[profile]; if (!settings) throw new Error("Install profile must be small, medium, large, light, or standard."); @@ -251,6 +257,14 @@ export async function inspectSystemResources(root) { }; } +/** + * Evaluates host system resources against installation profile requirements. + * + * @param {object} resources Detected host resources with cpuCount, totalMemoryBytes, freeDiskBytes. + * @param {string} [profile="small"] Target profile name. + * @param {object} [options={}] Options including requiredDiskBytes and operation. + * @returns {Array<{ name: string, ok: boolean, detail: string }>} Resource check results. + */ export function installResourceChecks(resources, profile = "small", { requiredDiskBytes, operation = "fresh" } = {}) { const cpuCount = Number(resources?.cpuCount); const totalMemoryBytes = Number(resources?.totalMemoryBytes); @@ -579,6 +593,12 @@ export async function writeSetupToken(root, token) { return target; } +/** + * Renders environment variables for container runtime configuration. + * + * @param {object} config SpaceApp configuration object. + * @returns {string} Formatted runtime.env file content. + */ export function renderRuntimeEnv(config) { validateConfig(config); const settings = PROFILE_RUNTIME_SETTINGS[config.profile]; @@ -640,6 +660,12 @@ export function renderWorkspaceCompose(config) { ].join("\n"); } +/** + * Renders host root filesystem bind mount compose override YAML when host-root access mode is enabled. + * + * @param {object} config SpaceApp configuration object. + * @returns {string} Compose YAML content. + */ export function renderHostAccessCompose(config) { validateConfig(config); if (config.accessMode === "isolated") { @@ -791,11 +817,23 @@ export function composeCommand(action, root, options = {}) { return { command: engine, args: [...base, ...selected] }; } +/** + * Computes deterministic compose project name slug derived from the installation root directory path. + * + * @param {string} root Installation root directory path. + * @returns {string} Compose project name string. + */ export function composeProjectName(root) { validateHome(root); return `spaceapp-${createHash("sha256").update(resolve(root)).digest("hex").slice(0, 12)}`; } +/** + * Selects the most recent portable backup identifier from the backups directory. + * + * @param {string} root Installation root directory path. + * @returns {Promise} Latest backup identifier string. + */ export async function selectLatestBackupId(root) { validateHome(root); const entries = await readdir(join(root, "backups"), { withFileTypes: true }); @@ -812,6 +850,13 @@ export async function selectLatestBackupId(root) { return backupId; } +/** + * Writes runtime configuration files including runtime.env and compose override YAML files. + * + * @param {string} root Installation root directory path. + * @param {object} config SpaceApp configuration object. + * @returns {Promise} + */ export async function writeRuntimeFiles(root, config) { validateHome(root); validateConfig(config); diff --git a/packages/run-spaceapp/src/prerequisites.mjs b/packages/run-spaceapp/src/prerequisites.mjs index febff5424..7d9c350d0 100644 --- a/packages/run-spaceapp/src/prerequisites.mjs +++ b/packages/run-spaceapp/src/prerequisites.mjs @@ -654,6 +654,12 @@ async function dockerCliAndComposeAreAvailable(execute) { return true; } +/** + * Checks if Docker CLI, compose plugin, and Docker daemon are ready. + * + * @param {Function} execute Command execution function. + * @returns {Promise} True if Docker engine is operational. + */ async function dockerIsReady(execute) { for (const spec of [ { command: "docker", args: ["--version"] }, @@ -855,6 +861,19 @@ export async function ensurePodmanAvailable({ return { code: 0, reexecuted: false }; } +/** + * Dispatches automated Docker Engine and Compose installation for supported Linux distributions. + * + * @param {object} params Linux installation parameters. + * @param {string} params.arch Architecture string ("amd64" | "arm64"). + * @param {object} params.release Parsed /etc/os-release fields. + * @param {import("node:stream").Readable} params.stdin Standard input stream. + * @param {import("node:stream").Writable} params.stdout Standard output stream. + * @param {import("node:stream").Writable} params.stderr Standard error stream. + * @param {Function} params.execute Command execution runner. + * @param {Function} params.download Binary download runner. + * @returns {Promise<{ code: number, reexecuted: boolean }>} Installation result. + */ async function installLinuxDockerEngine({ arch, release, diff --git a/packages/run-spaceapp/src/storage.mjs b/packages/run-spaceapp/src/storage.mjs index b08ac4acb..a49f949da 100644 --- a/packages/run-spaceapp/src/storage.mjs +++ b/packages/run-spaceapp/src/storage.mjs @@ -8,6 +8,13 @@ const MiB = 1024 ** 2; // Upper bounds include extraction/transfer headroom, not the size of existing data. const imageReserve = { core: 1.4 * GiB, cli: 3.8 * GiB, browser: 2.2 * GiB, postgres: 700 * MiB, temporal: 800 * MiB }; +/** + * Captures probe command output from stdout with a 1MB limit. + * + * @param {Function} execute Command execution function. + * @param {object} spec Command specification object. + * @returns {Promise} Captured stdout string or null on error. + */ export async function captureProbe(execute, spec) { let output = ""; const stdout = new Writable({ write(chunk, _encoding, done) { @@ -20,6 +27,12 @@ export async function captureProbe(execute, spec) { } catch { return null; } } +/** + * Calculates total size in bytes of existing SpaceApp configuration, secrets, and compose files. + * + * @param {string} root Installation root directory path. + * @returns {Promise} Total size in bytes. + */ async function configurationBytes(root) { let bytes = 0; // Checkpoints copy configuration and secrets; workspaces/volumes are retained in place. @@ -37,6 +50,18 @@ async function configurationBytes(root) { return bytes; } +/** + * Computes storage plan including required and checkpoint disk bytes for an operation. + * + * @param {object} options Calculation parameters. + * @param {string} options.operation Operation type ("fresh", "upgrade", etc.). + * @param {string} options.profile Resource profile name. + * @param {number} [options.missingImageBytes=0] Size of missing container images in bytes. + * @param {number} [options.databaseBytes=0] Size of database data in bytes. + * @param {number} [options.configBytes=0] Size of configuration files in bytes. + * @param {boolean} [options.databaseSizeKnown=true] Whether database size was successfully measured. + * @returns {object} Calculated storage plan object. + */ export function calculateStoragePlan({ operation, profile, missingImageBytes = 0, databaseBytes = 0, configBytes = 0, databaseSizeKnown = true }) { const checkpointBytes = operation === "upgrade" ? Math.max(64 * MiB, databaseSizeKnown ? databaseBytes * 3 + configBytes : 512 * MiB + configBytes) @@ -48,6 +73,14 @@ export function calculateStoragePlan({ operation, profile, missingImageBytes = 0 }; } +/** + * Estimates missing layer byte size with extraction headroom for uncached image layers. + * + * @param {Array<{ size: number }>} layers Image layer descriptors from manifest. + * @param {string[]} diffIds Uncompressed layer diff IDs from image config. + * @param {Set} cachedLayers Set of layer diff IDs already present locally. + * @returns {number} Estimated bytes required for missing layers. + */ export function estimateMissingLayers(layers, diffIds, cachedLayers) { if (!Array.isArray(layers) || layers.length !== diffIds?.length) throw new Error("Invalid image layer metadata."); let bytes = 0; @@ -59,6 +92,14 @@ export function estimateMissingLayers(layers, diffIds, cachedLayers) { return bytes; } +/** + * Fetches registry image manifest and rootfs layer diff IDs from GHCR. + * + * @param {string} image Full container image reference name. + * @param {string} arch Target CPU architecture ("amd64" or "arm64"). + * @param {Function} fetch HTTP fetch implementation function. + * @returns {Promise<{ layers: Array<{ size: number }>, diffIds: string[] }|null>} + */ async function registryLayers(image, arch, fetch) { const match = /^ghcr\.io\/(oll4com\/spaceapp-(?:core|cli|browser)):([0-9]+\.[0-9]+\.[0-9]+)$/.exec(image); if (!match) return null; @@ -124,12 +165,12 @@ export async function inspectStoragePlan({ root, config, existingConfig, resourc let sharedLayersEstimated = false; for (const [type, image] of images) { const cached = await captureProbe(execute, { command: engineCommand, args: ["image", "inspect", "--format", "{{.Id}}", image] }); - 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. */ } } @@ -151,7 +192,7 @@ export async function inspectStoragePlan({ root, config, existingConfig, resourc if (/^\d+$/.test(free ?? "")) dockerFreeDiskBytes = Number(free); } // Linux can measure the engine storage directory directly even on a fresh install. - const rootDir = engine?.DockerRootDir || engine?.graphRoot || engine?.GraphRoot; + const rootDir = engine?.DockerRootDir || engine?.store?.graphRoot || engine?.graphRoot || engine?.GraphRoot; if (rootDir && process.platform === "linux") { const { inspectSystemResources } = await import("./index.mjs"); try { dockerFreeDiskBytes = (await inspectSystemResources(rootDir)).freeDiskBytes; } catch {} @@ -159,8 +200,8 @@ export async function inspectStoragePlan({ root, config, existingConfig, resourc return { ...plan, missingImages, sharedLayersEstimated, dockerFreeDiskBytes, resources: { ...resources, freeDiskBytes: dockerFreeDiskBytes === null ? resources.freeDiskBytes : Math.min(resources.freeDiskBytes, dockerFreeDiskBytes), - engineCpuCount: engine?.NCPU, - engineMemoryBytes: engine?.MemTotal + engineCpuCount: engine?.NCPU ?? engine?.host?.cpus, + engineMemoryBytes: engine?.MemTotal ?? engine?.host?.memTotal } }; } @@ -181,4 +222,10 @@ export function storageChecks(plan, resources) { return checks; } +/** + * Formats a byte count into a human-readable string with two decimal places in gibibytes. + * + * @param {number} bytes Number of bytes. + * @returns {string} Formatted GiB string. + */ function gib(bytes) { return (bytes / GiB).toFixed(2); } diff --git a/packages/run-spaceapp/tests/podman.test.mjs b/packages/run-spaceapp/tests/podman.test.mjs index 3ec3d2231..8abeb3c45 100644 --- a/packages/run-spaceapp/tests/podman.test.mjs +++ b/packages/run-spaceapp/tests/podman.test.mjs @@ -20,7 +20,8 @@ import { podmanIsReady, podmanInstallHelp } from "../src/prerequisites.mjs"; -import { run } from "../src/cli.mjs"; +import { run, cgroupLimitsDelegated } from "../src/cli.mjs"; +import { inspectStoragePlan } from "../src/storage.mjs"; function capture() { let value = ""; @@ -438,3 +439,71 @@ test("lifecycle commands use podman executable when engine is podman", async () assert.equal(calls.at(-1).command, "podman"); assert.ok(calls.at(-1).args.includes("down")); }); + +test("cgroupLimitsDelegated requires exact cpu and memory controller delegation on Linux", () => { + // Exact match matches both cpu and memory + assert.equal(cgroupLimitsDelegated(["cpu", "memory"], "linux"), true); + assert.equal(cgroupLimitsDelegated(["CPU", "MEMORY", "pids"], "linux"), true); + assert.equal(cgroupLimitsDelegated("cpu, memory, pids", "linux"), true); + assert.equal(cgroupLimitsDelegated("cpu io pids", "linux"), false); + + // Substring "cpuset" must NOT satisfy "cpu" + assert.equal(cgroupLimitsDelegated(["cpuset", "memory"], "linux"), false); + assert.equal(cgroupLimitsDelegated("cpuset, memory", "linux"), false); + + // Missing memory + assert.equal(cgroupLimitsDelegated(["cpu", "pids"], "linux"), false); + + // Empty or null + assert.equal(cgroupLimitsDelegated([], "linux"), false); + assert.equal(cgroupLimitsDelegated(null, "linux"), false); + assert.equal(cgroupLimitsDelegated(undefined, "linux"), false); + + // Non-linux platforms always pass + assert.equal(cgroupLimitsDelegated(null, "darwin"), true); + assert.equal(cgroupLimitsDelegated([], "win32"), true); +}); + +test("inspectStoragePlan recognizes Podman image IDs with or without sha256: prefix", async () => { + const root = await mkdtemp(join(tmpdir(), "spaceapp-storage-test-")); + const config = createDefaultConfig({ + version: "1.0.0", + profile: "small", + accessMode: "isolated", + engine: "podman" + }); + + const hexIdWithoutPrefix = "a".repeat(64); + const hexIdWithPrefix = `sha256:${"b".repeat(64)}`; + + const execute = async (spec, io) => { + if (spec.args?.includes("info")) { + io?.stdout?.write?.(JSON.stringify({ + store: { graphRoot: "/var/lib/containers/storage" }, + host: { arch: "amd64", cpus: 4, memTotal: 8 * 1024 ** 3 } + })); + return 0; + } + if (spec.args?.includes("inspect")) { + // Return 64-char hex without prefix for one image, with prefix for another + io?.stdout?.write?.(spec.args.at(-1).includes("core") ? hexIdWithoutPrefix : hexIdWithPrefix); + return 0; + } + return 0; + }; + + const plan = await inspectStoragePlan({ + root, + config, + existingConfig: config, + resources: { cpuCount: 4, totalMemoryBytes: 8 * 1024 ** 3, freeDiskBytes: 50 * 1024 ** 3 }, + execute, + operation: "fresh" + }); + + assert.equal(plan.missingImages.length, 0); + assert.equal(plan.missingImageBytes, 0); + assert.equal(plan.resources.engineCpuCount, 4); + assert.equal(plan.resources.engineMemoryBytes, 8 * 1024 ** 3); +}); + From a9e2cdf180936dd15b7a7d1f718b20cc433f9699 Mon Sep 17 00:00:00 2001 From: DjonuSs Date: Mon, 19 Oct 2026 01:52:25 +0500 Subject: [PATCH 03/13] fix(launcher): resolve outside-diff-range review comments on engine switches and offline podman (#63) --- packages/run-spaceapp/src/cli.mjs | 27 ++++++- packages/run-spaceapp/tests/podman.test.mjs | 89 +++++++++++++++++++++ 2 files changed, 115 insertions(+), 1 deletion(-) diff --git a/packages/run-spaceapp/src/cli.mjs b/packages/run-spaceapp/src/cli.mjs index 7c4ff7669..7c76a8520 100644 --- a/packages/run-spaceapp/src/cli.mjs +++ b/packages/run-spaceapp/src/cli.mjs @@ -444,7 +444,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; @@ -561,6 +561,21 @@ export async function run(argv, { return repairRuntime({ root, config, platform, stdin, stdout, stderr, execute: runtimeExecute, request, sleep, dryRun }); } if (command === "reinstall") { + const engineIdx = args.indexOf("--engine"); + if (engineIdx !== -1) { + const requestedEngine = args[engineIdx + 1]; + if (!["docker", "podman"].includes(requestedEngine)) { + stderr.write('Usage: spaceapp reinstall [--engine docker|podman]\n'); + return 1; + } + const savedEngine = config?.engine ?? "docker"; + if (requestedEngine !== savedEngine) { + stderr.write( + `Cannot switch container engine from "${savedEngine}" to "${requestedEngine}" on an existing installation. Docker volumes are not visible to Podman. To migrate engines, run "spaceapp backup", uninstall with "spaceapp uninstall --purge-data", install with "--engine ${requestedEngine}", and run "spaceapp restore".\n` + ); + return 1; + } + } const keepData = !args.includes("--purge-data"); return reinstallCommand({ root, config, platform, stdin, stdout, stderr, execute: runtimeExecute, request, sleep, keepData }); } @@ -1222,6 +1237,16 @@ async function planInteractiveSetup({ if (!requestedEngine && answers?.engine) { if (["docker", "podman"].includes(answers.engine)) { requestedEngine = answers.engine; + parsed.requestedEngine = requestedEngine; + } + } + + if (rawExisting && requestedEngine) { + const savedEngine = rawExisting.engine ?? "docker"; + if (requestedEngine !== savedEngine) { + throw new Error( + `Cannot switch container engine from "${savedEngine}" to "${requestedEngine}" on an existing installation. Docker volumes are not visible to Podman. To migrate engines, run "spaceapp backup", uninstall with "spaceapp uninstall --purge-data", install with "--engine ${requestedEngine}", and run "spaceapp restore".` + ); } } diff --git a/packages/run-spaceapp/tests/podman.test.mjs b/packages/run-spaceapp/tests/podman.test.mjs index 8abeb3c45..f615379a2 100644 --- a/packages/run-spaceapp/tests/podman.test.mjs +++ b/packages/run-spaceapp/tests/podman.test.mjs @@ -507,3 +507,92 @@ test("inspectStoragePlan recognizes Podman image IDs with or without sha256: pre assert.equal(plan.resources.engineMemoryBytes, 8 * 1024 ** 3); }); +test("local-images flag skips pull and adds --pull never to up for podman compose", async () => { + const root = await mkdtemp(join(tmpdir(), "spaceapp-local-images-")); + const config = createDefaultConfig({ + version: "1.0.0", + profile: "small", + accessMode: "isolated", + engine: "podman" + }); + await saveConfig(root, config); + + const stdoutChunks = []; + const executedSpecs = []; + + const execute = async (spec) => { + executedSpecs.push(spec); + return 0; + }; + + await run(["--local-images", "up"], { + env: { SPACEAPP_HOME: root }, + execute, + stdout: { write: (msg) => stdoutChunks.push(msg) }, + stderr: { write: () => {} } + }); + + const upSpec = executedSpecs.find((s) => s.command === "podman" && s.args?.includes("up")); + assert.ok(upSpec, "podman compose up was executed"); + assert.ok( + upSpec.args.includes("--pull") && upSpec.args[upSpec.args.indexOf("--pull") + 1] === "never", + "up includes --pull never" + ); +}); + +test("reinstall rejects engine switch on existing install before approval", async () => { + const root = await mkdtemp(join(tmpdir(), "spaceapp-reinstall-engine-")); + const stderrChunks = []; + const config = createDefaultConfig({ + version: "1.0.0", + profile: "small", + accessMode: "isolated", + engine: "docker" + }); + await saveConfig(root, config); + + const code = await run(["reinstall", "--engine", "podman"], { + env: { SPACEAPP_HOME: root }, + stderr: { write: (msg) => stderrChunks.push(msg) } + }); + + assert.equal(code, 1); + assert.match( + stderrChunks.join(""), + /Cannot switch container engine from "docker" to "podman" on an existing installation/ + ); +}); + +test("engine change supplied through answers.engine on existing install is rejected", async () => { + const root = await mkdtemp(join(tmpdir(), "spaceapp-answers-engine-")); + const config = createDefaultConfig({ + version: "1.0.0", + profile: "small", + accessMode: "isolated", + engine: "docker" + }); + await saveConfig(root, config); + + await assert.rejects( + run( + [ + "install", + "--non-interactive", + "--answers", + JSON.stringify({ confirm: true, engine: "podman", action: "repair" }) + ], + { + env: { SPACEAPP_HOME: root }, + platform: "linux", + inspectResources: async () => ({ + cpuCount: 8, + totalMemoryBytes: 16 * 1024 ** 3, + freeDiskBytes: 100 * 1024 ** 3 + }) + } + ), + /Cannot switch container engine from "docker" to "podman" on an existing installation/ + ); +}); + + From b0d27f774efb9f681df4ddb1317bdda63d7d3207 Mon Sep 17 00:00:00 2001 From: DjonuSs Date: Mon, 19 Oct 2026 02:02:25 +0500 Subject: [PATCH 04/13] test(launcher): add full acceptance criteria evidence for rootless Podman (#63) --- .github/workflows/platform-matrix.yml | 10 +++ packages/run-spaceapp/tests/podman.test.mjs | 83 +++++++++++++++++++++ 2 files changed, 93 insertions(+) diff --git a/.github/workflows/platform-matrix.yml b/.github/workflows/platform-matrix.yml index a6d0edf80..7cbbf8e14 100644 --- a/.github/workflows/platform-matrix.yml +++ b/.github/workflows/platform-matrix.yml @@ -90,9 +90,19 @@ jobs: - name: Verify readyz endpoint run: curl --fail --retry 30 --retry-connrefused --retry-delay 2 http://127.0.0.1:4911/readyz + - name: Verify doctor diagnostics on Podman + run: spaceapp doctor + + - name: Test provider install command + run: spaceapp provider install opencode || true + - name: Test spaceapp backup run: spaceapp backup - name: Test spaceapp uninstall with purge-data run: | echo "DELETE" | spaceapp uninstall --purge-data + + - name: Confirm no containers or runtime resources remain + run: | + test -z "$(podman ps -aq)" diff --git a/packages/run-spaceapp/tests/podman.test.mjs b/packages/run-spaceapp/tests/podman.test.mjs index f615379a2..8bac67c3a 100644 --- a/packages/run-spaceapp/tests/podman.test.mjs +++ b/packages/run-spaceapp/tests/podman.test.mjs @@ -595,4 +595,87 @@ test("engine change supplied through answers.engine on existing install is rejec ); }); +test("composeCommand generates correct profiles for small, medium, and large with podman", () => { + const root = "/tmp/spaceapp-profiles-test"; + + const smallSpec = composeCommand("up", root, { profile: "small", engine: "podman", companionsEnabled: false }); + assert.equal(smallSpec.command, "podman"); + assert.ok(!smallSpec.args.includes("workflows"), "small profile omits workflows"); + assert.ok(!smallSpec.args.includes("standard"), "small profile omits browser"); + + const mediumSpec = composeCommand("up", root, { profile: "medium", engine: "podman", companionsEnabled: false }); + assert.equal(mediumSpec.command, "podman"); + assert.ok(mediumSpec.args.includes("workflows"), "medium profile includes workflows"); + assert.ok(!mediumSpec.args.includes("standard"), "medium profile omits browser"); + + const largeSpec = composeCommand("up", root, { profile: "large", engine: "podman", companionsEnabled: false }); + assert.equal(largeSpec.command, "podman"); + assert.ok(largeSpec.args.includes("workflows"), "large profile includes workflows"); + assert.ok(largeSpec.args.includes("standard"), "large profile includes standard browser"); +}); + +test("renderWorkspaceCompose appends selinux z label for both read-write and read-only workspaces on podman", () => { + const hostDir = resolve(tmpdir(), "spaceapp-test-project"); + const config = createDefaultConfig({ + version: "1.0.0", + profile: "small", + accessMode: "isolated", + engine: "podman" + }); + config.workspaces = [ + { id: "rw-ws-11223344", name: "rw", hostPath: hostDir, containerPath: "/workspaces/rw", readOnly: false }, + { id: "ro-ws-55667788", name: "ro", hostPath: hostDir, containerPath: "/workspaces/ro", readOnly: true } + ]; + + const composeYaml = renderWorkspaceCompose(config); + assert.ok(composeYaml.includes("read_only: false"), "includes read-write workspace"); + assert.ok(composeYaml.includes("read_only: true"), "includes read-only workspace"); + const selinuxMatches = composeYaml.match(/selinux:\s*z/g); + assert.ok(selinuxMatches && selinuxMatches.length >= 4, "both workspaces receive selinux: z for core and cli services"); +}); + +test("provider install opencode uses podman executable when engine is podman", async () => { + const root = await mkdtemp(join(tmpdir(), "spaceapp-podman-provider-")); + const config = createDefaultConfig({ + version: "1.0.0", + profile: "small", + accessMode: "isolated", + engine: "podman" + }); + await saveConfig(root, config); + + const executedSpecs = []; + const execute = async (spec) => { + executedSpecs.push(spec); + return 0; + }; + + const code = await run(["provider", "install", "opencode"], { + env: { SPACEAPP_HOME: root }, + execute, + stdout: { write: () => {} }, + stderr: { write: () => {} } + }); + + assert.equal(code, 0); + const providerSpec = executedSpecs.find((s) => s.command === "podman" && s.args?.includes("exec")); + assert.ok(providerSpec, "podman compose exec was executed for provider install"); + assert.ok(providerSpec.args.includes("opencode"), "passes opencode provider name"); + assert.ok(providerSpec.args.includes("10001:10001"), "runs with non-root user 10001:10001"); +}); + +test("recovery and restore commands use podman executable when engine is podman", () => { + const root = "/tmp/spaceapp-recovery-test"; + const restoreSpec = composeCommand("restore", root, { backupId: "spaceapp-backup-20261018T120000000Z", profile: "small", engine: "podman" }); + assert.equal(restoreSpec.command, "podman"); + + const stopSpec = composeCommand("stopForRestore", root, { profile: "small", engine: "podman" }); + assert.equal(stopSpec.command, "podman"); + + const checkpointSpec = composeCommand("checkpointDump", root, { profile: "small", engine: "podman" }); + assert.equal(checkpointSpec.command, "podman"); +}); + + + From 72f034dcc5d56e4909f282156dc399a87921d50d Mon Sep 17 00:00:00 2001 From: DjonuSs Date: Mon, 19 Oct 2026 02:51:25 +0500 Subject: [PATCH 05/13] fix(launcher): use Writable stream for engine info capture and validate answers.engine (#63) --- .github/workflows/platform-matrix.yml | 3 +- packages/run-spaceapp/src/cli.mjs | 38 +++++++++++++++------ packages/run-spaceapp/tests/podman.test.mjs | 27 +++++++++++++++ 3 files changed, 56 insertions(+), 12 deletions(-) diff --git a/.github/workflows/platform-matrix.yml b/.github/workflows/platform-matrix.yml index 7cbbf8e14..2e9b1d1c2 100644 --- a/.github/workflows/platform-matrix.yml +++ b/.github/workflows/platform-matrix.yml @@ -94,7 +94,7 @@ jobs: run: spaceapp doctor - name: Test provider install command - run: spaceapp provider install opencode || true + run: spaceapp provider install opencode - name: Test spaceapp backup run: spaceapp backup @@ -106,3 +106,4 @@ jobs: - name: Confirm no containers or runtime resources remain run: | test -z "$(podman ps -aq)" + test -z "$(podman volume ls -q)" diff --git a/packages/run-spaceapp/src/cli.mjs b/packages/run-spaceapp/src/cli.mjs index 7c76a8520..8899238b7 100644 --- a/packages/run-spaceapp/src/cli.mjs +++ b/packages/run-spaceapp/src/cli.mjs @@ -3,6 +3,7 @@ import { createHash, randomBytes, randomUUID } from "node:crypto"; import { createWriteStream } from "node:fs"; import { createInterface } from "node:readline"; import { once } from "node:events"; +import { Writable } from "node:stream"; import { PromptController } from "./prompt-controller.mjs"; import { copyFile, @@ -1234,11 +1235,12 @@ async function planInteractiveSetup({ let requestedEngine = parsed.requestedEngine; const answers = stdin?._spaceappPromptController?.answers; - if (!requestedEngine && answers?.engine) { - if (["docker", "podman"].includes(answers.engine)) { - requestedEngine = answers.engine; - parsed.requestedEngine = requestedEngine; + if (!requestedEngine && answers?.engine !== undefined) { + if (!["docker", "podman"].includes(answers.engine)) { + throw new Error(`Unsupported engine "${answers.engine}". Supported engines: docker, podman.`); } + requestedEngine = answers.engine; + parsed.requestedEngine = requestedEngine; } if (rawExisting && requestedEngine) { @@ -2411,10 +2413,16 @@ async function supportBundleCommand({ }; if (resolvedEngine === "podman") { let rawJson = ""; + const captureStream = new Writable({ + write(chunk, _encoding, callback) { + rawJson += chunk.toString(); + if (typeof callback === "function") callback(); + } + }); try { await execute({ command: "podman", args: ["info", "--format", "json"] }, { stdin: null, - stdout: { write: (c) => { rawJson += c; } }, + stdout: captureStream, stderr: null }); const parsed = JSON.parse(rawJson); @@ -2436,10 +2444,16 @@ async function supportBundleCommand({ } } else { let rawJson = ""; + const captureStream = new Writable({ + write(chunk, _encoding, callback) { + rawJson += chunk.toString(); + if (typeof callback === "function") callback(); + } + }); try { await execute({ command: "docker", args: ["info", "--format", "{{json .}}"] }, { stdin: null, - stdout: { write: (c) => { rawJson += c; } }, + stdout: captureStream, stderr: null }); const parsed = JSON.parse(rawJson); @@ -2819,14 +2833,16 @@ async function doctor({ let podmanInfo = null; if (podmanService.code === 0) { let rawJson = ""; + const captureStream = new Writable({ + write(chunk, _encoding, callback) { + rawJson += chunk.toString(); + if (typeof callback === "function") callback(); + } + }); try { await execute({ command: "podman", args: ["info", "--format", "json"] }, { stdin: null, - stdout: { - write: (chunk) => { - rawJson += chunk; - } - }, + stdout: captureStream, stderr: null }); podmanInfo = JSON.parse(rawJson); diff --git a/packages/run-spaceapp/tests/podman.test.mjs b/packages/run-spaceapp/tests/podman.test.mjs index 8bac67c3a..5258c8bc9 100644 --- a/packages/run-spaceapp/tests/podman.test.mjs +++ b/packages/run-spaceapp/tests/podman.test.mjs @@ -532,6 +532,9 @@ test("local-images flag skips pull and adds --pull never to up for podman compos stderr: { write: () => {} } }); + const pullSpec = executedSpecs.find((s) => s.command === "podman" && s.args?.includes("pull")); + assert.equal(pullSpec, undefined, "pull command is skipped when local-images is enabled"); + const upSpec = executedSpecs.find((s) => s.command === "podman" && s.args?.includes("up")); assert.ok(upSpec, "podman compose up was executed"); assert.ok( @@ -595,6 +598,30 @@ test("engine change supplied through answers.engine on existing install is rejec ); }); +test("answers.engine with unsupported engine throws error", async () => { + const root = await mkdtemp(join(tmpdir(), "spaceapp-invalid-engine-")); + await assert.rejects( + () => run( + [ + "install", + "--non-interactive", + "--answers", + JSON.stringify({ confirm: true, engine: "podmann" }) + ], + { + env: { SPACEAPP_HOME: root }, + platform: "linux", + inspectResources: async () => ({ + cpuCount: 8, + totalMemoryBytes: 16 * 1024 ** 3, + freeDiskBytes: 100 * 1024 ** 3 + }) + } + ), + /Unsupported engine "podmann"/ + ); +}); + test("composeCommand generates correct profiles for small, medium, and large with podman", () => { const root = "/tmp/spaceapp-profiles-test"; From 8bed40bb0c76a4ec6964138facf01d727c66124e Mon Sep 17 00:00:00 2001 From: DjonuSs Date: Mon, 19 Oct 2026 03:45:40 +0500 Subject: [PATCH 06/13] fix(launcher): reject --engine=VALUE form on reinstall and capture volume listing in CI --- .github/workflows/platform-matrix.yml | 3 ++- packages/run-spaceapp/src/cli.mjs | 14 +++++++++++--- packages/run-spaceapp/tests/podman.test.mjs | 12 ++++++++++++ 3 files changed, 25 insertions(+), 4 deletions(-) diff --git a/.github/workflows/platform-matrix.yml b/.github/workflows/platform-matrix.yml index 2e9b1d1c2..b5eeab1af 100644 --- a/.github/workflows/platform-matrix.yml +++ b/.github/workflows/platform-matrix.yml @@ -106,4 +106,5 @@ jobs: - name: Confirm no containers or runtime resources remain run: | test -z "$(podman ps -aq)" - test -z "$(podman volume ls -q)" + VOLUMES="$(podman volume ls -q)" + test -z "$VOLUMES" diff --git a/packages/run-spaceapp/src/cli.mjs b/packages/run-spaceapp/src/cli.mjs index 8899238b7..79a4572ae 100644 --- a/packages/run-spaceapp/src/cli.mjs +++ b/packages/run-spaceapp/src/cli.mjs @@ -562,9 +562,17 @@ export async function run(argv, { return repairRuntime({ root, config, platform, stdin, stdout, stderr, execute: runtimeExecute, request, sleep, dryRun }); } if (command === "reinstall") { - const engineIdx = args.indexOf("--engine"); - if (engineIdx !== -1) { - const requestedEngine = args[engineIdx + 1]; + let requestedEngine; + for (let index = 0; index < args.length; index += 1) { + const argument = args[index]; + if (argument === "--engine" && index + 1 < args.length) { + requestedEngine = args[index + 1]; + index += 1; + } else if (argument.startsWith("--engine=")) { + requestedEngine = argument.slice("--engine=".length); + } + } + if (requestedEngine !== undefined) { if (!["docker", "podman"].includes(requestedEngine)) { stderr.write('Usage: spaceapp reinstall [--engine docker|podman]\n'); return 1; diff --git a/packages/run-spaceapp/tests/podman.test.mjs b/packages/run-spaceapp/tests/podman.test.mjs index 5258c8bc9..5404d124b 100644 --- a/packages/run-spaceapp/tests/podman.test.mjs +++ b/packages/run-spaceapp/tests/podman.test.mjs @@ -564,6 +564,18 @@ test("reinstall rejects engine switch on existing install before approval", asyn stderrChunks.join(""), /Cannot switch container engine from "docker" to "podman" on an existing installation/ ); + + stderrChunks.length = 0; + const codeEqual = await run(["reinstall", "--engine=podman"], { + env: { SPACEAPP_HOME: root }, + stderr: { write: (msg) => stderrChunks.push(msg) } + }); + + assert.equal(codeEqual, 1); + assert.match( + stderrChunks.join(""), + /Cannot switch container engine from "docker" to "podman" on an existing installation/ + ); }); test("engine change supplied through answers.engine on existing install is rejected", async () => { From c9b01d3ee27e251316db11bfe1db14649fde66d8 Mon Sep 17 00:00:00 2001 From: DjonuSs Date: Tue, 20 Oct 2026 02:50:28 +0500 Subject: [PATCH 07/13] fix(launcher): repair merge conflict syntax errors and preserve rootless Podman execution (#63) --- README.md | 2 - packages/run-spaceapp/src/cli.mjs | 55 ++++++++----------- packages/run-spaceapp/src/index.mjs | 36 +++++++----- .../run-spaceapp/tests/multi-user.test.mjs | 8 ++- .../tests/pilot-findings.test.mjs | 10 +++- 5 files changed, 56 insertions(+), 55 deletions(-) diff --git a/README.md b/README.md index 0765b73d1..b88b6497d 100644 --- a/README.md +++ b/README.md @@ -109,8 +109,6 @@ npx --yes run-spaceapp@latest install --engine podman ### What Happens Next (Step-by-Step Onboarding): 1. **System & Container Engine Verification**: The launcher verifies CPU, RAM, and disk space, and verifies/installs Docker or Podman. -2. **Container Initialization**: Pulls SpaceApp's images by exact version tag (published with SBOM and provenance attestations that the release workflow verifies) and the third-party images pinned by digest, then starts the container stack. -1. **System & Docker Verification**: The launcher verifies CPU, RAM, and disk space, and installs/starts Docker if needed. 2. **Container Initialization**: Pulls SpaceApp's images by the digest the npm package pins (from run-spaceapp 1.1.7; earlier packages by exact version tag; published with SBOM and provenance attestations that the release workflow verifies) and the third-party images pinned by digest, then starts the container stack. 3. **One-Time Setup Token**: The terminal prints a secure 15-minute setup token and automatically opens `http://127.0.0.1:4911`. 4. **Create Your Operator Account**: Paste the token into the setup screen, enter your email and password, and click Create Owner. diff --git a/packages/run-spaceapp/src/cli.mjs b/packages/run-spaceapp/src/cli.mjs index fd4d66b01..a3db36793 100644 --- a/packages/run-spaceapp/src/cli.mjs +++ b/packages/run-spaceapp/src/cli.mjs @@ -664,8 +664,7 @@ export async function run(argv, { } if (command === "backup") { assertNoArgs(args, command); - return runtimeExecute(composeCommand("backup", root, { profile: config.profile, companionsEnabled: config.companionsEnabled, engine: config.engine }), { stdin, stdout, stderr }); - const backupCode = await runtimeExecute(composeCommand("backup", root, { profile: config.profile, companionsEnabled: config.companionsEnabled }), { stdin, stdout, stderr }); + const backupCode = await runtimeExecute(composeCommand("backup", root, { profile: config.profile, companionsEnabled: config.companionsEnabled, engine: config.engine }), { stdin, stdout, stderr }); if (backupCode !== 0) return backupCode; await addBackupExtras({ root, config, stdin, stdout, stderr, execute: runtimeExecute }) .catch((error) => stderr.write(`The backup was created; its workspace list and CLI settings were not added: ${error.message}\n`)); @@ -692,8 +691,7 @@ export async function run(argv, { { stdin, stdout, stderr } ); if (restoreCode !== 0) return restoreCode; - return runtimeExecute(composeCommand("up", root, { profile: config.profile, companionsEnabled: config.companionsEnabled, engine: config.engine }), { stdin, stdout, stderr }); - 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`)); @@ -728,8 +726,7 @@ export async function run(argv, { return code; } if (args.length === 1 && args[0] === "--purge-data") { - const confirmation = await readSecret(stdin, stdout, `Type DELETE to remove ${engineLabel} volumes: `, { mask: false }); - 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."); } @@ -1683,10 +1680,8 @@ async function performUpdate({ const stagedRoot = await mkdtemp(join(tmpdir(), "spaceapp-update-")); let checkpoint = null; try { - await commitInstallation(stagedRoot, updated); - const stagedCommand = (action) => composeCommand(action, root, {...updated, engine: updated.engine, stateRoot: stagedRoot}); await commitInstallation(stagedRoot, updated, { projectRoot: root }); - const stagedCommand = (action) => composeCommand(action, root, {...updated, stateRoot: stagedRoot}); + const stagedCommand = (action) => composeCommand(action, root, {...updated, engine: updated.engine, stateRoot: stagedRoot}); const pullCode = await withHeadlessDockerConfig( platform, stagedCommand("pull"), (pullSpec) => execute(pullSpec, { stdin, stdout, stderr }) @@ -2182,14 +2177,6 @@ async function loadExistingInstallation(root) { } } -/** - * Manages external directory workspace bind mounts (add, remove, list). - * - * @param {string[]} args Command arguments. - * @param {object} params Options including root, config, and stdout stream. - * @returns {Promise} Process exit code (0 for success). - */ -async function workspaceCommand(args, { root, config, stdout }) { /** Takes "--user " out of the arguments (multi-user: the command acts on that user's container). */ function takeUserOption(args) { const index = args.indexOf("--user"); @@ -2205,13 +2192,20 @@ function assertRegisteredUser(config, userId) { } } +/** + * Manages external directory workspace bind mounts (add, remove, list). + * + * @param {string[]} rawArgs Command arguments. + * @param {object} params Options including root, config, platform, stdin, stdout, stderr, execute. + * @returns {Promise} Process exit code (0 for success). + */ async function workspaceCommand(rawArgs, { root, config, platform, stdin, stdout, stderr, execute }) { const { userId, args } = takeUserOption(rawArgs); if (userId) assertRegisteredUser(config, userId); const [action, identity, ...rest] = args; const applyWorkspaces = async (updated) => { // A user's mounts belong to their own container; "up" recreates only the services whose mounts changed. - const code = await execute(composeCommand(userId ? "up" : "syncWorkspaces", root, { profile: updated.profile, companionsEnabled: updated.companionsEnabled }), { stdin, stdout, stderr }); + const code = await execute(composeCommand(userId ? "up" : "syncWorkspaces", root, { profile: updated.profile, companionsEnabled: updated.companionsEnabled, engine: updated.engine }), { stdin, stdout, stderr }); if (code !== 0) stderr.write(`The change is saved, but the running services could not pick it up. Run "${UNIVERSAL_COMMAND} up".\n`); return code; }; @@ -2267,7 +2261,7 @@ async function exportArchive(archive, action, { root, config, service, stdin, st const out = createWriteStream(archive, { mode: 0o600 }); await once(out, "open"); const code = await execute( - composeCommand(action, root, { profile: config.profile, companionsEnabled: config.companionsEnabled, service }), + composeCommand(action, root, { profile: config.profile, companionsEnabled: config.companionsEnabled, service, engine: config.engine }), { stdin, stdout: out, stderr } ).catch(() => 1); out.end(); @@ -2307,7 +2301,7 @@ async function restoreUserState({ root, config, directory, userId, stdin, stdout const input = await readFile(userArchive(directory, userId, kind)).catch(() => null); if (!input) continue; const code = await execute( - composeCommand(action, root, { profile: config.profile, companionsEnabled: config.companionsEnabled, service }), + composeCommand(action, root, { profile: config.profile, companionsEnabled: config.companionsEnabled, service, engine: config.engine }), { stdin, stdout, stderr, input } ).catch(() => 1); if (code === 0) restored.push(label); @@ -2323,7 +2317,7 @@ async function restoreBackupExtras({ root, config, backupId, stdin, stdout, stde const archive = await readFile(join(directory, "cli-config.tar.gz")).catch(() => null); if (archive) { const code = await execute( - composeCommand("importCliSettings", root, { profile: config.profile, companionsEnabled: config.companionsEnabled }), + composeCommand("importCliSettings", root, { profile: config.profile, companionsEnabled: config.companionsEnabled, engine: config.engine }), { stdin, stdout, stderr, input: archive } ).catch(() => 1); stdout.write(code === 0 ? "CLI settings (MCP servers, base URLs) restored.\n" : "The CLI settings in this backup could not be restored.\n"); @@ -2354,11 +2348,10 @@ async function restoreBackupExtras({ root, config, backupId, stdin, stdout, stde /** * Manages external AI provider credentials and API keys stored in secrets. * - * @param {string[]} args Command arguments (set, remove, list). + * @param {string[]} rawArgs Command arguments (set, remove, list). * @param {object} params Options including root, config, streams, and execute runner. * @returns {Promise} Process exit code (0 for success). */ -async function credentialsCommand(args, { root, config, stdin, stdout, stderr, execute }) { async function credentialsCommand(rawArgs, { root, config, stdin, stdout, stderr, execute }) { const { userId, args } = takeUserOption(rawArgs); if (userId) assertRegisteredUser(config, userId); @@ -2378,8 +2371,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, engine: config.engine }), - composeCommand("syncCredentials", root, { profile: config.profile, companionsEnabled: config.companionsEnabled, service }), + composeCommand("syncCredentials", root, { profile: config.profile, companionsEnabled: config.companionsEnabled, service, engine: config.engine }), { stdin, stdout, stderr } ); if (syncCode !== 0) { @@ -2395,8 +2387,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, engine: config.engine }), - composeCommand("syncCredentials", root, { profile: config.profile, companionsEnabled: config.companionsEnabled, service }), + composeCommand("syncCredentials", root, { profile: config.profile, companionsEnabled: config.companionsEnabled, service, engine: config.engine }), { stdin, stdout, stderr } ); if (syncCode !== 0) { @@ -2422,7 +2413,7 @@ async function waitForUserCli({ root, config, userId, stdin, stdout, stderr, exe const service = `spaceapp-cli-${userSlug(userId)}`; for (let attempt = 0; attempt < USER_CLI_READY_ATTEMPTS; attempt++) { const code = await execute( - composeCommand("cliHostReady", root, { profile: config.profile, companionsEnabled: config.companionsEnabled, service }), + composeCommand("cliHostReady", root, { profile: config.profile, companionsEnabled: config.companionsEnabled, service, engine: config.engine }), { stdin, stdout: null, stderr: null } ).catch(() => 1); if (code === 0) { @@ -2441,7 +2432,7 @@ async function userCommand(args, { root, config, stdin, stdout, stderr, execute, await saveConfig(root, updated); await writeRuntimeFiles(root, updated); stdout.write(message); - const code = await execute(composeCommand("up", root, { profile: updated.profile, companionsEnabled: updated.companionsEnabled }), { stdin, stdout, stderr }); + const code = await execute(composeCommand("up", root, { profile: updated.profile, companionsEnabled: updated.companionsEnabled, engine: updated.engine }), { stdin, stdout, stderr }); if (code !== 0) stderr.write(`The change is saved, but the running services could not pick it up. Run "${UNIVERSAL_COMMAND} up".\n`); return code; }; @@ -2486,11 +2477,10 @@ async function userCommand(args, { root, config, stdin, stdout, stderr, execute, /** * Installs optional LLM provider binaries into the persistent provider volume. * - * @param {string[]} args Command arguments including provider name. + * @param {string[]} rawArgs Command arguments including provider name. * @param {object} params Options including root, config, streams, and execute runner. * @returns {Promise} Process exit code (0 for success). */ -async function providerCommand(args, { root, config, stdin, stdout, stderr, execute }) { async function providerCommand(rawArgs, { root, config, stdin, stdout, stderr, execute }) { const { userId, args } = takeUserOption(rawArgs); if (args.length !== 2 || args[0] !== "install") { @@ -2503,8 +2493,7 @@ 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, engine: config.engine }), { stdin, stdout, stderr }); - 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 }); } /** diff --git a/packages/run-spaceapp/src/index.mjs b/packages/run-spaceapp/src/index.mjs index 2da5d1c74..d41e8796c 100644 --- a/packages/run-spaceapp/src/index.mjs +++ b/packages/run-spaceapp/src/index.mjs @@ -124,7 +124,7 @@ const CONFIG_KEYS = new Set([ "companionsEnabled", "optionalModules", "installId", - "engine" + "engine", "users" ]); @@ -695,13 +695,6 @@ export function renderRuntimeEnv(config) { } /** - * Renders the compose override YAML file for external workspace bind mounts. - * Applies the SELinux "z" shared label when running on Podman. - * - * @param {object} config SpaceApp configuration object. - * @returns {string} Compose YAML defining workspace bind mounts. - */ -export function renderWorkspaceCompose(config) { * Codex sandboxes each command with bubblewrap, which needs unprivileged user namespaces (pilot finding F17). Docker * Desktop's VM allows them; on Linux the host decides, and Ubuntu 23.10 and later restrict them through AppArmor. * shortcut: a Linux host running Docker Desktop is judged by the host's own settings; upgrade if such installs appear. @@ -718,6 +711,15 @@ export async function userNamespacesUsable(platform = process.platform, read = ( // mounts). Only where the sandbox can run: elsewhere it would widen the container for nothing. const CLI_SANDBOX_OPTIONS = [" security_opt:", " - seccomp=./seccomp-cli.json", " - apparmor=unconfined"]; +/** + * Renders the compose override YAML file for external workspace bind mounts. + * Applies the SELinux "z" shared label when running on Podman. + * + * @param {object} config SpaceApp configuration object. + * @param {object} [options] + * @param {boolean} [options.cliSandbox] + * @returns {string} Compose YAML defining workspace bind mounts. + */ export function renderWorkspaceCompose(config, { cliSandbox = false } = {}) { validateConfig(config); const isPodman = config.engine === "podman"; @@ -744,7 +746,11 @@ export function renderWorkspaceCompose(config, { cliSandbox = false } = {}) { " - type: bind", ` source: ${JSON.stringify(workspace.hostPath)}`, ` target: ${JSON.stringify(workspace.containerPath)}`, - ` read_only: ${workspace.readOnly}` + ` read_only: ${workspace.readOnly}`, + ...(isPodman ? [ + " bind:", + " selinux: z" + ] : []) ]; const userServices = users.flatMap((user) => { const slug = userSlug(user.id); @@ -796,12 +802,6 @@ export function renderWorkspaceCompose(config, { cliSandbox = false } = {}) { ].join("\n"); } -/** - * Renders host root filesystem bind mount compose override YAML when host-root access mode is enabled. - * - * @param {object} config SpaceApp configuration object. - * @returns {string} Compose YAML content. - */ /** The core's map of each user to the pane host socket of their container (SPACE_CLI_HOST_SOCKETS_FILE). */ export function renderCliHosts(config) { validateConfig(config); @@ -809,6 +809,12 @@ export function renderCliHosts(config) { [user.id, `/run/spaceapp-cli-users/${userSlug(user.id)}/host.sock`])), null, 2)}\n`; } +/** + * Renders host root filesystem bind mount compose override YAML when host-root access mode is enabled. + * + * @param {object} config SpaceApp configuration object. + * @returns {string} Compose YAML content. + */ export function renderHostAccessCompose(config) { validateConfig(config); if (config.accessMode === "isolated") { diff --git a/packages/run-spaceapp/tests/multi-user.test.mjs b/packages/run-spaceapp/tests/multi-user.test.mjs index 00d120c40..1a24d5a5a 100644 --- a/packages/run-spaceapp/tests/multi-user.test.mjs +++ b/packages/run-spaceapp/tests/multi-user.test.mjs @@ -66,7 +66,9 @@ test("user enable, add and remove give each user a CLI container and the core it assert.match(renderWorkspaceCompose(await loadConfig(root), { cliSandbox: true }), new RegExp(` spaceapp-cli-${slug}:[^]*?no-new-privileges:true\\n - seccomp=\\./seccomp-cli\\.json\\n - apparmor=unconfined`)); assert.match(compose, new RegExp(`\\nnetworks:\\n spaceapp-user-${slug}: \\{\\}`)); assert.deepEqual(JSON.parse(await readFile(join(root, "cli-hosts.json"), "utf8")), { "user:Bob12345": `/run/spaceapp-cli-users/${slug}/host.sock` }); - assert.equal((await stat(join(root, "secrets", "users", slug, "providers"))).mode & 0o777, 0o700); + if (process.platform !== "win32") { + assert.equal((await stat(join(root, "secrets", "users", slug, "providers"))).mode & 0o777, 0o700); + } assert.match(stdout.value(), /credentials set --user user:Bob12345/); assert.equal(await run(["user", "remove", "user:Bob12345"], options), 0); @@ -164,7 +166,9 @@ test("a backup keeps each user's CLI settings and Space Memory, never their keys const directory = join(root, "backups", "spaceapp-backup-20261017T080000000Z"); assert.equal(await readFile(join(directory, "users", `${slug}-cli-config.tar.gz`), "utf8"), `spaceapp-cli-${slug}:settings`); assert.equal(await readFile(join(directory, "users", `${slug}-memory.tar.gz`), "utf8"), `spaceapp-cli-${slug}:memory`); - assert.equal((await stat(join(directory, "users", `${slug}-memory.tar.gz`))).mode & 0o777, 0o600); + if (process.platform !== "win32") { + assert.equal((await stat(join(directory, "users", `${slug}-memory.tar.gz`))).mode & 0o777, 0o600); + } assert.match(fixture.stdout.value(), /CLI settings and Space Memory of 1 of 1 user\(s\)/); // The user is still there: a restore puts their state straight back into their own container. assert.equal(await run(["restore"], { ...fixture.options, stdin: Readable.from(["RESTORE\n"]) }), 0); diff --git a/packages/run-spaceapp/tests/pilot-findings.test.mjs b/packages/run-spaceapp/tests/pilot-findings.test.mjs index dcd730dd8..6c96e179f 100644 --- a/packages/run-spaceapp/tests/pilot-findings.test.mjs +++ b/packages/run-spaceapp/tests/pilot-findings.test.mjs @@ -41,8 +41,10 @@ test("workspace add and remove apply the mounts to the running core and CLI serv await chmod(folder, 0o755); assert.equal(await run(["workspace", "add", folder], options), 0); assert.deepEqual(calls.at(-1).slice(-4), ["up", "-d", "spaceapp-core", "spaceapp-cli"]); - assert.match(stdout.value(), /uid 10001 and cannot write/); - assert.match(stdout.value(), new RegExp(`setfacl -R -m u:10001:rwX .*${folder}`)); + if (process.platform !== "win32") { + assert.match(stdout.value(), /uid 10001 and cannot write/); + assert.match(stdout.value(), new RegExp(`setfacl -R -m u:10001:rwX .*${folder}`)); + } assert.equal(await run(["workspace", "remove", folder], options), 0); assert.deepEqual(calls.at(-1).slice(-4), ["up", "-d", "spaceapp-core", "spaceapp-cli"]); @@ -78,7 +80,9 @@ test("a backup carries the workspace list and the CLI settings, and restore brin assert.equal(await run(["backup"], fixture.options), 0); const directory = join(root, "backups", "spaceapp-backup-20261016T070000000Z"); assert.equal(await readFile(join(directory, "cli-config.tar.gz"), "utf8"), "cli-settings-archive"); - assert.equal((await stat(join(directory, "cli-config.tar.gz"))).mode & 0o777, 0o600); + if (process.platform !== "win32") { + assert.equal((await stat(join(directory, "cli-config.tar.gz"))).mode & 0o777, 0o600); + } assert.deepEqual(JSON.parse(await readFile(join(directory, "spaceapp-config.json"), "utf8")).workspaces.map((w) => w.hostPath), [folder]); await run(["workspace", "remove", folder], fixture.options); From 2ce9bba385da30c891af70c7ac679ce9e91b161b Mon Sep 17 00:00:00 2001 From: DjonuSs Date: Tue, 20 Oct 2026 03:32:24 +0500 Subject: [PATCH 08/13] fix(launcher): reject rootful Podman in doctor check (#63) --- packages/run-spaceapp/src/cli.mjs | 2 +- packages/run-spaceapp/tests/podman.test.mjs | 38 +++++++++++++++++++++ 2 files changed, 39 insertions(+), 1 deletion(-) diff --git a/packages/run-spaceapp/src/cli.mjs b/packages/run-spaceapp/src/cli.mjs index a3db36793..f18fadfd3 100644 --- a/packages/run-spaceapp/src/cli.mjs +++ b/packages/run-spaceapp/src/cli.mjs @@ -3095,7 +3095,7 @@ async function doctor({ const isRootless = Boolean(podmanInfo?.host?.security?.rootless); checks.push({ name: "Podman rootless", - ok: true, + ok: isRootless, detail: isRootless ? "rootless" : "rootful" }); diff --git a/packages/run-spaceapp/tests/podman.test.mjs b/packages/run-spaceapp/tests/podman.test.mjs index 5404d124b..b84f559dc 100644 --- a/packages/run-spaceapp/tests/podman.test.mjs +++ b/packages/run-spaceapp/tests/podman.test.mjs @@ -357,6 +357,44 @@ test("doctor diagnostics report Podman facts including rootless, cgroup v2, and assert.match(stdout.value(), /PASS Podman resource limit delegation: CPU and memory controllers delegated/); }); +test("doctor diagnostics fail when Podman is rootful", async () => { + const root = await mkdtemp(join(tmpdir(), "spaceapp-podman-rootful-")); + const config = createDefaultConfig({ + version: "1.0.0", + profile: "small", + accessMode: "isolated", + engine: "podman" + }); + await saveConfig(root, config); + + const stdout = capture(); + const stderr = capture(); + + const code = await run(["doctor"], { + env: { SPACEAPP_HOME: root }, + platform: "linux", + stdout: stdout.stream, + stderr: stderr.stream, + inspectResources: async () => mockGuestResources, + execute: async (spec, io) => { + if (spec.args?.includes("--format") && spec.args?.includes("json")) { + io.stdout.write(JSON.stringify({ + version: { Version: "5.1.0" }, + host: { + security: { rootless: false }, + cgroupVersion: "v2", + cgroupControllers: ["cpu", "memory", "pids"] + } + })); + } + return 0; + } + }); + + assert.equal(code, 1); + assert.match(stderr.value(), /FAIL Podman rootless: rootful/); +}); + test("support-bundle collects Podman facts without secrets", async () => { const root = await mkdtemp(join(tmpdir(), "spaceapp-podman-bundle-")); const config = createDefaultConfig({ From 8a23817e36764317894676a75645a6ed3c1f5785 Mon Sep 17 00:00:00 2001 From: DjonuSs Date: Tue, 20 Oct 2026 04:54:52 +0500 Subject: [PATCH 09/13] fix(launcher): reject rootful Podman in repair and refine engine diagnostics (#63) --- packages/run-spaceapp/src/cli.mjs | 93 +++++++++++---------- packages/run-spaceapp/src/index.mjs | 2 +- packages/run-spaceapp/tests/podman.test.mjs | 58 ++++++++++++- 3 files changed, 107 insertions(+), 46 deletions(-) diff --git a/packages/run-spaceapp/src/cli.mjs b/packages/run-spaceapp/src/cli.mjs index f18fadfd3..70fdb6ccf 100644 --- a/packages/run-spaceapp/src/cli.mjs +++ b/packages/run-spaceapp/src/cli.mjs @@ -569,9 +569,13 @@ export async function run(argv, { let requestedEngine; for (let index = 0; index < args.length; index += 1) { const argument = args[index]; - if (argument === "--engine" && index + 1 < args.length) { - requestedEngine = args[index + 1]; - index += 1; + if (argument === "--engine") { + if (index + 1 < args.length) { + requestedEngine = args[index + 1]; + index += 1; + } else { + requestedEngine = ""; + } } else if (argument.startsWith("--engine=")) { requestedEngine = argument.slice("--engine=".length); } @@ -584,7 +588,7 @@ export async function run(argv, { const savedEngine = config?.engine ?? "docker"; if (requestedEngine !== savedEngine) { stderr.write( - `Cannot switch container engine from "${savedEngine}" to "${requestedEngine}" on an existing installation. Docker volumes are not visible to Podman. To migrate engines, run "spaceapp backup", uninstall with "spaceapp uninstall --purge-data", install with "--engine ${requestedEngine}", and run "spaceapp restore".\n` + `Cannot switch container engine from "${savedEngine}" to "${requestedEngine}" on an existing installation. Docker and Podman volumes are not interchangeable. To migrate engines, run "spaceapp backup", uninstall with "spaceapp uninstall --purge-data", install with "--engine ${requestedEngine}", and run "spaceapp restore".\n` ); return 1; } @@ -1067,7 +1071,7 @@ async function installCommand(args, { { platform, stderr } ); if (statusCode !== 0) { - stderr.write(`SpaceApp service status check failed with Docker exit ${statusCode}.\n`); + stderr.write(`SpaceApp service status check failed with ${engineLabel} exit ${statusCode}.\n`); } } catch (error) { stderr.write( @@ -1087,7 +1091,7 @@ async function installCommand(args, { { platform, stderr } ); if (logsCode !== 0) { - stderr.write(`SpaceApp startup log check failed with Docker exit ${logsCode}.\n`); + stderr.write(`SpaceApp startup log check failed with ${engineLabel} exit ${logsCode}.\n`); } } catch (error) { stderr.write( @@ -1270,7 +1274,7 @@ async function planInteractiveSetup({ const savedEngine = rawExisting.engine ?? "docker"; if (requestedEngine !== savedEngine) { throw new Error( - `Cannot switch container engine from "${savedEngine}" to "${requestedEngine}" on an existing installation. Docker volumes are not visible to Podman. To migrate engines, run "spaceapp backup", uninstall with "spaceapp uninstall --purge-data", install with "--engine ${requestedEngine}", and run "spaceapp restore".` + `Cannot switch container engine from "${savedEngine}" to "${requestedEngine}" on an existing installation. Docker and Podman volumes are not interchangeable. To migrate engines, run "spaceapp backup", uninstall with "spaceapp uninstall --purge-data", install with "--engine ${requestedEngine}", and run "spaceapp restore".` ); } } @@ -1312,11 +1316,7 @@ async function planInteractiveSetup({ } else { let podmanWorking = false; try { - const podmanCode = await execute( - { command: "podman", args: ["--version"], timeoutMs: 15000 }, - { stdin: null, stdout: null, stderr: null } - ); - podmanWorking = podmanCode === 0; + podmanWorking = await podmanIsReady(execute); } catch { podmanWorking = false; } @@ -1601,6 +1601,12 @@ async function repairRuntime({ root, config, platform, stdin, stdout, stderr, ex stderr.write(`Podman is not running or accessible. ${podmanInstallHelp(platform)}\n`); return 1; } + const podmanInfo = await getPodmanInfo(execute); + const isRootless = Boolean(podmanInfo?.host?.security?.rootless); + if (!isRootless) { + stderr.write("Rootless Podman is required. Running Podman as root is not supported.\n"); + return 1; + } } else { const prerequisites = await ensureDocker({platform, arch, env, stdin, stdout, stderr, execute, sleep: sleep || wait, installArgs: {root,requestedProfile:repaired.profile,requestedAccessMode:repaired.accessMode,requestedEngine:engine,noOpen:true,autoConfirm:true}}); @@ -2613,6 +2619,33 @@ export function cgroupLimitsDelegated(controllers, platform) { return names.has("cpu") && names.has("memory"); } +/** + * Queries and parses Podman system information formatted as JSON. + * + * @param {Function} execute Command execution runner. + * @returns {Promise} Parsed JSON info object or null if Podman info command fails. + */ +export async function getPodmanInfo(execute) { + let rawJson = ""; + const captureStream = new Writable({ + write(chunk, _encoding, callback) { + rawJson += chunk.toString(); + if (typeof callback === "function") callback(); + } + }); + try { + const code = await execute({ command: "podman", args: ["info", "--format", "json"] }, { + stdin: null, + stdout: captureStream, + stderr: null + }); + if (code !== 0) return null; + return JSON.parse(rawJson); + } catch { + return null; + } +} + /** * Collects host, engine, configuration, and runtime health information into a sanitized diagnostic bundle. * @@ -2645,20 +2678,8 @@ async function supportBundleCommand({ engine: resolvedEngine }; if (resolvedEngine === "podman") { - let rawJson = ""; - const captureStream = new Writable({ - write(chunk, _encoding, callback) { - rawJson += chunk.toString(); - if (typeof callback === "function") callback(); - } - }); - try { - await execute({ command: "podman", args: ["info", "--format", "json"] }, { - stdin: null, - stdout: captureStream, - stderr: null - }); - const parsed = JSON.parse(rawJson); + const parsed = await getPodmanInfo(execute); + if (parsed) { const controllers = parsed?.host?.cgroupControllers; const limitsDelegated = cgroupLimitsDelegated(controllers, platform); engineFacts = { @@ -2669,7 +2690,7 @@ async function supportBundleCommand({ cgroupControllers: parsed?.host?.cgroupControllers ?? null, limitsDelegated }; - } catch { + } else { engineFacts = { engine: "podman", status: "unavailable" @@ -3065,23 +3086,7 @@ async function doctor({ let podmanInfo = null; if (podmanService.code === 0) { - let rawJson = ""; - const captureStream = new Writable({ - write(chunk, _encoding, callback) { - rawJson += chunk.toString(); - if (typeof callback === "function") callback(); - } - }); - try { - await execute({ command: "podman", args: ["info", "--format", "json"] }, { - stdin: null, - stdout: captureStream, - stderr: null - }); - podmanInfo = JSON.parse(rawJson); - } catch { - podmanInfo = null; - } + podmanInfo = await getPodmanInfo(execute); } if (podmanInfo) { diff --git a/packages/run-spaceapp/src/index.mjs b/packages/run-spaceapp/src/index.mjs index d41e8796c..011a9646e 100644 --- a/packages/run-spaceapp/src/index.mjs +++ b/packages/run-spaceapp/src/index.mjs @@ -1081,7 +1081,7 @@ export async function prepareInstallation(root, { const existingEngine = existingConfig.engine ?? "docker"; if (engine !== undefined && engine !== existingEngine) { throw new Error( - `Cannot switch container engine from "${existingEngine}" to "${engine}" on an existing installation. Docker volumes are not visible to Podman. To migrate engines, run "spaceapp backup", uninstall with "spaceapp uninstall --purge-data", install with "--engine ${engine}", and run "spaceapp restore".` + `Cannot switch container engine from "${existingEngine}" to "${engine}" on an existing installation. Docker and Podman volumes are not interchangeable. To migrate engines, run "spaceapp backup", uninstall with "spaceapp uninstall --purge-data", install with "--engine ${engine}", and run "spaceapp restore".` ); } } diff --git a/packages/run-spaceapp/tests/podman.test.mjs b/packages/run-spaceapp/tests/podman.test.mjs index b84f559dc..6bf309e00 100644 --- a/packages/run-spaceapp/tests/podman.test.mjs +++ b/packages/run-spaceapp/tests/podman.test.mjs @@ -186,7 +186,7 @@ test("engine switching on existing install is refused with migration instruction accessMode: "isolated", engine: "podman" }), - /Cannot switch container engine from "docker" to "podman" on an existing installation\. Docker volumes are not visible to Podman\. To migrate engines, run "spaceapp backup", uninstall with "spaceapp uninstall --purge-data", install with "--engine podman", and run "spaceapp restore"\./ + /Cannot switch container engine from "docker" to "podman" on an existing installation\. Docker and Podman volumes are not interchangeable\. To migrate engines, run "spaceapp backup", uninstall with "spaceapp uninstall --purge-data", install with "--engine podman", and run "spaceapp restore"\./ ); const loaded = await loadConfig(root); @@ -753,6 +753,62 @@ test("recovery and restore commands use podman executable when engine is podman" assert.equal(checkpointSpec.command, "podman"); }); +test("repair rejects rootful Podman before storage verification or runtime mutation", async () => { + const root = await mkdtemp(join(tmpdir(), "spaceapp-repair-rootful-")); + const config = createDefaultConfig({ + version: "1.0.0", + profile: "small", + accessMode: "isolated", + engine: "podman" + }); + await saveConfig(root, config); + + const stdout = capture(); + const stderr = capture(); + + const code = await run(["repair"], { + env: { SPACEAPP_HOME: root }, + platform: "linux", + stdin: ttyStdin("yes"), + stdout: stdout.stream, + stderr: stderr.stream, + inspectResources: async () => mockGuestResources, + execute: async (spec, io) => { + if (spec.args?.includes("--format") && spec.args?.includes("json")) { + io.stdout.write(JSON.stringify({ + host: { + security: { rootless: false } + } + })); + } + return 0; + } + }); + + assert.equal(code, 1); + assert.match(stderr.value(), /Rootless Podman is required/); +}); + +test("reinstall with bare --engine prints usage and exits with code 1", async () => { + const root = await mkdtemp(join(tmpdir(), "spaceapp-reinstall-bare-")); + const config = createDefaultConfig({ + version: "1.0.0", + profile: "small", + accessMode: "isolated", + engine: "docker" + }); + await saveConfig(root, config); + const stderr = capture(); + + const code = await run(["reinstall", "--engine"], { + env: { SPACEAPP_HOME: root }, + stderr: stderr.stream + }); + + assert.equal(code, 1); + assert.match(stderr.value(), /Usage: spaceapp reinstall \[--engine docker\|podman\]/); +}); + From 47a58585e81b44367326edeccb8eb69ea9c45f85 Mon Sep 17 00:00:00 2001 From: DjonuSs Date: Tue, 20 Oct 2026 05:59:26 +0500 Subject: [PATCH 10/13] fix(launcher): permit engine switch after volume purge and refine migration flow (#63) --- packages/run-spaceapp/src/cli.mjs | 18 +++--- packages/run-spaceapp/src/index.mjs | 12 +++- packages/run-spaceapp/tests/podman.test.mjs | 65 +++++++++++++++++++++ 3 files changed, 85 insertions(+), 10 deletions(-) diff --git a/packages/run-spaceapp/src/cli.mjs b/packages/run-spaceapp/src/cli.mjs index 70fdb6ccf..f96ece208 100644 --- a/packages/run-spaceapp/src/cli.mjs +++ b/packages/run-spaceapp/src/cli.mjs @@ -586,7 +586,7 @@ export async function run(argv, { return 1; } const savedEngine = config?.engine ?? "docker"; - if (requestedEngine !== savedEngine) { + if (requestedEngine !== savedEngine && config?.volumesPurged !== true) { stderr.write( `Cannot switch container engine from "${savedEngine}" to "${requestedEngine}" on an existing installation. Docker and Podman volumes are not interchangeable. To migrate engines, run "spaceapp backup", uninstall with "spaceapp uninstall --purge-data", install with "--engine ${requestedEngine}", and run "spaceapp restore".\n` ); @@ -740,6 +740,7 @@ export async function run(argv, { { stdin, stdout, stderr } ); if (code === 0) { + await saveConfig(root, { ...config, volumesPurged: true }); 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"); @@ -871,14 +872,14 @@ async function installCommand(args, { stdout.write( `Access: ${existingConfig?.accessMode ?? "not configured"} -> ${result.config.accessMode}\n` ); - stdout.write(existingConfig + stdout.write(existingConfig && !existingConfig.volumesPurged ? `Preserved existing data, workspaces, credentials, secrets, and persistent ${engineLabel} volumes.\n` : `Future refreshes preserve data, workspaces, credentials, secrets, and persistent ${engineLabel} volumes.\n`); stdout.write( `Selected profile: ${profile} (${formatGibibytes(resources.totalMemoryBytes)} GiB system memory detected).\n` ); stdout.write(`SpaceApp installation root: ${root}\n`); - const operation = existingConfig ? (existingConfig.version === runtimeVersion ? "repair" : "upgrade") : "fresh"; + const operation = existingConfig ? (existingConfig.volumesPurged ? "fresh" : existingConfig.version === runtimeVersion ? "repair" : "upgrade") : "fresh"; if (installResourceChecks(resources, profile, { operation }).some((check) => !check.ok)) { const doctorCode = await doctor({ root, @@ -1030,7 +1031,7 @@ async function installCommand(args, { if (await verifyStorage({ root, config: result.config, existingConfig, operation, inspectResources, execute, stdout, stderr }) !== 0) return failInstall(1); stage = "start"; - if(existingConfig && existingConfig.version !== result.config.version) { + if (existingConfig && existingConfig.version !== result.config.version && !existingConfig.volumesPurged) { upgradeCheckpoint = await createQuiescedCheckpoint(root, existingConfig, {stdin,stdout,stderr,execute,platform}); } runtimeMutationAttempted = true; @@ -1272,7 +1273,7 @@ async function planInteractiveSetup({ if (rawExisting && requestedEngine) { const savedEngine = rawExisting.engine ?? "docker"; - if (requestedEngine !== savedEngine) { + if (requestedEngine !== savedEngine && rawExisting.volumesPurged !== true) { throw new Error( `Cannot switch container engine from "${savedEngine}" to "${requestedEngine}" on an existing installation. Docker and Podman volumes are not interchangeable. To migrate engines, run "spaceapp backup", uninstall with "spaceapp uninstall --purge-data", install with "--engine ${requestedEngine}", and run "spaceapp restore".` ); @@ -1297,7 +1298,7 @@ async function planInteractiveSetup({ if (path === "fresh") { if (!requestedEngine) { - if (parsed.requestedAccessMode === "host-root" || platform !== "linux") { + if (parsed.requestedAccessMode === "host-root" || platform === "win32") { requestedEngine = "docker"; } else { let dockerWorking = false; @@ -1326,8 +1327,8 @@ async function planInteractiveSetup({ requestedEngine = "podman"; } else { requestedEngine = await promptChoice(stdin, stdout, "Which container engine?", [ - { label: "podman (Podman rootless)", value: "podman" }, - { label: "docker (Docker Engine / Docker Desktop)", value: "docker" } + { label: "docker (Docker Engine / Docker Desktop)", value: "docker" }, + { label: "podman (Podman rootless)", value: "podman" } ], { defaultIndex: 0, answerKey: "engine" }); } } else { @@ -2844,6 +2845,7 @@ async function factoryResetCommand({ } catch {} const result = await initializeInstallation(root, { version: runtimeVersion }); + await saveConfig(root, { ...result.config, volumesPurged: true }); stdout.write(`SpaceApp factory reset complete. Clean installation initialized at ${root}\n`); if (result.setupToken) { stdout.write(`One-time setup token: ${result.setupToken}\n`); diff --git a/packages/run-spaceapp/src/index.mjs b/packages/run-spaceapp/src/index.mjs index 011a9646e..6595089f2 100644 --- a/packages/run-spaceapp/src/index.mjs +++ b/packages/run-spaceapp/src/index.mjs @@ -125,7 +125,8 @@ const CONFIG_KEYS = new Set([ "optionalModules", "installId", "engine", - "users" + "users", + "volumesPurged" ]); // Multi-user (SpaceApp accounts with SPACE_MULTI_USER_ENABLED): every user's CLIs run in their own container, with @@ -402,6 +403,9 @@ export function validateConfig(config) { if (config.engine === "podman" && config.accessMode === "host-root") { throw new Error("Host-root access is not supported on Podman."); } + if (config.volumesPurged !== undefined && typeof config.volumesPurged !== "boolean") { + throw new Error("volumesPurged must be boolean."); + } if (config.optionalModules !== undefined && !OPTIONAL_MODULES_PATTERN.test(config.optionalModules)) { throw new Error("optionalModules must be all, none or a comma-separated list of module names."); } @@ -1079,7 +1083,7 @@ export async function prepareInstallation(root, { } if (existingConfig) { const existingEngine = existingConfig.engine ?? "docker"; - if (engine !== undefined && engine !== existingEngine) { + if (engine !== undefined && engine !== existingEngine && existingConfig.volumesPurged !== true) { throw new Error( `Cannot switch container engine from "${existingEngine}" to "${engine}" on an existing installation. Docker and Podman volumes are not interchangeable. To migrate engines, run "spaceapp backup", uninstall with "spaceapp uninstall --purge-data", install with "--engine ${engine}", and run "spaceapp restore".` ); @@ -1105,6 +1109,10 @@ export async function prepareInstallation(root, { if (engine !== undefined && config.engine !== engine) { config = { ...config, engine }; } + if (config.volumesPurged) { + const { volumesPurged, ...rest } = config; + config = rest; + } validateConfig(config); const postgresPasswordPath = join(root, "secrets", "postgres-password"); diff --git a/packages/run-spaceapp/tests/podman.test.mjs b/packages/run-spaceapp/tests/podman.test.mjs index 6bf309e00..35336656c 100644 --- a/packages/run-spaceapp/tests/podman.test.mjs +++ b/packages/run-spaceapp/tests/podman.test.mjs @@ -809,6 +809,71 @@ test("reinstall with bare --engine prints usage and exits with code 1", async () assert.match(stderr.value(), /Usage: spaceapp reinstall \[--engine docker\|podman\]/); }); +test("engine switch is permitted after data purge marked with volumesPurged", async () => { + const root = await mkdtemp(join(tmpdir(), "spaceapp-engine-switch-purged-")); + const config = createDefaultConfig({ + version: "1.0.0", + profile: "small", + accessMode: "isolated", + engine: "docker" + }); + config.volumesPurged = true; + await saveConfig(root, config); + + const prepared = await prepareInstallation(root, { + version: "1.0.0", + profile: "small", + accessMode: "isolated", + engine: "podman" + }); + + assert.equal(prepared.config.engine, "podman"); + assert.equal(prepared.config.volumesPurged, undefined, "clears volumesPurged marker on install"); +}); + +test("uninstall --purge-data sets volumesPurged and allows subsequent engine switch", async () => { + const root = await mkdtemp(join(tmpdir(), "spaceapp-uninstall-purge-")); + const config = createDefaultConfig({ + version: "1.0.0", + profile: "small", + accessMode: "isolated", + engine: "docker" + }); + await saveConfig(root, config); + await writeFile(join(root, "compose.yml"), "services: {}\n"); + + const stdout = capture(); + const stderr = capture(); + + const code = await run(["uninstall", "--purge-data"], { + env: { SPACEAPP_HOME: root }, + stdin: ttyStdin("DELETE"), + stdout: stdout.stream, + stderr: stderr.stream, + execute: async () => 0 + }); + + assert.equal(code, 0); + const purgedConfig = await loadConfig(root); + assert.equal(purgedConfig.volumesPurged, true); + + const installCode = await run(["install", "--engine", "podman", "--profile", "small", "--no-open", "--non-interactive", "--answers", '{"confirm": true}'], { + env: { SPACEAPP_HOME: root }, + platform: "linux", + stdout: stdout.stream, + stderr: stderr.stream, + inspectResources: async () => mockGuestResources, + request: readyRequest, + sleep: async () => {}, + execute: async () => 0 + }); + + assert.equal(installCode, 0); + const finalConfig = await loadConfig(root); + assert.equal(finalConfig.engine, "podman"); + assert.equal(finalConfig.volumesPurged, undefined); +}); + From 5209d1ba5dcaa7841cd290b346d49acd17b4aaa4 Mon Sep 17 00:00:00 2001 From: DjonuSs Date: Tue, 20 Oct 2026 07:01:16 +0500 Subject: [PATCH 11/13] fix(launcher): fail closed on missing podman info, guard reinstall engine switch, and refine rollback (#63) --- packages/run-spaceapp/src/cli.mjs | 14 ++- packages/run-spaceapp/tests/podman.test.mjs | 126 +++++++++++++++++++- 2 files changed, 132 insertions(+), 8 deletions(-) diff --git a/packages/run-spaceapp/src/cli.mjs b/packages/run-spaceapp/src/cli.mjs index f96ece208..10a81a62e 100644 --- a/packages/run-spaceapp/src/cli.mjs +++ b/packages/run-spaceapp/src/cli.mjs @@ -586,9 +586,11 @@ export async function run(argv, { return 1; } const savedEngine = config?.engine ?? "docker"; - if (requestedEngine !== savedEngine && config?.volumesPurged !== true) { + if (requestedEngine !== savedEngine) { stderr.write( - `Cannot switch container engine from "${savedEngine}" to "${requestedEngine}" on an existing installation. Docker and Podman volumes are not interchangeable. To migrate engines, run "spaceapp backup", uninstall with "spaceapp uninstall --purge-data", install with "--engine ${requestedEngine}", and run "spaceapp restore".\n` + config?.volumesPurged + ? `reinstall keeps the saved engine "${savedEngine}". Run "spaceapp install --engine ${requestedEngine}" to switch engines.\n` + : `Cannot switch container engine from "${savedEngine}" to "${requestedEngine}" on an existing installation. Docker and Podman volumes are not interchangeable. To migrate engines, run "spaceapp backup", uninstall with "spaceapp uninstall --purge-data", install with "--engine ${requestedEngine}", and run "spaceapp restore".\n` ); return 1; } @@ -991,7 +993,7 @@ async function installCommand(args, { } else await restoreRuntimeAfterFailedInstall({ root, stagedStateRoot, - existingConfig, + existingConfig: existingConfig?.volumesPurged ? null : existingConfig, attemptedProfile: profile, companionsEnabled, engine, @@ -3126,6 +3128,12 @@ async function doctor({ if (!limitsDelegated) { stderr.write("Warning: CPU and memory limits cannot be applied by rootless Podman because cgroup controllers are not delegated. Configure systemd user delegation to enable container resource limits.\n"); } + } else if (podmanService.code === 0) { + checks.push({ + name: "Podman rootless", + ok: false, + detail: "system information unavailable or unreadable" + }); } for (const check of checks) { diff --git a/packages/run-spaceapp/tests/podman.test.mjs b/packages/run-spaceapp/tests/podman.test.mjs index 35336656c..82e8ce6e2 100644 --- a/packages/run-spaceapp/tests/podman.test.mjs +++ b/packages/run-spaceapp/tests/podman.test.mjs @@ -66,6 +66,23 @@ const mockGuestResources = Object.freeze({ freeDiskBytes: 50 * 1024 ** 3 }); +function mockPodmanInfo(execute) { + return async (spec, io) => { + if (spec.command === "podman" && spec.args?.includes("info") && spec.args?.includes("json")) { + io?.stdout?.write?.(JSON.stringify({ + version: { Version: "5.1.0" }, + host: { + security: { rootless: true }, + cgroupVersion: "v2", + cgroupControllers: ["cpu", "memory", "pids"] + } + })); + return 0; + } + return execute ? execute(spec, io) : 0; + }; +} + function readyRequest(url) { return Promise.resolve(new Response(JSON.stringify( url.endsWith("/readyz") ? { ok: true } : { setupRequired: false, expiresAt: null } @@ -222,13 +239,13 @@ test("install with --engine podman initializes launcher configuration with engin inspectResources: async () => mockGuestResources, request: readyRequest, sleep: async () => {}, - execute: async (spec, io) => { + execute: mockPodmanInfo(async (spec, io) => { calls.push(spec); if (spec.args?.includes("pg_dump")) { io.stdout.write("-- PostgreSQL database dump\nSELECT 1;\n"); } return 0; - } + }) }); assert.equal(code, 0); @@ -252,12 +269,12 @@ test("install without --engine auto-selects podman when docker is absent and pod inspectResources: async () => mockGuestResources, request: readyRequest, sleep: async () => {}, - execute: async (spec, io) => { + execute: mockPodmanInfo(async (spec, io) => { calls.push(spec); if (spec.command === "docker") return 127; if (spec.command === "podman") return 0; return 0; - } + }) }); assert.equal(code, 0); @@ -865,7 +882,7 @@ test("uninstall --purge-data sets volumesPurged and allows subsequent engine swi inspectResources: async () => mockGuestResources, request: readyRequest, sleep: async () => {}, - execute: async () => 0 + execute: mockPodmanInfo() }); assert.equal(installCode, 0); @@ -874,6 +891,105 @@ test("uninstall --purge-data sets volumesPurged and allows subsequent engine swi assert.equal(finalConfig.volumesPurged, undefined); }); +test("reinstall rejects engine switch even when volumesPurged is set", async () => { + const root = await mkdtemp(join(tmpdir(), "spaceapp-reinstall-purged-")); + const config = createDefaultConfig({ + version: "1.0.0", + profile: "small", + accessMode: "isolated", + engine: "docker" + }); + config.volumesPurged = true; + await saveConfig(root, config); + + const stdout = capture(); + const stderr = capture(); + + const code = await run(["reinstall", "--engine", "podman"], { + env: { SPACEAPP_HOME: root }, + stdout: stdout.stream, + stderr: stderr.stream + }); + + assert.equal(code, 1); + assert.match( + stderr.value(), + /reinstall keeps the saved engine "docker"\. Run "spaceapp install --engine podman" to switch engines\./ + ); +}); + +test("doctor fails when podmanService is available but getPodmanInfo returns null", async () => { + const root = await mkdtemp(join(tmpdir(), "spaceapp-podman-doctor-no-info-")); + const config = createDefaultConfig({ + version: "1.0.0", + profile: "small", + accessMode: "isolated", + engine: "podman" + }); + await saveConfig(root, config); + + const stdout = capture(); + const stderr = capture(); + + const code = await run(["doctor"], { + env: { SPACEAPP_HOME: root }, + platform: "linux", + stdout: stdout.stream, + stderr: stderr.stream, + inspectResources: async () => mockGuestResources, + execute: async (spec, io) => { + if (spec.args?.includes("--format") && spec.args?.includes("json")) { + return 1; // Podman info fails + } + return 0; + } + }); + + assert.equal(code, 1); + assert.match(stderr.value(), /FAIL Podman rootless: system information unavailable or unreadable/); +}); + +test("failed install after volume purge does not attempt to restore previous purged engine", async () => { + const root = await mkdtemp(join(tmpdir(), "spaceapp-failed-purge-install-")); + const config = createDefaultConfig({ + version: "1.0.0", + profile: "small", + accessMode: "isolated", + engine: "docker" + }); + config.volumesPurged = true; + await saveConfig(root, config); + + const stdout = capture(); + const stderr = capture(); + const executedCommands = []; + + const code = await run(["install", "--engine", "podman", "--profile", "small", "--no-open", "--non-interactive", "--answers", '{"confirm": true}'], { + env: { SPACEAPP_HOME: root }, + platform: "linux", + stdout: stdout.stream, + stderr: stderr.stream, + inspectResources: async () => mockGuestResources, + request: async () => { + throw new Error("Simulated readiness failure"); + }, + sleep: async () => {}, + execute: mockPodmanInfo(async (spec) => { + executedCommands.push(spec); + return 0; + }) + }); + + assert.equal(code, 1); + // Should NOT attempt docker compose up to restore purged docker runtime + const dockerUp = executedCommands.find(c => c.command === "docker" && c.args?.includes("up")); + assert.equal(dockerUp, undefined, "Must not attempt to start old docker runtime after purge"); + // Should run podman compose down to stop partially started podman runtime + const podmanDown = executedCommands.find(c => c.command === "podman" && c.args?.includes("down")); + assert.ok(podmanDown, "Must stop the attempted podman runtime"); +}); + + From 5e791550e417e9935e02174f4eed05183e928018 Mon Sep 17 00:00:00 2001 From: DjonuSs Date: Tue, 20 Oct 2026 08:03:29 +0500 Subject: [PATCH 12/13] fix(launcher): clear volumesPurged on runtime start and verify full docker readiness (#63) --- packages/run-spaceapp/src/cli.mjs | 24 +++++-- packages/run-spaceapp/src/prerequisites.mjs | 2 +- packages/run-spaceapp/tests/podman.test.mjs | 70 ++++++++++++++++++++- 3 files changed, 90 insertions(+), 6 deletions(-) diff --git a/packages/run-spaceapp/src/cli.mjs b/packages/run-spaceapp/src/cli.mjs index 10a81a62e..28dba82cb 100644 --- a/packages/run-spaceapp/src/cli.mjs +++ b/packages/run-spaceapp/src/cli.mjs @@ -699,6 +699,11 @@ export async function run(argv, { if (restoreCode !== 0) return restoreCode; const upCode = await runtimeExecute(composeCommand("up", root, { profile: config.profile, companionsEnabled: config.companionsEnabled, engine: config.engine }), { stdin, stdout, stderr }); if (upCode !== 0) return upCode; + if (config?.volumesPurged) { + const { volumesPurged, ...rest } = config; + config = rest; + await saveConfig(root, config); + } 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; @@ -1305,11 +1310,17 @@ async function planInteractiveSetup({ } else { let dockerWorking = false; try { - const dockerCode = await execute( + dockerWorking = true; + for (const spec of [ { command: "docker", args: ["--version"], timeoutMs: 15000 }, - { stdin: null, stdout: null, stderr: null } - ); - dockerWorking = dockerCode === 0; + { command: "docker", args: ["compose", "version"], timeoutMs: 15000 }, + { command: "docker", args: ["info"], timeoutMs: 15000 } + ]) { + if (await execute(spec, { stdin: null, stdout: null, stderr: null }) !== 0) { + dockerWorking = false; + break; + } + } } catch { dockerWorking = false; } @@ -2600,6 +2611,11 @@ async function reinstallCommand({ return 1; } } + if (config?.volumesPurged) { + const { volumesPurged, ...rest } = config; + config = rest; + await saveConfig(root, config); + } stdout.write("SpaceApp clean setup completed successfully. All data and settings preserved.\n"); return 0; } diff --git a/packages/run-spaceapp/src/prerequisites.mjs b/packages/run-spaceapp/src/prerequisites.mjs index 7d9c350d0..b2072c91a 100644 --- a/packages/run-spaceapp/src/prerequisites.mjs +++ b/packages/run-spaceapp/src/prerequisites.mjs @@ -660,7 +660,7 @@ async function dockerCliAndComposeAreAvailable(execute) { * @param {Function} execute Command execution function. * @returns {Promise} True if Docker engine is operational. */ -async function dockerIsReady(execute) { +export async function dockerIsReady(execute) { for (const spec of [ { command: "docker", args: ["--version"] }, { command: "docker", args: ["compose", "version"] }, diff --git a/packages/run-spaceapp/tests/podman.test.mjs b/packages/run-spaceapp/tests/podman.test.mjs index 82e8ce6e2..675d48c82 100644 --- a/packages/run-spaceapp/tests/podman.test.mjs +++ b/packages/run-spaceapp/tests/podman.test.mjs @@ -904,14 +904,21 @@ test("reinstall rejects engine switch even when volumesPurged is set", async () const stdout = capture(); const stderr = capture(); + const executed = []; const code = await run(["reinstall", "--engine", "podman"], { env: { SPACEAPP_HOME: root }, + platform: "linux", stdout: stdout.stream, - stderr: stderr.stream + stderr: stderr.stream, + execute: async (spec) => { + executed.push(spec); + return 0; + } }); assert.equal(code, 1); + assert.deepEqual(executed, [], "engine-switch rejection must happen before any engine command"); assert.match( stderr.value(), /reinstall keeps the saved engine "docker"\. Run "spaceapp install --engine podman" to switch engines\./ @@ -989,6 +996,67 @@ test("failed install after volume purge does not attempt to restore previous pur assert.ok(podmanDown, "Must stop the attempted podman runtime"); }); +test("reinstall clears volumesPurged after successful clean setup", async () => { + const root = await mkdtemp(join(tmpdir(), "spaceapp-reinstall-clear-purged-")); + const config = createDefaultConfig({ + version: "1.0.0", + profile: "small", + accessMode: "isolated", + engine: "podman" + }); + config.volumesPurged = true; + await saveConfig(root, config); + + const stdout = capture(); + const stderr = capture(); + + const code = await run(["reinstall"], { + env: { SPACEAPP_HOME: root }, + platform: "linux", + stdin: ttyStdin("y"), + stdout: stdout.stream, + stderr: stderr.stream, + request: readyRequest, + sleep: async () => {}, + execute: mockPodmanInfo(async () => 0) + }); + + assert.equal(code, 0); + const reloaded = await loadConfig(root); + assert.equal(reloaded.volumesPurged, undefined, "reinstall must clear volumesPurged after containers start"); +}); + +test("install without --engine auto-selects podman when docker CLI is installed but daemon is stopped", async () => { + const root = await mkdtemp(join(tmpdir(), "spaceapp-podman-docker-stopped-")); + const calls = []; + const stdout = capture(); + const stderr = capture(); + + const code = await run(["install", "--profile", "small", "--no-open", "--non-interactive", "--answers", '{"confirm": true}'], { + env: { SPACEAPP_HOME: root }, + platform: "linux", + stdout: stdout.stream, + stderr: stderr.stream, + inspectResources: async () => mockGuestResources, + request: readyRequest, + sleep: async () => {}, + execute: mockPodmanInfo(async (spec, io) => { + calls.push(spec); + if (spec.command === "docker") { + if (spec.args?.includes("--version")) return 0; // CLI installed + return 1; // docker info / compose fails (daemon stopped) + } + if (spec.command === "podman") return 0; + return 0; + }) + }); + + assert.equal(code, 0); + const saved = await loadConfig(root); + assert.equal(saved.engine, "podman", "should auto-select podman when docker daemon is not operational"); +}); + + From f889189aa5d0b01966d0a02182a42ab4b28c63b9 Mon Sep 17 00:00:00 2001 From: DjonuSs Date: Tue, 20 Oct 2026 09:12:34 +0500 Subject: [PATCH 13/13] fix(launcher): clear volumesPurged on up/repair/restore and fix restore const binding (#63) --- packages/run-spaceapp/src/cli.mjs | 17 ++-- packages/run-spaceapp/tests/podman.test.mjs | 92 +++++++++++++++++++++ 2 files changed, 104 insertions(+), 5 deletions(-) diff --git a/packages/run-spaceapp/src/cli.mjs b/packages/run-spaceapp/src/cli.mjs index 28dba82cb..e51b4a347 100644 --- a/packages/run-spaceapp/src/cli.mjs +++ b/packages/run-spaceapp/src/cli.mjs @@ -538,7 +538,12 @@ 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, engine: config.engine }), { stdin, stdout, stderr }); + const code = await runtimeExecute(composeCommand(command, root, { profile: config.profile, companionsEnabled: config.companionsEnabled, engine: config.engine }), { stdin, stdout, stderr }); + if (code === 0 && command === "up" && config?.volumesPurged) { + delete config.volumesPurged; + await saveConfig(root, config); + } + return code; } if (command === "open") { assertNoArgs(args, "open"); @@ -700,8 +705,7 @@ export async function run(argv, { const upCode = await runtimeExecute(composeCommand("up", root, { profile: config.profile, companionsEnabled: config.companionsEnabled, engine: config.engine }), { stdin, stdout, stderr }); if (upCode !== 0) return upCode; if (config?.volumesPurged) { - const { volumesPurged, ...rest } = config; - config = rest; + delete config.volumesPurged; await saveConfig(root, config); } await restoreBackupExtras({ root, config, backupId, stdin, stdout, stderr, execute: runtimeExecute }) @@ -1666,6 +1670,10 @@ async function repairRuntime({ root, config, platform, stdin, stdout, stderr, ex } const cleanupCode = await removeInactiveProfileServices({root, config, execute, stdin, stdout, stderr}); if (cleanupCode !== 0) return cleanupCode; + if (config?.volumesPurged) { + delete config.volumesPurged; + await saveConfig(root, config); + } stdout.write(`SpaceApp ${config.version} runtime repaired.\n`); return 0; } @@ -2612,8 +2620,7 @@ async function reinstallCommand({ } } if (config?.volumesPurged) { - const { volumesPurged, ...rest } = config; - config = rest; + delete config.volumesPurged; await saveConfig(root, config); } stdout.write("SpaceApp clean setup completed successfully. All data and settings preserved.\n"); diff --git a/packages/run-spaceapp/tests/podman.test.mjs b/packages/run-spaceapp/tests/podman.test.mjs index 675d48c82..da4bfbb9b 100644 --- a/packages/run-spaceapp/tests/podman.test.mjs +++ b/packages/run-spaceapp/tests/podman.test.mjs @@ -1054,6 +1054,98 @@ test("install without --engine auto-selects podman when docker CLI is installed assert.equal(code, 0); const saved = await loadConfig(root); assert.equal(saved.engine, "podman", "should auto-select podman when docker daemon is not operational"); + assert.ok(calls.some((spec) => spec.command === "docker"), "docker must be probed first"); + assert.ok(calls.some((spec) => spec.command === "podman"), "podman must be used after the docker probe fails"); +}); + +test("restore clears volumesPurged after restoring data and starting runtime", async () => { + const root = await mkdtemp(join(tmpdir(), "spaceapp-restore-purged-")); + const config = createDefaultConfig({ + version: "1.0.0", + profile: "small", + accessMode: "isolated", + engine: "podman" + }); + config.volumesPurged = true; + await saveConfig(root, config); + + const backupDir = join(root, "backups", "spaceapp-backup-20261020T000000000Z"); + await mkdir(backupDir, { recursive: true }); + await writeFile(join(backupDir, "manifest.json"), JSON.stringify({ version: "1.0.0", files: [] })); + + const stdout = capture(); + const stderr = capture(); + + const code = await run(["restore"], { + env: { SPACEAPP_HOME: root }, + platform: "linux", + stdin: ttyStdin("RESTORE"), + stdout: stdout.stream, + stderr: stderr.stream, + execute: mockPodmanInfo(async () => 0) + }); + + assert.equal(code, 0); + const reloaded = await loadConfig(root); + assert.equal(reloaded.volumesPurged, undefined, "restore must clear volumesPurged after containers start"); +}); + +test("spaceapp up clears volumesPurged when starting runtime", async () => { + const root = await mkdtemp(join(tmpdir(), "spaceapp-up-purged-")); + const config = createDefaultConfig({ + version: "1.0.0", + profile: "small", + accessMode: "isolated", + engine: "podman" + }); + config.volumesPurged = true; + await saveConfig(root, config); + + const stdout = capture(); + const stderr = capture(); + + const code = await run(["up"], { + env: { SPACEAPP_HOME: root }, + platform: "linux", + stdout: stdout.stream, + stderr: stderr.stream, + execute: mockPodmanInfo(async () => 0) + }); + + assert.equal(code, 0); + const reloaded = await loadConfig(root); + assert.equal(reloaded.volumesPurged, undefined, "up must clear volumesPurged"); +}); + +test("repairRuntime clears volumesPurged when repairing runtime", async () => { + const root = await mkdtemp(join(tmpdir(), "spaceapp-repair-purged-")); + const config = createDefaultConfig({ + version: "1.0.0", + profile: "small", + accessMode: "isolated", + engine: "podman" + }); + config.volumesPurged = true; + await saveConfig(root, config); + + const stdout = capture(); + const stderr = capture(); + + const code = await run(["repair"], { + env: { SPACEAPP_HOME: root }, + platform: "linux", + stdin: ttyStdin("y"), + stdout: stdout.stream, + stderr: stderr.stream, + inspectResources: async () => mockGuestResources, + request: readyRequest, + sleep: async () => {}, + execute: mockPodmanInfo(async () => 0) + }); + + assert.equal(code, 0); + const reloaded = await loadConfig(root); + assert.equal(reloaded.volumesPurged, undefined, "repair must clear volumesPurged"); });