From 3e8ebb947469d8b7eff3dd0cdc8640aa1e509ade Mon Sep 17 00:00:00 2001 From: Arthi Arumugam <62213284+arthi-arumugam-git@users.noreply.github.com> Date: Sat, 24 Oct 2026 19:51:43 +0530 Subject: [PATCH] feat(run-spaceapp): support rootless Podman engine (closes #63) Signed-off-by: Arthi Arumugam <62213284+arthi-arumugam-git@users.noreply.github.com> --- .github/workflows/platform-matrix.yml | 156 +++++++++++ README.md | 30 ++- docs/getting-started.md | 110 ++++++-- packages/run-spaceapp/README.md | 74 +++++- packages/run-spaceapp/package.json | 2 +- packages/run-spaceapp/src/cli.mjs | 244 +++++++++++++----- packages/run-spaceapp/src/engines.mjs | 168 ++++++++++++ packages/run-spaceapp/src/index.mjs | 116 +++++++-- packages/run-spaceapp/src/storage.mjs | 55 ++-- packages/run-spaceapp/tests/engines.test.mjs | 222 ++++++++++++++++ .../run-spaceapp/tests/podman-cli.test.mjs | 174 +++++++++++++ .../tests/podman-compose.test.mjs | 177 +++++++++++++ .../tests/podman-machine-cli.test.mjs | 62 +++++ packages/run-spaceapp/tests/storage.test.mjs | 22 ++ 14 files changed, 1464 insertions(+), 148 deletions(-) create mode 100644 packages/run-spaceapp/src/engines.mjs create mode 100644 packages/run-spaceapp/tests/engines.test.mjs create mode 100644 packages/run-spaceapp/tests/podman-cli.test.mjs create mode 100644 packages/run-spaceapp/tests/podman-compose.test.mjs create mode 100644 packages/run-spaceapp/tests/podman-machine-cli.test.mjs diff --git a/.github/workflows/platform-matrix.yml b/.github/workflows/platform-matrix.yml index e09ecb53c..dd86ef6e3 100644 --- a/.github/workflows/platform-matrix.yml +++ b/.github/workflows/platform-matrix.yml @@ -57,3 +57,159 @@ 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: + name: Launcher / rootless Podman / small + runs-on: ubuntu-latest + timeout-minutes: 35 + env: + ELECTRON_SKIP_BINARY_DOWNLOAD: "1" + SPACEAPP_HOME: ${{ runner.temp }}/spaceapp-podman + SPACEAPP_TELEMETRY: "0" + PODMAN_COMPOSE_PROVIDER: docker-compose + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + + - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0 + with: + node-version: "22.23.0" + cache: npm + + - name: Install workspace dependencies + run: npm ci + + - name: Prepare Podman 5+ and the Compose v2 provider + shell: bash + run: | + set -euo pipefail + # Ubuntu 24.04 currently ships Podman 4.9; use Homebrew's Linux + # bottle until ubuntu-latest provides a supported version itself. + if ! podman --version | awk '{ split($3, v, "."); exit !(v[1] >= 5) }'; then + /home/linuxbrew/.linuxbrew/bin/brew install podman + export PATH="/home/linuxbrew/.linuxbrew/bin:$PATH" + echo /home/linuxbrew/.linuxbrew/bin >> "$GITHUB_PATH" + fi + sudo apt-get update + sudo apt-get install -y uidmap + podman --version + podman --version | awk '{ split($3, v, "."); exit !(v[1] >= 5) }' + + # Pin the provider major version; the latest Compose release may + # have different API requirements. Verify the release checksum. + mkdir -p "$RUNNER_TEMP/spaceapp-compose" + cd "$RUNNER_TEMP/spaceapp-compose" + curl --fail --location --retry 3 -O https://github.com/docker/compose/releases/download/v2.38.2/docker-compose-linux-x86_64 + echo "486b3ffc0f806ca2efbc430cef89955386011662f0c76bad17c103d059cfa9cf docker-compose-linux-x86_64" | sha256sum --check + install -m 755 docker-compose-linux-x86_64 docker-compose + echo "$RUNNER_TEMP/spaceapp-compose" >> "$GITHUB_PATH" + ./docker-compose version + + - name: Start an isolated rootless Podman API service + shell: bash + run: | + set -euo pipefail + test "$(id -u)" -ne 0 + spaceapp_runner_user=$(id -un) + spaceapp_runner_uid=$(id -u) + # Fresh hosted images may omit subordinate ranges for the runner. + # Allocate above all existing ranges without changing other users. + subid_start=$(awk -F: 'BEGIN { next_id = 100000 } { if ($2 + $3 > next_id) next_id = $2 + $3 } END { print next_id }' /etc/subuid /etc/subgid) + subid_end=$((subid_start + 65535)) + if ! awk -F: -v name="$spaceapp_runner_user" -v uid="$spaceapp_runner_uid" '($1 == name || $1 == uid) && $3 >= 65536 { found = 1 } END { exit !found }' /etc/subuid; then + sudo usermod --add-subuids "$subid_start-$subid_end" "$spaceapp_runner_user" + fi + if ! awk -F: -v name="$spaceapp_runner_user" -v uid="$spaceapp_runner_uid" '($1 == name || $1 == uid) && $3 >= 65536 { found = 1 } END { exit !found }' /etc/subgid; then + sudo usermod --add-subgids "$subid_start-$subid_end" "$spaceapp_runner_user" + fi + # Delegation in the API unit alone cannot enable controllers that + # the system manager has not delegated to the runner's user tree. + sudo loginctl enable-linger "$spaceapp_runner_user" + sudo /usr/bin/systemctl start "user@$spaceapp_runner_uid.service" + sudo /usr/bin/systemctl set-property --runtime "user@$spaceapp_runner_uid.service" Delegate=yes + export XDG_RUNTIME_DIR="/run/user/$spaceapp_runner_uid" + spaceapp_podman_socket="$XDG_RUNTIME_DIR/podman/podman.sock" + mkdir -p "$(dirname "$spaceapp_podman_socket")" + test ! -S "$spaceapp_podman_socket" + echo "XDG_RUNTIME_DIR=$XDG_RUNTIME_DIR" >> "$GITHUB_ENV" + /usr/bin/systemd-run --user --collect --unit=spaceapp-ci-podman --property=Delegate=yes \ + --setenv="PATH=$PATH" "$(command -v podman)" system service --time=0 "unix://$spaceapp_podman_socket" + for attempt in {1..30}; do + if curl --fail --silent --unix-socket "$spaceapp_podman_socket" http://localhost/_ping; then + break + fi + sleep 1 + done + curl --fail --silent --unix-socket "$spaceapp_podman_socket" http://localhost/_ping + podman info --format json | jq -e ' + .host.security.rootless == true and .host.cgroupVersion == "v2" and + (.host.cgroupControllers | index("cpu") != null) and + (.host.cgroupControllers | index("memory") != null)' + podman compose version + + - name: Install the small profile with Podman + run: >- + node packages/run-spaceapp/bin/spaceapp.mjs install + --engine podman --profile small --non-interactive + --answers '{"confirm":true,"open":false,"telemetry":false,"companions":false}' + + - name: Verify readiness and saved engine + shell: bash + run: | + set -euo pipefail + jq -e '.engine == "podman"' "$SPACEAPP_HOME/config.json" + for attempt in {1..60}; do + if curl --fail --silent --max-time 10 http://127.0.0.1:4911/readyz | jq -e '.ok == true'; then + node packages/run-spaceapp/bin/spaceapp.mjs doctor + exit 0 + fi + sleep 2 + done + node packages/run-spaceapp/bin/spaceapp.mjs logs + exit 1 + + - name: Create and verify a backup + shell: bash + run: | + set -euo pipefail + node packages/run-spaceapp/bin/spaceapp.mjs backup + test -n "$(find "$SPACEAPP_HOME/backups" -mindepth 1 -maxdepth 1 -type d -print -quit)" + + - name: Uninstall, purge volumes, and verify cleanup + if: always() + shell: bash + run: | + set -uo pipefail + cleanup_code=0 + project=$(node --input-type=module -e 'import { composeProjectName } from "./packages/run-spaceapp/src/index.mjs"; console.log(composeProjectName(process.env.SPACEAPP_HOME));') || exit 1 + if test -f "$SPACEAPP_HOME/config.json"; then + node packages/run-spaceapp/bin/spaceapp.mjs uninstall --purge-data --non-interactive \ + --answers '{"purgeConfirm":"DELETE"}' || cleanup_code=1 + fi + if podman info >/dev/null 2>&1; then + containers=$(podman ps -aq --filter "label=com.docker.compose.project=$project") || cleanup_code=1 + volumes=$(podman volume ls -q --filter "label=com.docker.compose.project=$project") || cleanup_code=1 + networks=$(podman network ls -q --filter "label=com.docker.compose.project=$project") || cleanup_code=1 + # Preserve a failing result for leaked resources, then clean up + # exactly this test's project even if install/uninstall failed. + if test -n "$containers$volumes$networks"; then + cleanup_code=1 + printf '%s\n' "$containers" | xargs -r podman rm -f + printf '%s\n' "$volumes" | xargs -r podman volume rm -f + printf '%s\n' "$networks" | xargs -r podman network rm -f + fi + test -z "$(podman ps -aq --filter "label=com.docker.compose.project=$project")" || cleanup_code=1 + test -z "$(podman volume ls -q --filter "label=com.docker.compose.project=$project")" || cleanup_code=1 + test -z "$(podman network ls -q --filter "label=com.docker.compose.project=$project")" || cleanup_code=1 + test -z "$(podman ps -q)" || cleanup_code=1 + podman unshare rm -rf "$SPACEAPP_HOME" || cleanup_code=1 + elif test -f "$SPACEAPP_HOME/config.json"; then + echo "Podman is unavailable; runtime cleanup could not be verified." + cleanup_code=1 + fi + /usr/bin/systemctl --user stop spaceapp-ci-podman.service || true + if /usr/bin/systemctl --user is-active --quiet spaceapp-ci-podman.service; then + cleanup_code=1 + fi + exit "$cleanup_code" diff --git a/README.md b/README.md index a3e90d971..06cd3278d 100644 --- a/README.md +++ b/README.md @@ -30,7 +30,7 @@ With SpaceApp, you can: - **Control Your Deployment**: Self-host the application and choose its providers and integrations. Remote model requests send the context needed for execution to the configured provider or gateway. Host filesystem access and credentials depend on deployment settings and explicit operator permissions; see the [state and trust boundaries](docs/quality/state-ownership.md). - **Start with OpenCode**: Setup presents OpenCode first. Available models, free tiers and authentication requirements depend on the installed CLI and its current provider catalog. Select and verify a model during setup. - **Shared Vector Memory (optional)**: When the memory graph is enabled for a deployment, SpaceApp stores memory records and their embeddings in PostgreSQL with `pgvector` and shows them as a 3D graph that agent sessions can query. Embeddings are computed by the configured embedding provider, so that provider receives the indexed text. -- **One-Command Setup**: A single universal launcher command installs Docker prerequisites (if missing), configures containers, sets up database secrets, and launches the web application across Linux, macOS, and Windows 11. +- **One-Command Setup**: A single universal launcher command selects Docker or rootless Podman, installs Docker prerequisites when needed, configures containers, sets up database secrets, and launches the web application across Linux, macOS, and Windows 11. --- @@ -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 is the default; if neither Docker nor Podman is present, the installer automatically installs official Docker Desktop (Windows/macOS) or Docker Engine (Linux). Alternatively, install **Podman >=5.0** with **Docker Compose v2 >=2.20.0** (`docker-compose` on `PATH`) and use `install --engine podman`. Linux requires rootless Podman, cgroup v2 and delegated CPU/memory controllers; macOS requires a running rootless Podman machine sized for the profile. Windows Podman is not supported. See [Podman setup and limits](docs/getting-started.md#rootless-podman-linux-and-macos). ### Run the Installation Command @@ -104,8 +104,18 @@ npx --yes run-spaceapp@latest install > **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`. +The engine is saved for later commands. When Docker is absent and Podman is +installed, the wizard offers Podman; `--non-interactive` selects it automatically. +Use `--answers answers.json` with `{"confirm":true,"engine":"podman","open":false}` for scripted selection. +Existing installs keep their engine: switching is refused because Docker and +Podman volumes are separate. Podman supports isolated access only; `--access +host-root` is refused. `doctor` and `support-bundle` report engine/version, +rootless status, cgroup version and CPU/memory limit delegation. Registered +Podman workspaces use shared SELinux `:z` labels and preserve your host user +ownership; see [workspace setup](docs/getting-started.md#register-a-workspace). + ### 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 & Engine Verification**: The launcher verifies CPU, RAM, disk and the selected engine, 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. @@ -130,7 +140,7 @@ You can run any command using `npx --yes run-spaceapp@latest ` or simpl | :--- | :--- | :--- | | `install` | Full setup: installs Docker if needed, creates secrets, starts stack, and opens browser. | `npx --yes run-spaceapp@latest install --profile auto` | | `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 the 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` | @@ -143,7 +153,7 @@ You can run any command using `npx --yes run-spaceapp@latest ` or simpl | :--- | :--- | :--- | | `owner reset-password` | **Forgot your password?** Securely resets the operator account password from your terminal via masked input. | `npx --yes run-spaceapp@latest owner reset-password` | | `owner rotate-setup-token` | **Setup token expired?** Generates a fresh 15-minute token if the first-run owner setup was not completed in time. | `npx --yes run-spaceapp@latest owner rotate-setup-token` | -| `factory-reset` | **Fresh start:** Creates a safety backup, purges all Docker volumes and database data, and resets to clean slate. | `npx --yes run-spaceapp@latest factory-reset` | +| `factory-reset` | **Fresh start:** Creates a safety backup, purges all application volumes and database data, and resets to clean slate. | `npx --yes run-spaceapp@latest factory-reset` | ### 📁 Host Workspace Management @@ -171,10 +181,10 @@ By default, the SpaceApp containers get no bind mount of your host filesystem: a | Command | Description | Example | | :--- | :--- | :--- | -| `doctor [--fix]` | Diagnoses system prerequisites, Docker health, and config; `--fix` automatically repairs issues. | `npx --yes run-spaceapp@latest doctor --fix` | +| `doctor [--fix]` | Diagnoses system prerequisites, container engine health, and config; `--fix` automatically repairs issues. | `npx --yes run-spaceapp@latest doctor --fix` | | `repair [--dry-run]` | Verifies and rebuilds container configurations and runtime files. | `npx --yes run-spaceapp@latest repair` | | `support-bundle` | Generates a sanitized diagnostic JSON bundle to share when seeking help or opening issues. | `npx --yes run-spaceapp@latest support-bundle` | -| `update [version]` | Safely downloads updated Docker images and upgrades the application stack with automatic checkpointing. | `npx --yes run-spaceapp@latest update` | +| `update [version]` | Safely downloads updated container images and upgrades the application stack with automatic checkpointing. | `npx --yes run-spaceapp@latest update` | | `rollback` | Switches back to the previously recorded runtime images and configuration. It does not revert database changes; use `restore` for that. | `npx --yes run-spaceapp@latest rollback` | | `backup` | Generates a portable, checksummed archive containing PostgreSQL data, app settings, and memory graph. | `npx --yes run-spaceapp@latest backup` | | `restore` | Restores database, application data, and memory state from a previously generated backup archive. | `npx --yes run-spaceapp@latest restore` | @@ -211,7 +221,7 @@ The runtime registry declares 16 CLI adapters, listed below. Installed versions ## 🏗️ System Architecture & Profiles -SpaceApp deploys a versioned Docker Compose stack. The installation profile decides which of the services below run; optional companion services are not shown: +SpaceApp deploys a versioned Compose stack on Docker or rootless Podman. The installation profile decides which of the services below run; optional companion services are not shown: ``` ┌────────────────────────────────────────────────────────────────────────┐ @@ -238,7 +248,7 @@ SpaceApp deploys a versioned Docker Compose stack. The installation profile deci ### Installation Profiles -The launcher selects the largest profile that fits the available CPU, RAM, and free disk (and Docker's CPU and memory allocation when it can measure it), or you can pass `--profile `: +The launcher selects the largest profile that fits the available CPU, RAM, and free disk (and the engine's CPU and memory allocation when it can measure it), or you can pass `--profile `: | Profile | Target System | Allocated Services | Minimum Hardware | | :--- | :--- | :--- | :--- | @@ -278,7 +288,7 @@ On Linux, the launcher can mount the whole host filesystem into the CLI service. ```bash npx --yes run-spaceapp@latest install --access host-root ``` -Host-root is supported only on Linux. Run `npx --yes run-spaceapp@latest install --access isolated` to remove the host mount again. +Host-root is supported only on Linux with Docker; Podman refuses it. Run `npx --yes run-spaceapp@latest install --access isolated` to remove the host mount again.
diff --git a/docs/getting-started.md b/docs/getting-started.md index c56d99ce9..2982a90de 100644 --- a/docs/getting-started.md +++ b/docs/getting-started.md @@ -1,9 +1,11 @@ # Getting started SpaceApp packages its application services and redistributable AI coding CLIs -in Docker. The host needs Node.js only for the small `spaceapp` launcher, -and enough resources for the stack. The launcher installs and starts Docker -automatically when it is missing. +in containers on Docker or rootless Podman. The host needs Node.js only for the +small `spaceapp` launcher, and enough resources for the stack. Docker remains the +default. If Docker is absent and Podman is installed, the launcher offers Podman +(or selects it with `--non-interactive`); otherwise it installs and starts Docker +when needed. ## Requirements @@ -32,7 +34,7 @@ npx --yes run-spaceapp@latest install Linux runs the containers directly through the host Docker Engine; SpaceApp does not create a second guest VM. The launcher runs as the current non-root user so the installation remains in that user's config directory. If Docker is -missing on Ubuntu, Debian, Fedora, RHEL, or CentOS, the launcher adds Docker's +selected but missing on Ubuntu, Debian, Fedora, RHEL, or CentOS, the launcher adds Docker's official repository. On Arch-family systems, including CachyOS, it installs the native `docker` and `docker-compose` packages through `pacman`. It then starts the service. Before adding the current user to the `docker` group, it @@ -47,12 +49,76 @@ Install Node.js 20.11 or newer, then run in Terminal: npx --yes run-spaceapp@latest install ``` -If Docker is missing, the launcher asks you to review and accept Docker -Desktop's terms, downloads the official architecture-specific DMG, verifies +If Docker is selected but missing, the launcher asks you to review and accept +Docker Desktop's terms, downloads the official architecture-specific DMG, verifies its code signature, installs it, starts it, and waits until it is ready. Docker Desktop uses its managed lightweight Linux environment. Apple Silicon uses the native `linux/arm64` images and Intel Macs use `linux/amd64`. +## Rootless Podman (Linux and macOS) + +Install [Podman](https://podman.io/docs/installation) **>=5.0** and **Docker +Compose v2 >=2.20.0**, with the `docker-compose` executable on `PATH`. +The launcher calls `podman compose` with Docker Compose v2 as its provider. +Python `podman-compose` and other major versions of Docker Compose are not +supported. The provider supports the stack's profiles, file-based secrets, health dependencies including +`required: false`, `init`, CPU/memory limits, shared socket volumes, `--wait`, and +`exec`/`run --user 10001:10001`. No Docker daemon is needed. See +[Podman's Compose provider configuration](https://docs.podman.io/en/latest/markdown/podman-compose.1.html). + +On Linux, use your normal unprivileged account. Rootless Podman needs subordinate +UID/GID ranges, cgroup v2 and delegated CPU and memory controllers. Check the +current engine and the launcher diagnostics: + +```bash +systemctl --user enable --now podman.socket +podman info +npx --yes run-spaceapp@latest install --engine podman --profile small +npx --yes run-spaceapp@latest doctor +``` + +If CPU/memory delegation is missing, an administrator must enable those +controllers for the systemd user service before installation. See the +[Podman rootless troubleshooting guide](https://github.com/containers/podman/blob/main/troubleshooting.md#26-running-containers-with-resource-limits-fails-with-a-permissions-error). +The launcher reports unavailable limits before starting the stack. It does not +install Podman or change the host's cgroup delegation, including with +`doctor --fix`. + +On macOS, create and start a rootless Podman machine first. This example leaves +headroom above the `small` profile's minimum: + +```bash +podman machine init --cpus 4 --memory 8192 --disk-size 30 +podman machine start +npx --yes run-spaceapp@latest install --engine podman --profile small +``` + +Machine CPU, memory and free storage must meet the chosen profile, independently +of your Mac's capacity. The launcher checks them and gives resizing guidance. +For QEMU machines, stop the machine, resize, then start it again; for example: + +```bash +podman machine stop +podman machine set --cpus 8 --memory 16384 --disk-size 40 +podman machine start +``` + +Disk size can only grow. Other machine providers may require recreation instead +of resizing: back up existing data before doing that. Consult +[`podman machine set`](https://docs.podman.io/en/latest/markdown/podman-machine-set.1.html) +for provider-specific restrictions. + +Engine selection is stored as `engine` in the installation's `config.json`. +Every subsequent command, including backups, updates and uninstall, uses that +engine. Scripted installation also accepts `--answers answers.json` with +`{"confirm":true,"engine":"podman","open":false}`. An explicit +`--engine docker` keeps the Docker path. +Old installations with no engine field still use Docker. The launcher refuses +engine changes on an existing installation because the engines cannot see each +other's volumes; no in-place engine migration is supported. Podman supports only +`--access isolated`; `--access host-root` is refused. Podman on Windows/WSL is +outside the supported scope. + ## Windows 11 — Command Prompt Enable hardware virtualization and install Node.js 20.11 or newer. Open @@ -98,7 +164,7 @@ The universal install command defaults to `--profile auto`: | `large` | medium + managed Chromium, larger resource limits | 8 | 15 GiB | 11 GiB | Auto chooses the largest profile that fits all three resources, then checks -Docker's allocation. Existing installations preserve their profile during an +the engine's allocation. Existing installations preserve their profile during an upgrade. `light` and `standard` keep compatibility settings for older installs. Additional providers and companions install only when requested. @@ -146,8 +212,8 @@ data, credentials, workspaces, secrets, and persistent volumes, run: npx --yes run-spaceapp@latest install --access isolated ``` -Host-root is supported only on Linux; the launcher refuses it on macOS and -Windows. +Host-root is supported only on Linux with Docker; the launcher refuses it +with Podman and on macOS and Windows. The `npx` command is the universal one-command installer. To retain a global launcher for later operations, optionally run `npm install -g run-spaceapp`; @@ -162,7 +228,7 @@ headroom. Target images already cached need no extra image space. Registry layer metadata excludes shared layers when available; offline/unknown metadata uses a conservative estimate. The launcher checks space again after pull and before cutover. Existing workspaces and volumes are retained in place, never duplicated -or deleted for space. Docker's separate disk capacity is checked when measurable; +or deleted for space. The engine's separate disk capacity is checked when measurable; an unavailable measurement is reported. Data size and optional companions can increase the requirement. @@ -178,7 +244,7 @@ The universal install command performs `initialize → prerequisites → doctor → pull → up → readiness → setup status → open`. It creates non-secret configuration, fresh database/session secrets, and a one-time setup token; installs Docker from official sources when required; checks CPU, RAM, -free disk, Docker CLI, Compose, and engine readiness; downloads the selected +free disk, the selected engine CLI, Compose, and engine readiness; downloads the selected images; starts the services; waits up to ten minutes for `/readyz`; verifies first-owner status; and opens `http://127.0.0.1:4911`. @@ -231,7 +297,7 @@ instructions before allowing tools to operate on it. services. Run `npx --yes run-spaceapp@latest up` only if the command says the services could not pick it up. -On Linux the CLIs run as uid 10001 inside Docker, and a mounted folder keeps its +On Linux with Docker, the CLIs run as uid 10001, and a mounted folder keeps its host owner. When they cannot write to it, `workspace add` prints the command that gives them access without changing the owner (install the `acl` package if `setfacl` is missing): @@ -240,6 +306,17 @@ gives them access without changing the owner (install the `acl` package if sudo setfacl -R -m u:10001:rwX -m u:$(id -u):rwX -m d:u:10001:rwX -m d:u:$(id -u):rwX /home/alice/code/example ``` +With rootless Podman, workspace services use `keep-id` to map your host UID/GID +to container UID/GID 10001. Files created in a writable workspace are owned by +your user on the host; the launcher does not recursively change ownership. Use +directories you own and already have permission to access. Read-only workspaces +remain read-only. + +On SELinux hosts, Podman workspace mounts include `:z` to label the directory for +sharing between core and CLI. Private `:Z` labeling is unsuitable because both +services use the same directory. SELinux relabeling applies even to read-only +mounts; register project directories, not system directories or your entire home. + ## First browser setup The install command starts the stack and opens the first-run page. If the @@ -308,9 +385,12 @@ npx --yes run-spaceapp@latest status npx --yes run-spaceapp@latest logs ``` -`doctor` checks Node.js, Docker CLI, Docker Compose, Docker Engine, CPU, -memory, and free disk exactly once each. It distinguishes a missing CLI or -Compose plugin from an installed engine that is stopped or inaccessible. +`doctor` checks Node.js, the selected engine CLI, Compose, engine readiness, +CPU, memory and free disk. It reports engine/version, rootless or rootful mode, +cgroup version and whether CPU/memory limits are delegated. For Podman, missing +limit delegation is a prerequisite failure with setup guidance. `support-bundle` +includes the same engine facts without credentials. Diagnostics distinguish a +missing CLI or Compose provider from a stopped or inaccessible engine. The `small` profile should show core, CLI, and PostgreSQL running; `medium` adds Temporal, and `large` also adds the browser service. If startup is still in progress, wait for the health checks and inspect bounded logs. diff --git a/packages/run-spaceapp/README.md b/packages/run-spaceapp/README.md index 0e8b1ab3b..0dbeb130e 100644 --- a/packages/run-spaceapp/README.md +++ b/packages/run-spaceapp/README.md @@ -1,6 +1,6 @@ # run-spaceapp -Cross-platform Docker launcher for SpaceApp. +Cross-platform Docker and rootless Podman launcher for SpaceApp. Run the same command in Linux, macOS, or Windows 11 Command Prompt: @@ -17,8 +17,10 @@ npm install -g run-spaceapp The smallest profile requires Node.js 20.11 or newer, 4 CPUs, 8 GB-class RAM (7 GiB usable), and 6.5 GiB free for a fresh installation. Keep `@latest` in the command so npm resolves the current release instead -of matching an existing local launcher. If Docker is missing, the universal -install command automatically installs it from official Docker sources on +of matching an existing local launcher. Docker remains the default. If Docker is +missing and Podman is installed, the launcher offers Podman (and selects it with +`--non-interactive`). If neither is available, the universal install command +automatically installs Docker from official Docker sources on Windows, macOS, Ubuntu, Debian, Fedora, RHEL, and CentOS, or with native `pacman` packages on Arch-family Linux including CachyOS. It starts Docker and waits for Engine and Compose readiness before pulling images. Windows prefers @@ -41,7 +43,7 @@ credential-free config so Docker Desktop's graphical credential helper is never invoked outside an interactive logon. The launcher selects `small`, `medium`, or `large` automatically using CPU, -RAM and free disk. Docker's CPU and RAM allocation is checked as well. +RAM and free disk. The selected engine's CPU and RAM allocation is checked as well. | Profile | CPU | Usable RAM | Fresh free space | Services | | --- | --- | --- | --- | --- | @@ -59,9 +61,65 @@ 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 engine disk capacity is checked when measurable and its availability is reported. +### Rootless Podman + +Install [Podman](https://podman.io/docs/installation) **5.0 or newer** yourself, +then install **Docker Compose v2, version 2.20.0 or newer**, as the +`docker-compose` executable on `PATH`. The launcher runs `podman compose` +with `docker-compose` as its provider. Python `podman-compose` and other major +versions of Docker Compose are not supported. This provider supplies profiles, file secrets, +health dependencies (including `required: false`), `--wait`, and resource limits. +A Docker daemon is not required. See the +[Podman Compose provider documentation](https://docs.podman.io/en/latest/markdown/podman-compose.1.html). + +```bash +npx --yes run-spaceapp@latest install --engine podman --profile small +``` + +On Linux, run as your normal user with cgroup v2 and CPU/memory controllers +delegated to the user's systemd service. Enable the rootless API socket with +`systemctl --user enable --now podman.socket` before installing. `doctor` +reports the engine/version, +rootless status, cgroup version and limit delegation; `support-bundle` includes +those facts without credentials. Missing delegation stops installation with +setup guidance. `doctor --fix` rechecks Podman but does not install it or change +system cgroup delegation. See [rootless setup and troubleshooting](https://github.com/containers/podman/blob/main/troubleshooting.md). + +On macOS, initialize and start a rootless Podman machine with enough resources: + +```bash +podman machine init --cpus 4 --memory 8192 --disk-size 30 +podman machine start +npx --yes run-spaceapp@latest install --engine podman --profile small +``` + +The launcher checks the machine's CPU, memory and available disk against the +selected profile. On machine providers that support resizing (QEMU), stop the +machine, run `podman machine set --cpus 4 --memory 8192 --disk-size 30 `, +then start it again. Disk capacity can only increase; other providers may require +recreating the machine after backing up its data. See +[`podman machine set`](https://docs.podman.io/en/latest/markdown/podman-machine-set.1.html). + +`--engine docker|podman` is saved in `config.json`; all later commands use that +engine without another flag. Unattended installs can use +`install --non-interactive --answers answers.json` with +`{"confirm":true,"engine":"podman","open":false}`. +Existing installations without an engine setting continue to use Docker. +Changing engines on an existing installation is refused because their volumes +are separate; there is no supported in-place engine migration. Podman supports +isolated access only: `--access host-root` is refused. Windows Podman/WSL is +outside this support path. + +Workspace bind mounts on Podman use shared SELinux `:z` labeling (including +read-only mounts) because core and CLI share them. Register project directories +you own, not system directories: relabeling changes their SELinux labels. The +launcher maps container UID/GID 10001 to your host user for these services, so +files created in writable workspaces retain your host ownership without a +recursive `chown`. See [workspace setup](https://github.com/oll4com/spaceapp/blob/main/docs/getting-started.md#register-a-workspace). + ### Telemetry Telemetry is off unless you turn it on. The install wizard lists what is sent @@ -114,13 +172,13 @@ before enabling it: npx --yes run-spaceapp@latest install --access host-root ``` -Host-root is supported only on Linux. Run +Host-root is supported only on Linux with Docker; Podman refuses it. Run `npx --yes run-spaceapp@latest install --access isolated` to remove the host mount while keeping SpaceApp data and volumes. Provider API keys are read from masked standard input and written to restrictive files outside Git. OAuth and device-code credentials remain in -isolated Docker provider state. Neither credential source is included in +isolated container provider state. Neither credential source is included in portable SpaceApp backups. Claude Code is not redistributed in SpaceApp images. Install the reviewed @@ -162,7 +220,7 @@ npx --yes run-spaceapp@latest owner rotate-setup-token ```bash npx --yes run-spaceapp@latest status # Check container health and port bindings npx --yes run-spaceapp@latest logs # Show recent container log lines -npx --yes run-spaceapp@latest doctor # Verify system prerequisites and diagnose Docker +npx --yes run-spaceapp@latest doctor # Verify system prerequisites and diagnose the engine npx --yes run-spaceapp@latest up # Start containers in background npx --yes run-spaceapp@latest down # Stop containers cleanly npx --yes run-spaceapp@latest open # Open web app in default browser diff --git a/packages/run-spaceapp/package.json b/packages/run-spaceapp/package.json index 0fe41cc3e..f0ffd19d1 100644 --- a/packages/run-spaceapp/package.json +++ b/packages/run-spaceapp/package.json @@ -36,7 +36,7 @@ ], "scripts": { "test": "node --test tests/*.test.mjs", - "check": "node --check bin/spaceapp.mjs && node --check src/package-info.mjs && node --check src/index.mjs && node --check src/cli.mjs && node --check src/storage.mjs && node --check src/telemetry.mjs && node --check src/prerequisites.mjs", + "check": "node --check bin/spaceapp.mjs && node --check src/package-info.mjs && node --check src/index.mjs && node --check src/cli.mjs && node --check src/storage.mjs && node --check src/telemetry.mjs && node --check src/prerequisites.mjs && node --check src/engines.mjs", "pack:dry-run": "npm pack --dry-run" }, "repository": { diff --git a/packages/run-spaceapp/src/cli.mjs b/packages/run-spaceapp/src/cli.mjs index e0c16bdb4..9e6df6834 100644 --- a/packages/run-spaceapp/src/cli.mjs +++ b/packages/run-spaceapp/src/cli.mjs @@ -47,6 +47,7 @@ import { writeRuntimeFiles, writeSetupToken } from "./index.mjs"; +import { detectContainerEngine, inspectEngine, podmanInstallHelp } from "./engines.mjs"; import { inspectStoragePlan, storageChecks } from "./storage.mjs"; import { ensureDockerAvailable, @@ -76,6 +77,7 @@ const SETUP_STATUS_TIMEOUT_MS = 10_000; const trustedCommands = new Set([ "codesign", "docker", + "podman", "hdiutil", "open", "powershell.exe", @@ -88,6 +90,7 @@ const trustedCommands = new Set([ "xdg-open" ]); const trustedEnvironmentNames = new Set([ + "PODMAN_COMPOSE_PROVIDER", "SPACEAPP_DOCKER_INSTALLER_PATH", "SPACEAPP_OPEN_URL", "SPACEAPP_RESUME_SCRIPT_PATH" @@ -105,7 +108,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-")); @@ -338,7 +341,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 (["docker", "podman"].includes(spec.command) && spec.args?.includes("compose")) { if (spec.args.at(-1) === "pull") { stdout.write("Local images only: skipping registry downloads; missing images will fail startup.\n"); return 0; @@ -427,7 +430,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, config), { stdin, stdout, stderr }); } if (command === "open") { assertNoArgs(args, "open"); @@ -512,14 +515,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, rollback), (pullSpec) => runtimeExecute(pullSpec, { stdin, stdout, stderr }) ); if (pullCode !== 0) { await writeRuntimeFiles(root, config); return pullCode; } - const upCode = await runtimeExecute(composeCommand("up", root, { profile: rollback.profile, companionsEnabled: rollback.companionsEnabled }), { stdin, stdout, stderr }); + const upCode = await runtimeExecute(composeCommand("up", root, rollback), { stdin, stdout, stderr }); if (upCode !== 0) { await writeRuntimeFiles(root, config); return upCode; @@ -551,14 +554,14 @@ export async function run(argv, { const backupId = await selectLatestBackupId(root); const backupCode = await backupWithWritersPaused(runtimeExecute, root, config, { stdin, stdout, stderr }); if (backupCode !== 0) return backupCode; - const stopCode = await runtimeExecute(composeCommand("stopForPortableRestore", root, { profile: config.profile, companionsEnabled: config.companionsEnabled }), { stdin, stdout, stderr }); + const stopCode = await runtimeExecute(composeCommand("stopForPortableRestore", root, config), { stdin, stdout, stderr }); if (stopCode !== 0) return stopCode; const restoreCode = await runtimeExecute( - composeCommand("restore", root, { backupId, profile: config.profile }), + composeCommand("restore", root, { ...config, backupId }), { stdin, stdout, stderr } ); if (restoreCode !== 0) return restoreCode; - const upCode = await runtimeExecute(composeCommand("up", root, { profile: config.profile, companionsEnabled: config.companionsEnabled }), { stdin, stdout, stderr }); + const upCode = await runtimeExecute(composeCommand("up", root, config), { stdin, stdout, stderr }); if (upCode !== 0) return upCode; await restoreBackupExtras({ root, config, backupId, stdin, stdout, stderr, execute: runtimeExecute }) .catch((error) => stderr.write(`The data was restored; the CLI settings of the backup were not: ${error.message}\n`)); @@ -569,45 +572,45 @@ export async function run(argv, { 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." + `${config.engine === "podman" ? "Podman" : "Docker"} 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, config), { 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(`${config.engine === "podman" ? "Podman" : "Docker"} 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 (${config.engine === "podman" ? "Podman" : "Docker"} exit ${code}).\n`); stdout.write(`Data, configuration, secrets, and backups remain at ${root}.\n`); stdout.write("The global SpaceApp CLI remains installed.\n"); } return code; } if (args.length === 1 && args[0] === "--purge-data") { - const confirmation = await readSecret(stdin, stdout, "Type DELETE to remove Docker volumes: ", { mask: false, answerKey: "purgeConfirm" }); + const confirmation = await readSecret(stdin, stdout, `Type DELETE to remove ${config.engine === "podman" ? "Podman" : "Docker"} volumes: `, { mask: false, answerKey: "purgeConfirm" }); if (confirmation !== "DELETE") { throw new Error("Purge cancelled."); } - stdout.write("Removing SpaceApp containers, network, and Docker volumes...\n"); + stdout.write(`Removing SpaceApp containers, network, and ${config.engine === "podman" ? "Podman" : "Docker"} volumes...\n`); const code = await runtimeExecute( - composeCommand("purge", root, { profile: config.profile, companionsEnabled: config.companionsEnabled }), + composeCommand("purge", root, config), { stdin, stdout, stderr } ); if (code === 0) { - stdout.write("SpaceApp runtime and Docker volumes removed successfully.\n"); + stdout.write(`SpaceApp runtime and ${config.engine === "podman" ? "Podman" : "Docker"} 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 ${config.engine === "podman" ? "Podman" : "Docker"} volume purge failed (${config.engine === "podman" ? "Podman" : "Docker"} exit ${code}).\n`); stdout.write(`Host files and the global SpaceApp CLI remain at ${root}.\n`); } return code; @@ -646,7 +649,7 @@ async function installCommand(args, { stdout, stderr, execute, - inspectResources, request, sleep, ensureDocker, env, arch + inspectResources, request, sleep, ensureDocker, prepareDockerPath, env, arch }); if (wizard.exit !== undefined) { return wizard.exit; @@ -654,16 +657,21 @@ 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 (engine === "podman" && accessMode === "host-root") { + throw new Error("--access host-root is not supported with Podman. Use --access isolated."); + } if (accessMode === "host-root" && platform !== "linux") { throw new Error("Host-root access is supported only on Linux."); } @@ -684,6 +692,7 @@ async function installCommand(args, { const result = await prepareInstallation(root, { version: runtimeVersion, profile, + engine, accessMode, ...(companionsEnabled ? { companionsEnabled } : {}) }); @@ -706,6 +715,7 @@ async function installCommand(args, { }; stdout.write(`Launcher version: ${launcherVersion}\n`); + stdout.write(`Container engine: ${engine}\n`); stdout.write( `Runtime image version: ${existingConfig?.version ?? "not installed"} -> ${result.config.version}\n` ); @@ -716,8 +726,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 ${engine === "podman" ? "Podman" : "Docker"} volumes.\n` + : `Future refreshes preserve data, workspaces, credentials, secrets, and persistent ${engine === "podman" ? "Podman" : "Docker"} volumes.\n`); stdout.write( `Selected profile: ${profile} (${formatGibibytes(resources.totalMemoryBytes)} GiB system memory detected).\n` ); @@ -733,7 +743,7 @@ async function installCommand(args, { stdin, inspectResources, resources, - profile, operation + profile, engine, operation }); stderr.write( `Installation stopped before downloading images. Fix the failed checks and run "${UNIVERSAL_COMMAND} install" again.\n` @@ -741,8 +751,10 @@ async function installCommand(args, { return failInstall(doctorCode); } - stage = "docker"; - const prerequisiteResult = await ensureDocker({ + stage = "engine"; + const prerequisiteResult = await ensureSelectedEngine({ + engine, profile: requestedProfile === "auto" && !existingConfig ? undefined : profile, ensureDocker, + requiredDiskBytes: existingConfig ? 512 * 1024 ** 2 : undefined, platform, arch, env, @@ -770,7 +782,7 @@ async function installCommand(args, { if (engineProfile !== profile) { profile = engineProfile; result.config = { ...result.config, profile }; - stdout.write(`Docker resources select profile: ${profile}.\n`); + stdout.write(`${engine === "podman" ? "Podman" : "Docker"} resources select profile: ${profile}.\n`); storagePlan = await inspectStoragePlan({ root, config: result.config, resources: storagePlan.resources, execute, operation }); } } @@ -784,7 +796,7 @@ async function installCommand(args, { inspectResources, resources, profile, - dockerReady: true, operation, storagePlan + engine, dockerReady: true, operation, storagePlan }); if (doctorCode !== 0) { stderr.write( @@ -802,6 +814,7 @@ async function installCommand(args, { root, stateRoot: stagedStateRoot, profile, + engine, platform, stdin, stdout, @@ -816,6 +829,7 @@ async function installCommand(args, { stagedStateRoot, existingConfig, attemptedProfile: profile, + engine, companionsEnabled, platform, stdin, @@ -833,6 +847,7 @@ async function installCommand(args, { const stagedComposeCommand = (action, options = {}) => composeCommand(action, root, { profile, + engine, stateRoot: stagedStateRoot, companionsEnabled, ...options @@ -893,7 +908,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 ${engine} exit ${statusCode}.\n`); } } catch (error) { stderr.write( @@ -913,7 +928,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 ${engine} exit ${logsCode}.\n`); } } catch (error) { stderr.write( @@ -1027,7 +1042,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, {...config, profile, stateRoot}), {stdin, stdout, stderr}); if (code !== 0) { stderr.write("Inactive service cleanup failed. Data volumes were preserved. Run doctor --fix.\n"); return code; @@ -1046,10 +1061,21 @@ async function planInteractiveSetup({ stderr, execute, inspectResources, - request, sleep, ensureDocker, env, arch + request, sleep, ensureDocker, prepareDockerPath, env, arch }) { const parsed = parseInstallArgs(args); const rawExisting = await readRawConfig(root); + const answerEngine = stdin?._spaceappPromptController?.answers.engine; + parsed.requestedEngine ??= answerEngine; + if (parsed.requestedEngine !== undefined && !["docker", "podman"].includes(parsed.requestedEngine)) { + throw new Error("Invalid engine. Use --engine docker|podman or an answers engine of docker or podman."); + } + if (rawExisting && parsed.requestedEngine !== undefined && parsed.requestedEngine !== (rawExisting.engine ?? "docker")) { + throw new Error("Cannot switch engines on an existing installation: Docker and Podman volumes are separate. Keep the saved engine; in-place engine migration is not supported."); + } + if (parsed.requestedEngine === "podman" && (parsed.requestedAccessMode === "host-root" || stdin?._spaceappPromptController?.answers.access === "host-root")) { + throw new Error("--access host-root is not supported with Podman. Use --access isolated."); + } const repairs = planConfigRepairs(rawExisting); const path = upgradePath(rawExisting?.version ?? null, runtimeVersion); const interactive = interactiveAvailable(stdin); @@ -1077,6 +1103,7 @@ async function planInteractiveSetup({ const actions = continuation.actions ?? {}; return { requestedProfile: actions.profile ?? "auto", + requestedEngine: actions.engine ?? rawExisting?.engine ?? "docker", requestedAccessMode: actions.accessMode, noOpen: actions.noOpen !== false, companionsEnabled: actions.companionsEnabled ?? false, @@ -1086,6 +1113,19 @@ async function planInteractiveSetup({ } if (path === "fresh") { + // Host-root requires Docker; preserve its existing prerequisite and prompt path. + const needsDocker = parsed.requestedAccessMode === "host-root" || stdin?._spaceappPromptController?.answers.access === "host-root"; + if (!needsDocker && platform === "darwin") await prepareDockerPath({ platform, env }); + const detected = needsDocker + ? { engine: "docker" } + : await detectContainerEngine({ execute, requestedEngine: parsed.requestedEngine, platform }); + let engineChoice = detected.engine; + if (!parsed.requestedEngine && detected.engine === "podman" && interactive && !stdin?._spaceappPromptController?.nonInteractive) { + engineChoice = await promptChoice(stdin, stdout, "Docker was not detected. Use the available Podman engine?", [ + { label: "Podman (rootless; already installed)", value: "podman" }, + { label: "Docker (install if missing)", value: "docker" } + ], { answerKey: "engine" }); + } 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" }, @@ -1098,6 +1138,9 @@ async function planInteractiveSetup({ { label: "isolated (recommended; no host access)", value: "isolated" }, { label: "host-root (Linux only; CLI sessions can read and modify the whole host)", value: "host-root" } ], { defaultIndex: parsed.requestedAccessMode === "host-root" ? 1 : 0, answerKey: "access" }); + if (engineChoice === "podman" && accessChoice === "host-root") { + throw new Error("--access host-root is not supported with Podman. Use --access isolated."); + } const companionsChoice = await promptYesNo(stdin, stdout, "Enable companion integrations (Claude Code, browser companions)?", { defaultYes: parsed.companionsEnabled, answerKey: "companions" }); stdout.write( "Anonymous telemetry sends spaceapp.dev a random install ID with the OS and its version, CPUs, RAM, profile,\n" + @@ -1124,9 +1167,10 @@ async function planInteractiveSetup({ `Access mode: ${accessChoice}`, `Companions: ${companionsChoice ? "enabled" : "disabled"}`, `Telemetry: ${telemetryChoice ? "enabled" : "disabled"}`, - `Docker installation: ${dockerChoice ? "automatic if missing" : "not included"}`, + `Container engine: ${engineChoice}`, + engineChoice === "podman" ? "Podman and docker-compose must already be installed." : `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 container volumes." ]); if (!approved) { return { exit: 0 }; @@ -1143,6 +1187,7 @@ async function planInteractiveSetup({ } return { requestedProfile: profileChoice, + requestedEngine: engineChoice, requestedAccessMode: accessChoice, noOpen: !openBrowserChoice, companionsEnabled: companionsChoice, @@ -1204,7 +1249,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 container volumes." ]); if (!approved) { return { exit: 0 }; @@ -1220,6 +1265,7 @@ async function planInteractiveSetup({ } return { requestedProfile: parsed.requestedProfile, + requestedEngine: rawExisting?.engine ?? "docker", requestedAccessMode: parsed.requestedAccessMode, noOpen: parsed.noOpen, companionsEnabled: parsed.companionsEnabled, @@ -1267,6 +1313,16 @@ async function detectDockerAvailable({ execute }) { } } +async function ensureSelectedEngine({ engine = "docker", profile, requiredDiskBytes, ensureDocker = ensureDockerAvailable, ...options }) { + if (engine === "docker") return ensureDocker(options); + const report = await inspectEngine({ engine, profile, requiredDiskBytes, execute: options.execute, platform: options.platform }); + for (const check of report.checks) { + (check.ok ? options.stdout : options.stderr).write(`${check.ok ? "PASS" : "FAIL"} ${check.name}: ${check.detail}\n`); + } + if (report.errors.length) options.stderr.write(`${podmanInstallHelp(options.platform)}\n`); + return { code: report.errors.length ? 1 : 0, reexecuted: false }; +} + async function offerUnattendedContinuation(root, runtimeVersion, stdin, stdout, actions) { const answer = await promptYesNo( stdin, @@ -1293,7 +1349,7 @@ 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, + const prerequisites = await ensureSelectedEngine({engine: repaired.engine, profile: repaired.profile, requiredDiskBytes: 512 * 1024 ** 2, ensureDocker, platform, arch, env, stdin, stdout, stderr, execute, sleep: sleep || wait, installArgs: {root,requestedProfile:repaired.profile,requestedAccessMode:repaired.accessMode,noOpen:true,autoConfirm:true}}); if(prerequisites.reexecuted || prerequisites.code !== 0) return prerequisites.code; config = repaired; @@ -1302,7 +1358,7 @@ async function repairRuntime({ root, config, platform, stdin, stdout, stderr, ex await writeRuntimeFiles(root, config); const pullCode = await withHeadlessDockerConfig( platform, - composeCommand("pull", root, { profile: config.profile, companionsEnabled: config.companionsEnabled }), + composeCommand("pull", root, config), (pullSpec) => execute(pullSpec, { stdin, stdout, stderr }) ); if (pullCode !== 0) { @@ -1311,7 +1367,7 @@ async function repairRuntime({ root, config, platform, stdin, stdout, stderr, ex } if (await verifyStorage({ root, config, existingConfig: config, operation: "repair", inspectResources, execute, stdout, stderr }) !== 0) return 1; const upCode = await execute( - composeCommand("repair", root, { profile: config.profile, companionsEnabled: config.companionsEnabled }), + composeCommand("repair", root, config), { stdin, stdout, stderr } ); if (upCode !== 0) { @@ -1370,17 +1426,17 @@ async function performUpdate({ ); if (pullCode !== 0) { writeWindowsCredentialHint(platform, stderr); - throw new Error(`Image pull failed with Docker exit ${pullCode}.`); + throw new Error(`Image pull failed with ${config.engine === "podman" ? "Podman" : "Docker"} 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, updated), { stdin, stdout, stderr } ); if (upCode !== 0) { - throw new Error(`Runtime start failed with Docker exit ${upCode}.`); + throw new Error(`Runtime start failed with ${config.engine === "podman" ? "Podman" : "Docker"} exit ${upCode}.`); } if (typeof request === "function") { const url = `http://${updated.bindHost}:${updated.port}`; @@ -1454,7 +1510,7 @@ async function collectFiles(directory, logicalDirectory) { // the database, files, memory and Temporal histories are one moment and CLI sessions survive (external review // 2026-10-16). Compose skips services that are not running. async function backupWithWritersPaused(execute, root, config, io) { - const options = { profile: config.profile, companionsEnabled: config.companionsEnabled }; + const options = config; io.stdout.write("Pausing SpaceApp writers for the backup; the app and CLI sessions resume afterwards...\n"); const pauseCode = await execute(composeCommand("pauseWriters", root, options), io); if (pauseCode !== 0) return pauseCode; @@ -1517,7 +1573,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, config), { stdin: null, stdout: dumpStream, stderr } ); } finally { @@ -1599,6 +1655,7 @@ async function reportInstallDiagnostics({ root, stateRoot, profile, + engine, platform, stdin, stdout, @@ -1612,6 +1669,7 @@ async function reportInstallDiagnostics({ execute, composeCommand(action, root, { profile, + engine, stateRoot, lines: 200 }), @@ -1620,7 +1678,7 @@ async function reportInstallDiagnostics({ ); if (code !== 0) { stderr.write( - `SpaceApp could not collect ${action} diagnostics (Docker exit ${code}).\n` + `SpaceApp could not collect ${action} diagnostics (${engine === "podman" ? "Podman" : "Docker"} exit ${code}).\n` ); } } catch (error) { @@ -1636,6 +1694,7 @@ async function restoreRuntimeAfterFailedInstall({ stagedStateRoot, existingConfig, attemptedProfile, + engine, companionsEnabled, platform, stdin, @@ -1654,6 +1713,7 @@ async function restoreRuntimeAfterFailedInstall({ await commitInstallation(root, existingConfig); const restoreCode = await runtimeExecute( composeCommand("up", root, { + ...existingConfig, profile: existingConfig.profile, companionsEnabled: existingConfig.companionsEnabled }) @@ -1672,6 +1732,7 @@ async function restoreRuntimeAfterFailedInstall({ const stopCode = await runtimeExecute( composeCommand("down", root, { + engine: existingConfig?.engine ?? engine, profile: existingConfig?.profile ?? attemptedProfile, stateRoot: existingConfig ? root : stagedStateRoot, companionsEnabled: existingConfig?.companionsEnabled ?? companionsEnabled @@ -1692,6 +1753,7 @@ async function restoreRuntimeAfterFailedInstall({ function parseInstallArgs(args) { let requestedProfile = "auto"; let requestedAccessMode; + let requestedEngine; let noOpen = false; let companionsEnabled = false; for (let index = 0; index < args.length; index += 1) { @@ -1704,6 +1766,14 @@ function parseInstallArgs(args) { companionsEnabled = true; continue; } + if (argument === "--engine" && index + 1 < args.length) { + requestedEngine = args[++index]; + continue; + } + if (argument.startsWith("--engine=")) { + requestedEngine = argument.slice("--engine=".length); + continue; + } if (argument === "--profile" && index + 1 < args.length) { requestedProfile = args[index + 1]; index += 1; @@ -1723,10 +1793,11 @@ 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 ( + (requestedEngine !== undefined && !["docker", "podman"].includes(requestedEngine)) || !["auto", "small", "medium", "large", "light", "standard"].includes(requestedProfile) || ( requestedAccessMode !== undefined && @@ -1734,10 +1805,10 @@ function parseInstallArgs(args) { ) ) { throw new Error( - `Usage: ${UNIVERSAL_COMMAND} install [--profile auto|small|medium|large|light|standard] [--access isolated|host-root] [--with-companions] [--no-open]` + `Usage: ${UNIVERSAL_COMMAND} install [--engine docker|podman] [--profile auto|small|medium|large|light|standard] [--access isolated|host-root] [--with-companions] [--no-open]` ); } - return { requestedProfile, requestedAccessMode, noOpen, companionsEnabled }; + return { requestedProfile, requestedAccessMode, requestedEngine, noOpen, companionsEnabled }; } async function loadExistingInstallation(root) { @@ -1772,7 +1843,7 @@ async function workspaceCommand(rawArgs, { root, config, platform, stdin, stdout const [action, identity, ...rest] = args; const applyWorkspaces = async (updated) => { // A user's mounts belong to their own container; "up" recreates only the services whose mounts changed. - const code = await execute(composeCommand(userId ? "up" : "syncWorkspaces", root, { profile: updated.profile, companionsEnabled: updated.companionsEnabled }), { stdin, stdout, stderr }); + const code = await execute(composeCommand(userId ? "up" : "syncWorkspaces", root, updated), { stdin, stdout, stderr }); if (code !== 0) stderr.write(`The change is saved, but the running services could not pick it up. Run "${UNIVERSAL_COMMAND} up".\n`); return code; }; @@ -1787,7 +1858,7 @@ async function workspaceCommand(rawArgs, { root, config, platform, stdin, stdout await saveConfig(root, updated); await writeRuntimeFiles(root, updated); stdout.write(`Workspace registered: ${identity}\n`); - if (platform === "linux" && !readOnly) await writeAccessHint(identity, stdout); + if (platform === "linux" && config.engine !== "podman" && !readOnly) await writeAccessHint(identity, stdout); return applyWorkspaces(updated); } if (action === "remove") { @@ -1828,7 +1899,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, { ...config, service }), { stdin, stdout: out, stderr } ).catch(() => 1); out.end(); @@ -1868,7 +1939,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, { ...config, service }), { stdin, stdout, stderr, input } ).catch(() => 1); if (code === 0) restored.push(label); @@ -1884,7 +1955,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, config), { stdin, stdout, stderr, input: archive } ).catch(() => 1); stdout.write(code === 0 ? "CLI settings (MCP servers, base URLs) restored.\n" : "The CLI settings in this backup could not be restored.\n"); @@ -1931,7 +2002,7 @@ async function credentialsCommand(rawArgs, { root, config, stdin, stdout, stderr const value = await readSecret(stdin, stdout, `Enter ${provider} credential: `); await writeCredential(root, provider, value, { userId }); const syncCode = await execute( - composeCommand("syncCredentials", root, { profile: config.profile, companionsEnabled: config.companionsEnabled, service }), + composeCommand("syncCredentials", root, { ...config, service }), { stdin, stdout, stderr } ); if (syncCode !== 0) { @@ -1947,7 +2018,7 @@ async function credentialsCommand(rawArgs, { root, config, stdin, stdout, stderr } const removed = await removeCredential(root, provider, { userId }); const syncCode = await execute( - composeCommand("syncCredentials", root, { profile: config.profile, companionsEnabled: config.companionsEnabled, service }), + composeCommand("syncCredentials", root, { ...config, service }), { stdin, stdout, stderr } ); if (syncCode !== 0) { @@ -1973,7 +2044,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, { ...config, service }), { stdin, stdout: null, stderr: null } ).catch(() => 1); if (code === 0) { @@ -1992,7 +2063,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, updated), { stdin, stdout, stderr }); if (code !== 0) stderr.write(`The change is saved, but the running services could not pick it up. Run "${UNIVERSAL_COMMAND} up".\n`); return code; }; @@ -2046,13 +2117,13 @@ async function providerCommand(rawArgs, { root, config, stdin, stdout, stderr, e stdout.write(`Installing optional provider ${provider} into ${userId ? `the private provider volume of ${userId}` : "this installation's private provider volume"}.\n`); // Keep the existing Claude path compatible with older runtime images. const action=provider === "claude" ? "installClaude" : "installProvider"; - return execute(composeCommand(action, root, { profile: config.profile, companionsEnabled: config.companionsEnabled, provider, service }), { stdin, stdout, stderr }); + return execute(composeCommand(action, root, { ...config, provider, service }), { stdin, stdout, stderr }); } async function ownerCommand(args, { root, config, stdin, stdout, stderr, execute }) { if (args.length === 1 && args[0] === "rotate-setup-token") { const token = randomBytes(32).toString("base64url"); - const code = await execute(composeCommand("rotateOwnerSetupToken", root, { profile: config.profile, companionsEnabled: config.companionsEnabled }), { + const code = await execute(composeCommand("rotateOwnerSetupToken", root, config), { stdin, stdout, stderr, @@ -2073,7 +2144,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, config), { stdin, stdout, stderr, @@ -2101,7 +2172,7 @@ async function reinstallCommand({ return 0; } const downCode = await execute( - composeCommand("down", root, { profile: config.profile, companionsEnabled: config.companionsEnabled }), + composeCommand("down", root, config), { stdin, stdout, stderr } ); if (downCode !== 0) { @@ -2109,7 +2180,7 @@ async function reinstallCommand({ } await writeRuntimeFiles(root, config); const upCode = await execute( - composeCommand("up", root, { profile: config.profile, companionsEnabled: config.companionsEnabled }), + composeCommand("up", root, config), { stdin, stdout, stderr } ); if (upCode !== 0) { @@ -2164,6 +2235,7 @@ async function supportBundleCommand({ config: config ? { version: config.version, + engine: config.engine ?? "docker", profile: config.profile, accessMode: config.accessMode, companionsEnabled: config.companionsEnabled, @@ -2176,6 +2248,9 @@ async function supportBundleCommand({ services: {}, health: {} }; + const engineReport = await inspectEngine({ engine: config?.engine ?? "docker", execute, platform, profile: config?.profile, requiredDiskBytes: 512 * 1024 ** 2 }); + bundle.engine = engineReport.facts; + bundle.engineChecks = engineReport.checks; if (config) { const url = `http://${config.bindHost}:${config.port}`; @@ -2241,7 +2316,7 @@ async function factoryResetCommand({ } const purgeCode = await execute( - composeCommand("purge", root, { profile: config.profile, companionsEnabled: config.companionsEnabled }), + composeCommand("purge", root, config), { stdin, stdout, stderr } ); if (purgeCode !== 0) { @@ -2255,7 +2330,7 @@ async function factoryResetCommand({ await rm(join(root, "compose.yml"), { force: true }); } catch {} - const result = await initializeInstallation(root, { version: runtimeVersion }); + const result = await initializeInstallation(root, { version: runtimeVersion, engine: config.engine ?? "docker" }); stdout.write(`SpaceApp factory reset complete. Clean installation initialized at ${root}\n`); if (result.setupToken) { stdout.write(`One-time setup token: ${result.setupToken}\n`); @@ -2337,13 +2412,13 @@ async function updateCommand(args, { root, config, version, platform, stdin, std return 0; } } - const prerequisites = await ensureDocker({ platform, arch, env, stdin, stdout, stderr, execute, + const prerequisites = await ensureSelectedEngine({ engine: config.engine, profile: config.profile, requiredDiskBytes: 512 * 1024 ** 2, ensureDocker, platform, arch, env, stdin, stdout, stderr, execute, installArgs: { root, requestedProfile: config.profile, noOpen: true, autoConfirm: true } }); if (prerequisites.reexecuted || prerequisites.code !== 0) return prerequisites.code; const resources = await inspectResources(root); const storagePlan = await inspectStoragePlan({ root, config: { ...config, version: targetVersion }, existingConfig: config, resources, execute, operation: "upgrade" }); if (await doctor({ root, platform, stdout, stderr, execute, stdin, inspectResources, resources, - profile: config.profile, dockerReady: true, operation: "upgrade", storagePlan }) !== 0) return 1; + profile: config.profile, engine: config.engine, dockerReady: true, operation: "upgrade", storagePlan }) !== 0) return 1; return performUpdate({ root, config, @@ -2384,8 +2459,11 @@ async function doctor({ profile, operation, storagePlan, + engine, dockerReady = false }) { + const installedConfig = await loadConfig(root).catch(() => null); + engine ??= installedConfig?.engine ?? "docker"; 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); let resolvedProfile = profile; @@ -2409,6 +2487,16 @@ async function doctor({ ...resourceChecks.filter((check) => !storagePlan || check.name !== "Free disk"), ...(storagePlan ? storageChecks(storagePlan, detectedResources) : []) ]; + if (engine === "podman") { + const report = await inspectEngine({ engine, execute, platform, profile: resolvedProfile, + requiredDiskBytes: storagePlan?.requiredDiskBytes ?? (operation === "fresh" ? undefined : 512 * 1024 ** 2) }); + checks.push(...report.checks); + for (const check of checks) { + (check.ok ? stdout : stderr).write(`${check.ok ? "PASS" : "FAIL"} ${check.name}: ${check.detail}\n`); + } + if (report.errors.length) 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"] }, @@ -2448,6 +2536,10 @@ async function doctor({ ? "installed but not running or inaccessible" : "not reachable because Docker CLI is missing" }); + if (dockerEngine.code === 0 && !dockerReady) { + const report = await inspectEngine({ engine, execute, platform, profile: resolvedProfile }); + checks.push(...report.checks); + } for (const check of checks) { (check.ok ? stdout : stderr).write(`${check.ok ? "PASS" : "FAIL"} ${check.name}: ${check.detail}\n`); } @@ -2563,6 +2655,9 @@ async function executeWithDockerDiagnostics(execute, spec, io, { platform, stder `SpaceApp could not find the Docker CLI. ${dockerInstallHelp(platform)}\n` ); } + if (spec.command === "podman" && code === 127) { + stderr.write(`SpaceApp could not find the Podman CLI. ${podmanInstallHelp(platform)}\n`); + } return code; } @@ -2591,9 +2686,16 @@ export function executeCommand(spec, { stdin, stdout, stderr, input } = {}) { reject(new Error("SpaceApp command arguments must be strings.")); return; } - const commandEnv = spec.env === undefined + let commandEnv = spec.env === undefined ? process.env : validateCommandEnvironment(spec.env); + if (spec.command === "podman") { + // podman compose honors Docker's environment. An inherited Docker endpoint must + // never redirect a saved Podman installation's lifecycle or volume operations. + commandEnv = { ...commandEnv }; + delete commandEnv.DOCKER_HOST; + delete commandEnv.DOCKER_CONTEXT; + } const background = spec.command === 'xdg-open' && spec.background === true; let launchTimer, commandTimer; const child = spawnTrustedCommand(spec, { @@ -2631,6 +2733,7 @@ function spawnTrustedCommand(spec, options) { switch (spec.command) { case "codesign": return spawn("codesign", args, options); case "docker": return spawn("docker", args, options); + case "podman": return spawn("podman", args, options); case "hdiutil": return spawn("hdiutil", args, options); case "open": return spawn("open", args, options); case "powershell.exe": @@ -2652,7 +2755,8 @@ function validateCommandEnvironment(commandEnvironment) { typeof commandEnvironment !== "object" || Array.isArray(commandEnvironment) || Object.entries(commandEnvironment).some( - ([name, value]) => !trustedEnvironmentNames.has(name) || typeof value !== "string" + ([name, value]) => !trustedEnvironmentNames.has(name) || typeof value !== "string" || + (name === "PODMAN_COMPOSE_PROVIDER" && value !== "docker-compose") ) ) { throw new Error("SpaceApp refused untrusted command environment values."); @@ -2736,12 +2840,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] 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 selected container engine application open Open the local web application doctor [--fix] Diagnose; --fix repairs existing config and runtime support-bundle Generate a diagnostic bundle JSON for support @@ -2774,7 +2878,7 @@ Options: --non-interactive Run non-interactively, failing on missing input --answers Pre-seed answers; automation requires confirm:true -Profiles (auto uses CPU, RAM and free disk, then checks Docker resources): +Profiles (auto uses CPU, RAM and free disk, then checks container engine resources): small: 4 CPUs, 7 GiB usable RAM, 6.5 GiB fresh space; SpaceApp + OpenCode. Background integration workers and managed browser are disabled. medium: 4 CPUs, 11 GiB usable RAM, 8 GiB fresh space; adds background workers. @@ -2788,8 +2892,10 @@ No automatic deletion of images, volumes, workspaces, or backups to free space. Quick start: install (automatic profile, OpenCode first, browser opens on completion). Windows PowerShell: use npx.cmd if script execution policy blocks npx.ps1. -The npx command requires Node.js 20.11+ installed; Docker is installed if missing. -Data lives in SPACEAPP_HOME (config/secrets) and persistent Docker volumes. +The npx command requires Node.js 20.11+. Docker is installed if missing. +Podman >=5 with docker-compose v2 >=2.20 is supported on Linux and macOS; install it first. +The selected engine is saved; switching engines and Podman host-root access are refused. +Data lives in SPACEAPP_HOME (config/secrets) and persistent container volumes. Use backup before major changes; uninstall preserves data unless --purge-data. Never use factory-reset to solve an ordinary installation problem. diff --git a/packages/run-spaceapp/src/engines.mjs b/packages/run-spaceapp/src/engines.mjs new file mode 100644 index 000000000..c61d5db96 --- /dev/null +++ b/packages/run-spaceapp/src/engines.mjs @@ -0,0 +1,168 @@ +import { PROFILE_REQUIREMENTS } from "./index.mjs"; +import { captureProbe } from "./storage.mjs"; + +const GiB = 1024 ** 3; +const MiB = 1024 ** 2; +const PODMAN_VERSION_PATTERN = /^podman version \d+\.\d+\.\d+(?:$|[-+\s])/i; +export const PODMAN_COMPOSE_ENV = Object.freeze({ PODMAN_COMPOSE_PROVIDER: "docker-compose" }); + +function validateEngine(engine) { + if (engine !== "docker" && engine !== "podman") throw new Error("Container engine must be docker or podman."); +} + +async function probeWithExit(execute, spec) { + let code = 1; + const output = await captureProbe(async (command, io) => { + try { code = await execute(command, io); } + catch (error) { code = error.code === "ENOENT" ? 127 : 1; } + return code; + }, spec); + return { code, output }; +} + +// An installed Docker CLI still owns the default even when its daemon is stopped. +// A missing CLI or podman-docker compatibility shim offers native Podman instead. +export async function detectContainerEngine({ execute, requestedEngine, platform = process.platform }) { + if (requestedEngine !== undefined) { + validateEngine(requestedEngine); + if (requestedEngine === "podman" && platform === "win32") throw new Error("Podman is supported on Linux and macOS only."); + return { engine: requestedEngine, detected: false, reason: "requested" }; + } + const docker = await probeWithExit(execute, { command: "docker", args: ["--version"] }); + const podmanShim = docker.code === 0 && PODMAN_VERSION_PATTERN.test(docker.output ?? ""); + if (docker.code !== 127 && !podmanShim) return { engine: "docker", detected: docker.code === 0, reason: "docker-present" }; + if (platform !== "win32") { + const podmanVersion = await captureProbe(execute, { command: "podman", args: ["--version"] }); + if (PODMAN_VERSION_PATTERN.test(podmanVersion ?? "")) { + return { engine: "podman", detected: true, reason: "podman-available" }; + } + } + return { engine: "docker", detected: docker.code === 0, reason: docker.code === 127 ? "docker-default" : "docker-present" }; +} + +function jsonObject(text) { + try { + const value = JSON.parse(text); + return value && typeof value === "object" && !Array.isArray(value) ? value : null; + } catch { return null; } +} + +function version(value) { + const match = typeof value === "string" && /(?:^|\s|v)(\d+\.\d+\.\d+)(?:$|[-+\s,])/.exec(value); + return match ? match[1] : null; +} + +function positiveNumber(value) { + return typeof value === "number" && Number.isFinite(value) && value > 0 ? value : null; +} + +function nullableBoolean(value) { return typeof value === "boolean" ? value : null; } +function status(value) { return value === null ? "unknown" : value ? "delegated" : "not delegated"; } +function cgroupVersion(value) { return value === "v2" || value === "2" ? "v2" : value === "v1" || value === "1" ? "v1" : null; } + +export function podmanInstallHelp(platform = process.platform) { + const common = "SpaceApp requires Podman >= 5 and the docker-compose v2 provider >= 2.20 on PATH; podman-compose is not supported. SpaceApp does not install Podman."; + if (platform === "darwin") return `${common} Install Podman, create a rootless machine with podman machine init, then run podman machine start.`; + if (platform === "win32") return "Podman is supported on Linux and macOS only. Use Docker on Windows."; + return `${common} Install Podman from your distribution, use a rootless login with cgroup v2, and enable the user socket with systemctl --user enable --now podman.socket.`; +} + +function machineResizeHelp(profile, name) { + const requirements = PROFILE_REQUIREMENTS[profile]; + const memoryMiB = { small: 3072, medium: 5120, large: 7168, light: 3072, standard: 3072 }[profile]; + // Names are validated before they can reach output or a command argument. + return `For a QEMU machine, run podman machine stop ${name}, then podman machine set --cpus ${requirements.cpuCount} --memory ${memoryMiB} --disk-size ${Math.max(20, Math.ceil(requirements.freshDiskBytes / GiB) + 5)} ${name}, then podman machine start ${name}. Other VM providers may require creating a larger machine; back up and restore data before replacing an existing machine.`; +} + +async function inspectMachine(execute) { + const info = jsonObject(await captureProbe(execute, { command: "podman", args: ["machine", "info", "--format", "json"] })); + const name = info?.Host?.DefaultMachine || info?.Host?.CurrentMachine; + if (typeof name !== "string" || !/^[a-zA-Z0-9][a-zA-Z0-9_.-]{0,127}$/.test(name)) return null; + let inspections; + try { inspections = JSON.parse(await captureProbe(execute, { command: "podman", args: ["machine", "inspect", name] })); } catch { return null; } + const machine = Array.isArray(inspections) ? inspections.find((entry) => entry.Name === name) : null; + if (!machine) return null; + return { + name, + running: machine.State === "running", + rootless: machine.Rootful === false, + cpuCount: positiveNumber(machine.Resources?.CPUs), + memoryBytes: positiveNumber(machine.Resources?.Memory) === null ? null : machine.Resources.Memory * MiB, + diskBytes: positiveNumber(machine.Resources?.DiskSize) === null ? null : machine.Resources.DiskSize * GiB + }; +} + +// Return only an explicit allowlist of diagnostic facts: engine info contains +// host paths, registry credentials and connection details unsuitable for bundles. +export async function inspectEngine({ engine = "docker", execute, platform = process.platform, profile, requiredDiskBytes }) { + validateEngine(engine); + if (profile !== undefined && !PROFILE_REQUIREMENTS[profile]) throw new Error("Unknown installation profile."); + if (requiredDiskBytes !== undefined && (!Number.isFinite(requiredDiskBytes) || requiredDiskBytes < 0)) throw new Error("Required disk space must be a non-negative number."); + const podman = engine === "podman"; + const [versionText, infoText, composeText] = await Promise.all([ + captureProbe(execute, { command: podman ? "podman" : "docker", args: ["--version"] }), + captureProbe(execute, { command: podman ? "podman" : "docker", args: ["info", "--format", podman ? "json" : "{{json .}}"] }), + captureProbe(execute, { command: podman ? "podman" : "docker", args: ["compose", "version", "--short"], ...(podman ? { env: PODMAN_COMPOSE_ENV } : {}) }) + ]); + const info = jsonObject(infoText); + const host = info?.host; + const controllers = Array.isArray(host?.cgroupControllers) ? host.cgroupControllers : null; + const allocated = positiveNumber(info?.store?.graphRootAllocated); + const used = typeof info?.store?.graphRootUsed === "number" && info.store.graphRootUsed >= 0 ? info.store.graphRootUsed : null; + const facts = { + engine, + version: version(podman ? info?.version?.Version : info?.ServerVersion) ?? version(versionText), + composeProvider: podman ? "docker-compose" : "docker compose", + composeVersion: version(composeText), + rootless: podman ? nullableBoolean(host?.security?.rootless) : Array.isArray(info?.SecurityOptions) ? info.SecurityOptions.some((option) => /^name=rootless(?:,|$)/.test(option)) : null, + cgroupVersion: cgroupVersion(podman ? host?.cgroupVersion : info?.CgroupVersion), + cpuLimitsDelegated: podman ? controllers === null ? null : controllers.includes("cpu") : nullableBoolean(info?.CpuCfsQuota), + memoryLimitsDelegated: podman ? controllers === null ? null : controllers.includes("memory") : nullableBoolean(info?.MemoryLimit), + cpuCount: positiveNumber(podman ? host?.cpus : info?.NCPU), + memoryBytes: positiveNumber(podman ? host?.memTotal : info?.MemTotal), + freeDiskBytes: allocated !== null && used !== null && allocated >= used ? allocated - used : null + }; + const checks = []; + const add = (name, ok, detail) => checks.push({ name, ok, detail }); + if (podman) { + const [major] = (facts.version ?? "").split(".").map(Number); + const [composeMajor, composeMinor] = (facts.composeVersion ?? "").split(".").map(Number); + add("Podman version", major >= 5, `${facts.version ?? "unavailable"}; Podman >= 5 required`); + add("Podman Compose", composeMajor === 2 && composeMinor >= 20, `docker-compose ${facts.composeVersion ?? "unavailable"}; docker-compose v2 >= 2.20 required on PATH`); + const engineAvailable = host && typeof host === "object" && !Array.isArray(host) && Object.keys(host).length > 0 && platform !== "win32"; + add("Podman Engine", Boolean(engineAvailable), engineAvailable ? "available" : podmanInstallHelp(platform)); + const apiReady = await captureProbe(execute, { command: "podman", args: ["compose", "ls", "--format", "json"], env: PODMAN_COMPOSE_ENV }) !== null; + add("Podman Compose API", apiReady, apiReady ? "API socket reachable" : platform === "darwin" + ? "Podman API socket unavailable. Run podman machine start and select its rootless connection." + : "Podman API socket unavailable. Run systemctl --user enable --now podman.socket before installing or starting SpaceApp."); + add("Rootless containers", facts.rootless === true, facts.rootless === true ? "rootless" : facts.rootless === false ? "rootful; use Podman as your normal user and a rootless machine connection" : "unknown; rootless Podman is required"); + add("Cgroup version", facts.cgroupVersion === "v2", `${facts.cgroupVersion ?? "unknown"}; cgroup v2 required`); + add("CPU limit delegation", facts.cpuLimitsDelegated === true, `${status(facts.cpuLimitsDelegated)}${facts.cpuLimitsDelegated ? "" : "; the cpu controller must be delegated to your user before SpaceApp can apply cpus limits"}`); + add("Memory limit delegation", facts.memoryLimitsDelegated === true, `${status(facts.memoryLimitsDelegated)}${facts.memoryLimitsDelegated ? "" : "; the memory controller must be delegated to your user before SpaceApp can apply mem_limit"}`); + if (facts.cpuLimitsDelegated !== true || facts.memoryLimitsDelegated !== true) { + checks.at(-1).detail += ". Ask your administrator to enable Delegate=cpu cpuset memory pids for user@.service, then log out and back in."; + } + if (platform === "darwin") { + const machine = await inspectMachine(execute); + facts.machine = machine; + add("Podman machine", machine?.running === true && machine?.rootless === true, machine ? `${machine.name}: ${machine.running ? "running" : "stopped"}, ${machine.rootless ? "rootless" : "rootful"}` : "No active machine found. Run podman machine init and podman machine start, then select its rootless connection."); + if (profile && machine) { + const requirements = PROFILE_REQUIREMENTS[profile]; + const memoryBytes = { small: 3, medium: 5, large: 7, light: 3, standard: 3 }[profile] * GiB; + const minimumDiskBytes = requiredDiskBytes ?? requirements.freshDiskBytes; + const availableDisk = facts.freeDiskBytes; + const resize = machineResizeHelp(profile, machine.name); + add("Podman machine CPU", machine.cpuCount !== null && machine.cpuCount >= requirements.cpuCount, `${machine.cpuCount ?? "unknown"} allocated; ${requirements.cpuCount} required for ${profile}. ${resize}`); + add("Podman machine memory", machine.memoryBytes !== null && machine.memoryBytes >= memoryBytes, `${machine.memoryBytes === null ? "unknown" : (machine.memoryBytes / GiB).toFixed(1)} GiB allocated; ${memoryBytes / GiB} GiB required for ${profile}. ${resize}`); + add("Podman machine disk", availableDisk !== null && availableDisk >= minimumDiskBytes, `${availableDisk === null ? "unknown" : (availableDisk / GiB).toFixed(1)} GiB free; ${(minimumDiskBytes / GiB).toFixed(1)} GiB required for ${profile}. ${resize}`); + } + } + } else { + add("Container engine", true, `Docker ${facts.version ?? "version unknown"}`); + add("Container mode", true, facts.rootless === null ? "unknown" : facts.rootless ? "rootless" : "rootful"); + add("Cgroup version", true, facts.cgroupVersion ?? "unknown"); + add("CPU limit delegation", facts.cpuLimitsDelegated !== false, status(facts.cpuLimitsDelegated)); + add("Memory limit delegation", facts.memoryLimitsDelegated !== false, status(facts.memoryLimitsDelegated)); + } + return { engine, facts, checks, errors: checks.filter((check) => !check.ok).map((check) => `${check.name}: ${check.detail}`) }; +} diff --git a/packages/run-spaceapp/src/index.mjs b/packages/run-spaceapp/src/index.mjs index fad1eb208..80bf2a325 100644 --- a/packages/run-spaceapp/src/index.mjs +++ b/packages/run-spaceapp/src/index.mjs @@ -124,6 +124,7 @@ const CONFIG_KEYS = new Set([ "port", "telemetry", "profile", + "engine", "accessMode", "workspaces", "companionsEnabled", @@ -194,6 +195,13 @@ export function resolveInstallAccessMode(requestedMode, existingMode = "isolated throw new Error("Install access mode must be isolated or host-root."); } +export function resolveContainerEngine(engine = "docker") { + if (engine !== "docker" && engine !== "podman") { + throw new Error("Container engine must be docker or podman."); + } + return engine; +} + export function versionTuple(version) { if (typeof version !== "string") { return null; @@ -281,6 +289,8 @@ export function installResourceChecks(resources, profile = "small", { requiredDi if (!requirements) throw new Error("Unknown installation profile."); const minFreeDisk = requiredDiskBytes ?? (operation === "fresh" ? requirements.freshDiskBytes : 512 * 1024 ** 2); const engineMemoryBytes = { small: 3, medium: 5, large: 7, light: 3, standard: 3 }[profile] * 1024 ** 3; + const engineName = resources.engine === "podman" ? "Podman" : "Docker"; + const engineCpuCount = resources.engine === "podman" ? requirements.cpuCount : 4; return [ { name: "CPU", @@ -297,15 +307,16 @@ export function installResourceChecks(resources, profile = "small", { requiredDi ok: freeDiskBytes >= minFreeDisk, detail: `${formatGibibytes(freeDiskBytes)} GiB available; ${formatGibibytes(minFreeDisk)} GiB required${operation === "fresh" ? "" : " additionally for " + operation}` }, - ...(resources.engineMemoryBytes > 0 ? [{ name: "Docker memory", ok: resources.engineMemoryBytes >= engineMemoryBytes, + ...(resources.engineMemoryBytes > 0 ? [{ name: `${engineName} memory`, ok: resources.engineMemoryBytes >= engineMemoryBytes, detail: `${formatGibibytes(resources.engineMemoryBytes)} GiB allocated; ${formatGibibytes(engineMemoryBytes)} GiB required for ${profile}` }] : []), - ...(resources.engineCpuCount > 0 ? [{ name: "Docker CPU", ok: resources.engineCpuCount >= 4, - detail: `${resources.engineCpuCount} allocated; 4 required` }] : []) + ...(resources.engineCpuCount > 0 ? [{ name: `${engineName} CPU`, ok: resources.engineCpuCount >= engineCpuCount, + detail: `${resources.engineCpuCount} allocated; ${engineCpuCount} required` }] : []) ]; } export function createDefaultConfig({ version, + engine, profile = "light", accessMode = "isolated", companionsEnabled = false @@ -315,7 +326,7 @@ export function createDefaultConfig({ throw new Error("Default config requires a resolved installation profile."); } const resolvedAccessMode = resolveInstallAccessMode(accessMode); - return { + return validateConfig({ schemaVersion: CONFIG_SCHEMA_VERSION, version, previousVersion: null, @@ -323,11 +334,13 @@ export function createDefaultConfig({ port: 4911, telemetry: false, profile, + // Omitted in legacy Docker configs; new installs explicitly save their selected engine. + ...(engine !== undefined ? { engine } : {}), accessMode: resolvedAccessMode, companionsEnabled, optionalModules: "none", workspaces: [] - }; + }); } export function validateConfig(config) { @@ -364,6 +377,9 @@ export function validateConfig(config) { if (config.accessMode !== "isolated" && config.accessMode !== "host-root") { throw new Error("accessMode must be isolated or host-root."); } + if (resolveContainerEngine(config.engine) === "podman" && config.accessMode === "host-root") { + throw new Error("Podman does not support --access host-root. Use isolated access and add individual workspace folders."); + } if (typeof config.companionsEnabled !== "boolean") { throw new Error("companionsEnabled must be boolean."); } @@ -670,30 +686,36 @@ export async function userNamespacesUsable(platform = process.platform, read = ( // Docker's default seccomp profile plus user namespaces, and no AppArmor profile (docker-default denies the namespace // 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"]; +// Map the invoking host user to the application's fixed uid without changing workspace ownership. Entry points +// still initialize named volumes as namespace root, then drop privileges to spaceapp (10001:10001). +const PODMAN_USER_OPTIONS = [' userns_mode: "keep-id:uid=10001,gid=10001"', ' user: "0:0"']; export function renderWorkspaceCompose(config, { cliSandbox = false } = {}) { validateConfig(config); - const mounts = config.workspaces.flatMap((workspace) => [ + const podman = config.engine === "podman"; + const userOptions = podman ? PODMAN_USER_OPTIONS : []; + const bind = (workspace) => [ " - type: bind", ` source: ${JSON.stringify(workspace.hostPath)}`, ` target: ${JSON.stringify(workspace.containerPath)}`, - ` read_only: ${workspace.readOnly}` - ]); + ` read_only: ${workspace.readOnly}`, + // Compose 2.20 sends SELinux options through the Bind API only with create_host_path enabled. Workspace + // registration already requires an existing folder. The core/CLI share its label, including read-only binds. + ...(podman ? [" bind:", " create_host_path: true", " selinux: z"] : []) + ]; + const mounts = config.workspaces.flatMap(bind); const service = mounts.length > 0 ? [" volumes:", ...mounts] : [" volumes: []"]; + // Every consumer of the shared application volumes needs the same namespace mapping. + const sharedServices = podman ? [" spaceapp-browser:", ...userOptions, " codex-sessions-init:", ...userOptions] : []; if (config.users === undefined) { - return ["services:", " spaceapp-core:", ...service, " spaceapp-cli:", ...service, ...(cliSandbox ? CLI_SANDBOX_OPTIONS : []), ""].join("\n"); + return ["services:", " spaceapp-core:", ...userOptions, ...service, " spaceapp-cli:", ...userOptions, ...service, + ...(cliSandbox ? CLI_SANDBOX_OPTIONS : []), ...sharedServices, ""].join("\n"); } // "user enable": accounts are on, and the core starts a USER's CLIs only in that user's own container. const users = config.users; const settings = profileRuntimeSettings(config.profile); - const bind = (workspace) => [ - " - type: bind", - ` source: ${JSON.stringify(workspace.hostPath)}`, - ` target: ${JSON.stringify(workspace.containerPath)}`, - ` read_only: ${workspace.readOnly}` - ]; const userServices = users.flatMap((user) => { const slug = userSlug(user.id); return [ @@ -702,6 +724,7 @@ export function renderWorkspaceCompose(config, { cliSandbox = false } = {}) { " image: ghcr.io/oll4com/spaceapp-cli:${SPACEAPP_IMAGE_TAG}${SPACEAPP_CLI_IMAGE_DIGEST:-}", " restart: unless-stopped", " init: true", + ...userOptions, " security_opt:", " - no-new-privileges:true", ...(cliSandbox ? CLI_SANDBOX_OPTIONS.slice(1) : []), @@ -716,7 +739,7 @@ export function renderWorkspaceCompose(config, { cliSandbox = false } = {}) { ` - spaceapp-memory-${slug}:/var/lib/spaceapp/memory`, ` - spaceapp-cli-socket-${slug}:/run/spaceapp-cli`, ` - spaceapp-workspaces-${slug}:/workspaces`, - ` - ./secrets/users/${slug}/providers:/run/spaceapp-secrets/providers:ro`, + ` - ./secrets/users/${slug}/providers:/run/spaceapp-secrets/providers:ro${podman ? ",z" : ""}`, ...user.workspaces.flatMap(bind), // Its own network: internet only, never the owner's internal services (temporal, companions) or other users. " networks:", @@ -726,17 +749,20 @@ export function renderWorkspaceCompose(config, { cliSandbox = false } = {}) { return [ "services:", " spaceapp-core:", + ...userOptions, " environment:", ' SPACE_MULTI_USER_ENABLED: "true"', " SPACE_CLI_HOST_SOCKETS_FILE: /etc/spaceapp/cli-hosts.json", " volumes:", ...(mounts.length > 0 ? mounts : []), - " - ./cli-hosts.json:/etc/spaceapp/cli-hosts.json:ro", + ` - ./cli-hosts.json:/etc/spaceapp/cli-hosts.json:ro${podman ? ",z" : ""}`, ...users.map((user) => ` - spaceapp-cli-socket-${userSlug(user.id)}:/run/spaceapp-cli-users/${userSlug(user.id)}`), " spaceapp-cli:", + ...userOptions, ...service, ...(cliSandbox ? CLI_SANDBOX_OPTIONS : []), ...userServices, + ...sharedServices, ...(users.length > 0 ? ["volumes:"] : []), ...users.flatMap((user) => ["cli-state", "memory", "cli-socket", "workspaces"].map((kind) => ` spaceapp-${kind}-${userSlug(user.id)}: {}`)), ...(users.length > 0 ? ["networks:"] : []), @@ -783,6 +809,7 @@ export function renderHostAccessCompose(config) { export function composeCommand(action, root, options = {}) { validateHome(root); + const engine = resolveContainerEngine(options.engine); const stateRoot = options.stateRoot ?? root; validateHome(stateRoot); const profile = options.profile ?? "standard"; @@ -905,7 +932,9 @@ export function composeCommand(action, root, options = {}) { if (!selected) { throw new Error(`Unsupported Compose action "${action}".`); } - return { command: "docker", args: [...base, ...selected] }; + return engine === "podman" + ? { command: "podman", args: [...base, ...selected], env: { PODMAN_COMPOSE_PROVIDER: "docker-compose" } } + : { command: "docker", args: [...base, ...selected] }; } export function composeProjectName(root) { @@ -954,34 +983,35 @@ export async function writeRuntimeFiles(root, config) { export async function prepareInstallation(root, { version, + engine, profile, accessMode, companionsEnabled }) { validateHome(root); - await mkdir(root, { recursive: true, mode: 0o700 }); - await Promise.all([ - mkdir(join(root, "backups"), { recursive: true, mode: 0o700 }), - mkdir(join(root, "secrets", "providers"), { recursive: true, mode: 0o700 }) - ]); - await ensureOwnerOnlySecrets(root); + if (engine !== undefined) resolveContainerEngine(engine); const resolvedProfile = profile === undefined ? undefined : resolveInstallProfile(profile, profile === "auto" ? await inspectSystemResources(root) : totalmem()); let config; try { config = await loadConfig(root); + if (engine !== undefined && engine !== resolveContainerEngine(config.engine)) { + throw new Error(`Cannot switch an existing installation from ${resolveContainerEngine(config.engine)} to ${engine}: the engines do not share volumes. Keep this installation's engine and use a separate SPACEAPP_HOME for a new installation.`); + } } catch (error) { if (error?.code !== "ENOENT") { throw error; } config = createDefaultConfig({ version, + engine, profile: resolvedProfile ?? "light", accessMode: resolveInstallAccessMode(accessMode), companionsEnabled }); } + if (engine !== undefined) config = { ...config, engine }; if (config.version !== version) { config = { ...config, @@ -1001,6 +1031,14 @@ export async function prepareInstallation(root, { } validateConfig(config); + // Validate engine and access before changing any files, permissions or secrets in an existing installation. + await mkdir(root, { recursive: true, mode: 0o700 }); + await Promise.all([ + mkdir(join(root, "backups"), { recursive: true, mode: 0o700 }), + mkdir(join(root, "secrets", "providers"), { recursive: true, mode: 0o700 }) + ]); + await ensureOwnerOnlySecrets(root); + const postgresPasswordPath = join(root, "secrets", "postgres-password"); let postgresPassword; try { @@ -1065,13 +1103,41 @@ export async function commitInstallation(root, config, { validateConfig(config); const composeTemplate = await readFile(join(templateDir, "compose.yml"), "utf8"); await mkdir(root, { recursive: true, mode: 0o700 }); - await atomicWrite(join(root, "compose.yml"), composeTemplate); + await atomicWrite(join(root, "compose.yml"), renderEngineCompose(composeTemplate, config)); await mkdir(projectRoot, { recursive: true, mode: 0o700 }); await atomicWrite(join(projectRoot, "seccomp-cli.json"), await readFile(join(defaultTemplateDir(), "seccomp-cli.json"), "utf8")); await writeRuntimeFiles(root, config); if (persistConfig) await saveConfig(root, config); } +/** Adapt the bundled template's host binds for SELinux while keeping Docker's template byte-for-byte intact. */ +export function renderEngineCompose(template, config) { + validateConfig(config); + if (config.engine !== "podman") return template; + const files = new Map([...template.matchAll(/^ {2}([a-z][a-z0-9-]*):\n {4}file: (\.\/[^\r\n]+)$/gm)] + .map((match) => [match[1], match[2]])); + // Compose implements file secrets with bind mounts but offers no SELinux label option on secrets. On Podman, + // mount those same owner-protected files read-only at the same targets with a shared label (postgres/temporal + // and core/browser share secrets). Do not disable SELinux on entire containers to work around these binds. + return template.replace(/^ {2}[a-z][a-z0-9-]*:\n(?:(?: {4}.*| {2}#.*|[ \t]*)\n)*/gm, (service) => { + const secrets = /^ {4}secrets:\n((?: {6}- [a-z][a-z0-9-]*\n)+)/m.exec(service); + let result = service; + if (secrets) { + const mounts = secrets[1].trim().split("\n").map((line) => { + const name = line.trim().slice(2); + if (!files.has(name)) throw new Error(`Podman requires a file source for Compose secret "${name}".`); + return ` - ${files.get(name)}:/run/secrets/${name}:ro,z\n`; + }).join(""); + result = result.replace(secrets[0], ""); + result = /^ {4}volumes:\n/m.test(result) + ? result.replace(/^ {4}volumes:\n/m, ` volumes:\n${mounts}`) + : `${result} volumes:\n${mounts}`; + } + return result.replace(/^( {6}- \.\/[^:\n]+:[^:\n]+)(:ro)?$/gm, + (_line, mount, readOnly) => `${mount}:${readOnly ? "ro,z" : "z"}`); + }); +} + export async function initializeInstallation(root, options) { const result = await prepareInstallation(root, options); await commitInstallation(root, result.config, { diff --git a/packages/run-spaceapp/src/storage.mjs b/packages/run-spaceapp/src/storage.mjs index c8b4cfadc..1b3152f83 100644 --- a/packages/run-spaceapp/src/storage.mjs +++ b/packages/run-spaceapp/src/storage.mjs @@ -95,16 +95,21 @@ async function installedImageDigests(root, version) { return digests; } -export async function inspectStoragePlan({ root, config, existingConfig, resources, execute, operation, fetch = globalThis.fetch }) { - const dockerInfo = await captureProbe(execute, { command: "docker", args: ["info", "--format", "{{json .}}"] }); +export async function inspectStoragePlan({ root, config, existingConfig, resources, execute, operation, fetch = globalThis.fetch, platform = process.platform }) { + const selectedEngine = config.engine ?? "docker"; + if (!["docker", "podman"].includes(selectedEngine)) throw new Error("Container engine must be docker or podman."); + const podman = selectedEngine === "podman"; + const command = podman ? "podman" : "docker"; + const engineInfo = await captureProbe(execute, { command, args: ["info", "--format", podman ? "json" : "{{json .}}"] }); let engine = null; - try { engine = JSON.parse(dockerInfo); } catch { /* Conservative reserve when the engine is unavailable. */ } + try { engine = JSON.parse(engineInfo); } catch { /* Conservative reserve when the engine is unavailable. */ } + const cachedImageId = podman ? /^(?:sha256:)?[a-f0-9]{64}$/ : /^sha256:[a-f0-9]{64}$/; const cachedLayers = new Set(); if (engine && existingConfig) { const existingDigests = await installedImageDigests(root, existingConfig.version); for (const type of ["core", "cli", "browser"]) { for (const ref of localImageRefs(type, existingConfig.version, existingDigests[type])) { - const output = await captureProbe(execute, { command: "docker", args: ["image", "inspect", "--format", "{{json .RootFS.Layers}}", ref] }); + const output = await captureProbe(execute, { command, args: ["image", "inspect", "--format", "{{json .RootFS.Layers}}", ref] }); try { for (const layer of JSON.parse(output)) cachedLayers.add(layer); break; } catch {} } } @@ -126,15 +131,15 @@ export async function inspectStoragePlan({ root, config, existingConfig, resourc const refs = ["core", "cli", "browser"].includes(type) ? localImageRefs(type, config.version) : [image]; let cached = null; for (const ref of refs) { - cached = await captureProbe(execute, { command: "docker", args: ["image", "inspect", "--format", "{{.Id}}", ref] }); - if (/^sha256:[a-f0-9]{64}$/.test(cached ?? "")) break; + cached = await captureProbe(execute, { command, args: ["image", "inspect", "--format", "{{.Id}}", ref] }); + if (cachedImageId.test(cached ?? "")) break; } - if (!/^sha256:[a-f0-9]{64}$/.test(cached ?? "")) { + if (!cachedImageId.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((podman ? engine.host?.arch : engine.Architecture) ?? "") ? "arm64" : "amd64", fetch); if (metadata) { reserve = estimateMissingLayers(metadata.layers, metadata.diffIds, cachedLayers); sharedLayersEstimated = true; } } catch { /* Offline/private registry: retain the full conservative reserve. */ } } @@ -149,30 +154,40 @@ export async function inspectStoragePlan({ root, config, existingConfig, resourc } const plan = calculateStoragePlan({ operation, profile: config.profile, missingImageBytes, databaseBytes, configBytes: await configurationBytes(root), databaseSizeKnown }); - let dockerFreeDiskBytes = null; + let engineFreeDiskBytes = null; + if (podman && Number.isFinite(engine?.store?.graphRootAllocated) && Number.isFinite(engine?.store?.graphRootUsed) + && engine.store.graphRootAllocated >= engine.store.graphRootUsed && engine.store.graphRootUsed >= 0) { + engineFreeDiskBytes = engine.store.graphRootAllocated - engine.store.graphRootUsed; + } if (existingConfig) { const free = await captureProbe(execute, composeCommand("storageFree", root, existingConfig)); - if (/^\d+$/.test(free ?? "")) dockerFreeDiskBytes = Number(free); + if (/^\d+$/.test(free ?? "")) engineFreeDiskBytes = 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 filesystem directly even on a fresh install. + const storageRoot = podman ? engine?.store?.graphRoot : engine?.DockerRootDir; + if (typeof storageRoot === "string" && storageRoot && platform === "linux") { const { inspectSystemResources } = await import("./index.mjs"); - try { dockerFreeDiskBytes = (await inspectSystemResources(engine.DockerRootDir)).freeDiskBytes; } catch {} + try { engineFreeDiskBytes = (await inspectSystemResources(storageRoot)).freeDiskBytes; } catch {} } - return { ...plan, missingImages, sharedLayersEstimated, dockerFreeDiskBytes, + return { ...plan, engine: selectedEngine, missingImages, sharedLayersEstimated, engineFreeDiskBytes, + // Retain the old field for callers consuming existing Docker storage plans. + dockerFreeDiskBytes: engineFreeDiskBytes, resources: { ...resources, - freeDiskBytes: dockerFreeDiskBytes === null ? resources.freeDiskBytes : Math.min(resources.freeDiskBytes, dockerFreeDiskBytes), - engineCpuCount: engine?.NCPU, - engineMemoryBytes: engine?.MemTotal + engine: selectedEngine, + freeDiskBytes: engineFreeDiskBytes === null ? resources.freeDiskBytes : Math.min(resources.freeDiskBytes, engineFreeDiskBytes), + engineCpuCount: podman ? engine?.host?.cpus : engine?.NCPU, + engineMemoryBytes: podman ? engine?.host?.memTotal : engine?.MemTotal } }; } export function storageChecks(plan, resources) { + const engineName = plan.engine === "podman" ? "Podman" : "Docker"; + const engineFreeDiskBytes = plan.engineFreeDiskBytes ?? plan.dockerFreeDiskBytes ?? null; 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, - 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." }); + if (engineFreeDiskBytes !== null) checks.push({ name: `${engineName} storage`, ok: engineFreeDiskBytes >= plan.requiredDiskBytes, + detail: `${gib(engineFreeDiskBytes)} GiB available; ${gib(plan.requiredDiskBytes)} GiB required additionally` }); + else checks.push({ name: `${engineName} storage`, ok: true, detail: `Separate engine disk capacity unavailable; host estimate used. ${engineName} ENOSPC will stop the operation before cutover.` }); return checks; } diff --git a/packages/run-spaceapp/tests/engines.test.mjs b/packages/run-spaceapp/tests/engines.test.mjs new file mode 100644 index 000000000..cb0da156c --- /dev/null +++ b/packages/run-spaceapp/tests/engines.test.mjs @@ -0,0 +1,222 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { detectContainerEngine, inspectEngine, podmanInstallHelp } from "../src/engines.mjs"; + +const GiB = 1024 ** 3; +function podmanInfo(overrides = {}) { + return { + host: { cpus: 8, memTotal: 8 * GiB, security: { rootless: true }, cgroupVersion: "v2", cgroupControllers: ["cpu", "memory", "pids"], ...overrides.host }, + version: { Version: "5.2.3", ...overrides.version }, + store: { graphRootAllocated: 100 * GiB, graphRootUsed: 20 * GiB, ...overrides.store } + }; +} + +function engineProbe({ info = podmanInfo(), engineVersion = "podman version 5.2.3", composeVersion = "2.29.2", machine } = {}) { + const calls = []; + const execute = async (spec, io) => { + calls.push(spec); + let output; + if (spec.args[0] === "--version") output = engineVersion; + else if (spec.args[0] === "info") output = JSON.stringify(info); + else if (spec.args[0] === "compose") output = spec.args[1] === "version" ? composeVersion : "[]"; + else if (spec.args[0] === "machine" && spec.args[1] === "info") output = JSON.stringify({ Host: { DefaultMachine: machine?.Name } }); + else if (spec.args[0] === "machine" && spec.args[1] === "inspect") output = JSON.stringify([machine]); + if (output === undefined) return 1; + io.stdout.write(output); + return 0; + }; + return { execute, calls }; +} + +test("Docker remains the default when both engines are installed", async () => { + const calls = []; + const selected = await detectContainerEngine({ execute: async (spec) => { calls.push(spec); return 0; }, platform: "linux" }); + assert.equal(selected.engine, "docker"); + assert.deepEqual(calls.map((spec) => spec.command), ["docker"]); +}); + +test("Docker CLI failure does not silently move an existing engine choice", async () => { + const selected = await detectContainerEngine({ execute: async () => 1, platform: "linux" }); + assert.equal(selected.engine, "docker"); +}); + +test("podman-docker compatibility shim selects confirmed native Podman", async () => { + const calls = []; + const selected = await detectContainerEngine({ platform: "linux", execute: async (spec, io) => { + calls.push(spec.command); + io.stdout.write("podman version 5.4.2\n"); + return 0; + } }); + assert.equal(selected.engine, "podman"); + assert.deepEqual(calls, ["docker", "podman"], "each executable is probed once"); +}); + +test("a shim cannot select Podman without a working native executable", async () => { + const selected = await detectContainerEngine({ platform: "linux", execute: async (spec, io) => { + if (spec.command === "podman") return 127; + io.stdout.write("podman version 5.4.2\n"); + return 0; + } }); + assert.equal(selected.engine, "docker"); +}); + +test("a real Docker version preserves its default without probing Podman", async () => { + const calls = []; + const selected = await detectContainerEngine({ platform: "linux", execute: async (spec, io) => { + calls.push(spec.command); + io.stdout.write("Docker version 27.5.1, build 9f9e405\n"); + return 0; + } }); + assert.equal(selected.engine, "docker"); + assert.deepEqual(calls, ["docker"]); +}); + +test("Podman is detected on Linux and macOS only when Docker is absent", async () => { + for (const platform of ["linux", "darwin"]) { + const selected = await detectContainerEngine({ platform, execute: async (spec, io) => { + if (spec.command === "docker") return 127; + io.stdout.write("podman version 5.2.3"); + return 0; + } }); + assert.deepEqual(selected, { engine: "podman", detected: true, reason: "podman-available" }); + } +}); + +test("neither engine and Windows preserve the Docker installation path", async () => { + assert.equal((await detectContainerEngine({ platform: "linux", execute: async () => 127 })).engine, "docker"); + const calls = []; + assert.equal((await detectContainerEngine({ platform: "win32", execute: async (spec) => { calls.push(spec); return 127; } })).engine, "docker"); + assert.equal(calls.length, 1); +}); + +test("explicit engine choice avoids detection and rejects unsupported inputs", async () => { + const execute = async () => { throw new Error("unexpected probe"); }; + for (const requestedEngine of ["docker", "podman"]) { + assert.equal((await detectContainerEngine({ execute, platform: "linux", requestedEngine })).engine, requestedEngine); + } + await assert.rejects(detectContainerEngine({ execute, requestedEngine: "sh" }), /must be docker or podman/); + await assert.rejects(detectContainerEngine({ execute, platform: "win32", requestedEngine: "podman" }), /Linux and macOS only/); +}); + +test("missing executable exceptions still permit Podman discovery", async () => { + const execute = async (spec, io) => { + if (spec.command === "docker") throw Object.assign(new Error("missing"), { code: "ENOENT" }); + io.stdout.write("podman version 5.2.3"); + return 0; + }; + assert.equal((await detectContainerEngine({ execute, platform: "linux" })).engine, "podman"); +}); + +test("an empty successful command is not mistaken for an installed Podman CLI", async () => { + const selected = await detectContainerEngine({ platform: "darwin", execute: async (spec) => spec.command === "docker" ? 127 : 0 }); + assert.equal(selected.engine, "docker"); +}); + +test("Podman inspection enforces a static Compose v2 provider and reports rootless limits", async () => { + const { execute, calls } = engineProbe(); + const result = await inspectEngine({ engine: "podman", execute, platform: "linux" }); + assert.deepEqual(result.errors, []); + assert.equal(result.facts.version, "5.2.3"); + assert.equal(result.facts.rootless, true); + assert.equal(result.facts.cgroupVersion, "v2"); + assert.equal(result.facts.cpuLimitsDelegated, true); + assert.equal(result.facts.memoryLimitsDelegated, true); + assert.equal(result.facts.freeDiskBytes, 80 * GiB); + assert.ok(calls.every((spec) => spec.command === "podman")); + assert.deepEqual(calls.find((spec) => spec.args[0] === "compose").env, { PODMAN_COMPOSE_PROVIDER: "docker-compose" }); +}); + +test("old Podman, old Compose, rootful mode and missing delegation fail before runtime launch", async () => { + const { execute } = engineProbe({ + info: podmanInfo({ version: { Version: "4.9.4" }, host: { security: { rootless: false }, cgroupVersion: "v1", cgroupControllers: ["pids"] } }), + composeVersion: "2.19.1" + }); + const result = await inspectEngine({ engine: "podman", execute, platform: "linux" }); + for (const name of ["Podman version", "Podman Compose", "Rootless containers", "Cgroup version", "CPU limit delegation", "Memory limit delegation"]) { + assert.equal(result.checks.find((check) => check.name === name).ok, false, name); + } + assert.match(result.errors.join("\n"), /Delegate=cpu cpuset memory pids/); +}); + +test("a stopped Podman API socket fails preflight even when native engine info succeeds", async () => { + const probe = engineProbe(); + const result = await inspectEngine({ engine: "podman", platform: "linux", execute: (spec, io) => { + if (spec.args[0] === "compose" && spec.args[1] === "ls") return 1; + return probe.execute(spec, io); + } }); + assert.equal(result.checks.find((check) => check.name === "Podman Engine").ok, true); + assert.match(result.errors.join("\n"), /systemctl --user enable --now podman.socket/); +}); + +test("unavailable or invalid Podman probes fail closed without raw probe data", async () => { + const { execute } = engineProbe({ info: null, engineVersion: "secret-token-value", composeVersion: "podman-compose version 1.3.0" }); + const result = await inspectEngine({ engine: "podman", execute, platform: "linux" }); + assert.equal(result.facts.version, null); + assert.ok(result.errors.length > 0); + assert.doesNotMatch(JSON.stringify(result), /secret-token-value/); + const empty = await inspectEngine({ engine: "podman", execute: engineProbe({ info: {} }).execute, platform: "linux" }); + assert.equal(empty.checks.find((check) => check.name === "Podman Engine").ok, false); +}); + +test("engine diagnostics allowlist excludes paths, registry authentication and hostname", async () => { + const info = { ...podmanInfo(), registries: { auth: "secret-registry-token" } }; + info.host.hostname = "private-host-name"; + info.store.graphRoot = "/home/private-person/containers"; + const result = await inspectEngine({ engine: "podman", execute: engineProbe({ info }).execute, platform: "linux" }); + assert.doesNotMatch(JSON.stringify(result), /secret-registry-token|private-host-name|private-person/); +}); + +test("Docker diagnostics use Docker facts and tolerate unknown optional fields", async () => { + const { execute, calls } = engineProbe({ info: { ServerVersion: "28.0.1", SecurityOptions: ["name=rootless"], CgroupVersion: "2", CpuCfsQuota: true, MemoryLimit: true, NCPU: 4, MemTotal: 8 * GiB }, engineVersion: "Docker version 28.0.1", composeVersion: "2.34.0" }); + const result = await inspectEngine({ execute, platform: "linux" }); + assert.deepEqual(result.errors, []); + assert.equal(result.facts.rootless, true); + assert.equal(result.facts.cgroupVersion, "v2"); + assert.ok(calls.every((spec) => spec.command === "docker" && !spec.env)); + const unknown = await inspectEngine({ execute: async () => 0, platform: "linux" }); + assert.deepEqual(unknown.errors, []); + assert.equal(unknown.facts.version, null); +}); + +test("macOS checks the active machine CPU, memory and available disk against the profile", async () => { + const machine = { Name: "spaceapp-machine", State: "running", Rootful: false, Resources: { CPUs: 4, Memory: 4096, DiskSize: 100 } }; + const { execute, calls } = engineProbe({ machine }); + const result = await inspectEngine({ engine: "podman", execute, platform: "darwin", profile: "small" }); + assert.deepEqual(result.errors, []); + assert.equal(result.facts.machine.memoryBytes, 4 * GiB); + assert.equal(result.facts.machine.diskBytes, 100 * GiB); + assert.deepEqual(calls.find((spec) => spec.args[1] === "inspect").args, ["machine", "inspect", "spaceapp-machine"]); +}); + +test("undersized machines explain resize and require actual free disk, not total disk", async () => { + const machine = { Name: "spaceapp-machine", State: "running", Rootful: false, Resources: { CPUs: 2, Memory: 2048, DiskSize: 100 } }; + const { execute } = engineProbe({ machine, info: podmanInfo({ store: { graphRootUsed: 99 * GiB } }) }); + const result = await inspectEngine({ engine: "podman", execute, platform: "darwin", profile: "small" }); + for (const name of ["Podman machine CPU", "Podman machine memory", "Podman machine disk"]) assert.equal(result.checks.find((check) => check.name === name).ok, false); + assert.match(result.errors.join("\n"), /podman machine set --cpus 4 --memory 3072 --disk-size 20 spaceapp-machine/); + assert.match(result.errors.join("\n"), /Other VM providers/); +}); + +test("macOS maintenance uses additional required disk instead of fresh image reserve", async () => { + const machine = { Name: "spaceapp-machine", State: "running", Rootful: false, Resources: { CPUs: 4, Memory: 4096, DiskSize: 100 } }; + const { execute } = engineProbe({ machine, info: podmanInfo({ store: { graphRootUsed: 99 * GiB } }) }); + const fresh = await inspectEngine({ engine: "podman", execute, platform: "darwin", profile: "small" }); + assert.equal(fresh.checks.find((check) => check.name === "Podman machine disk").ok, false); + const maintenance = await inspectEngine({ engine: "podman", execute, platform: "darwin", profile: "small", requiredDiskBytes: 512 * 1024 ** 2 }); + assert.deepEqual(maintenance.errors, []); + assert.match(maintenance.checks.find((check) => check.name === "Podman machine disk").detail, /0\.5 GiB required/); +}); + +test("macOS refuses missing and rootful machines", async () => { + const missing = await inspectEngine({ engine: "podman", execute: engineProbe().execute, platform: "darwin", profile: "small" }); + assert.equal(missing.checks.find((check) => check.name === "Podman machine").ok, false); + const machine = { Name: "spaceapp-machine", State: "running", Rootful: true, Resources: { CPUs: 4, Memory: 4096, DiskSize: 100 } }; + const rootful = await inspectEngine({ engine: "podman", execute: engineProbe({ machine }).execute, platform: "darwin" }); + assert.equal(rootful.checks.find((check) => check.name === "Podman machine").ok, false); +}); + +test("Podman help explains prerequisites without installing the engine", () => { + assert.match(podmanInstallHelp("linux"), /systemctl --user enable --now podman.socket/); + assert.match(podmanInstallHelp("darwin"), /podman machine start/); + assert.match(podmanInstallHelp("win32"), /Use Docker on Windows/); +}); diff --git a/packages/run-spaceapp/tests/podman-cli.test.mjs b/packages/run-spaceapp/tests/podman-cli.test.mjs new file mode 100644 index 000000000..8319547b0 --- /dev/null +++ b/packages/run-spaceapp/tests/podman-cli.test.mjs @@ -0,0 +1,174 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { mkdtemp, readFile, rm, mkdir, writeFile, chmod } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { Readable, Writable } from "node:stream"; +import { run, executeCommand } from "../src/cli.mjs"; +import { initializeInstallation, loadConfig } from "../src/index.mjs"; + +const GiB = 1024 ** 3; +const podmanInfo = { + version: { Version: "5.4.2" }, + host: { security: { rootless: true }, cgroupVersion: "v2", cgroupControllers: ["cpu", "memory", "pids"], cpus: 8, memTotal: 16 * GiB }, + store: { graphRootAllocated: 100 * GiB, graphRootUsed: 10 * GiB } +}; + +async function fixture(t, { dockerPresent = false, info = podmanInfo } = {}) { + const root = await mkdtemp(join(tmpdir(), "spaceapp-podman-cli-")); + t.after(() => rm(root, { recursive: true, force: true })); + const calls = []; + let text = ""; + const output = new Writable({ write(chunk, _encoding, done) { text += chunk; done(); } }); + const options = { + env: { SPACEAPP_HOME: root, SPACEAPP_TELEMETRY: "0" }, platform: "linux", + stdin: Readable.from([]), stdout: output, stderr: output, + prepareDockerPath: async () => {}, + ensureDocker: async () => { assert.ok(dockerPresent, "Podman installs must never install Docker"); return { code: 0 }; }, + inspectResources: async () => ({ cpuCount: 8, totalMemoryBytes: 16 * GiB, freeDiskBytes: 40 * GiB }), + sleep: async () => {}, + request: async (url) => ({ ok: true, status: 200, json: async () => url.endsWith("/readyz") ? { ok: true } : { setupRequired: false, expiresAt: null } }), + execute: async (spec, io) => { + calls.push(spec); + if (spec.command === "docker" && !dockerPresent) return 127; + const args = spec.args; + if (args[0] === "--version") io?.stdout?.write(spec.command === "podman" ? "podman version 5.4.2" : "Docker version 27.0.0"); + else if (args[0] === "info") io?.stdout?.write(JSON.stringify(spec.command === "podman" ? info : { ServerVersion: "27.0.0", NCPU: 8, MemTotal: 16 * GiB })); + else if (args[0] === "compose" && args[1] === "version") io?.stdout?.write("2.29.2"); + else if (args.includes("image")) return 1; + else if (args.includes("pg_dump")) io?.stdout?.write("-- PostgreSQL database dump\nSELECT 1;\n"); + return 0; + } + }; + return { root, calls, options, text: () => text }; +} +const automation = ["--non-interactive", "--answers", '{"confirm":true,"open":false,"profile":"small"}']; + +for (const selection of ["flag", "answers", "automatic"]) { + test(`Podman ${selection} install persists engine and dispatches every runtime command to Podman`, async (t) => { + const f = await fixture(t); + const args = selection === "flag" ? ["--engine", "podman", ...automation] + : selection === "answers" ? ["--non-interactive", "--answers", '{"engine":"podman","confirm":true,"open":false,"profile":"small"}'] : automation; + assert.equal(await run(["install", ...args], f.options), 0, f.text()); + assert.equal((await loadConfig(f.root)).engine, "podman"); + const compose = f.calls.filter((spec) => spec.args.includes("compose")); + assert.ok(compose.some((spec) => spec.args.includes("up"))); + assert.ok(compose.every((spec) => spec.command === "podman")); + assert.ok(compose.every((spec) => spec.env.PODMAN_COMPOSE_PROVIDER === "docker-compose")); + }); +} + +test("a working Docker remains the default when both engines are installed", async (t) => { + const f = await fixture(t, { dockerPresent: true }); + assert.equal(await run(["install", ...automation], f.options), 0, f.text()); + assert.equal((await loadConfig(f.root)).engine, "docker"); + assert.ok(!f.calls.some((spec) => spec.command === "podman")); +}); + +for (const installed of ["docker", "podman"]) { + for (const source of ["flag", "answers"]) { + test(`refuse switching ${installed} via ${source} before any mutation or engine command`, async (t) => { + const f = await fixture(t); + await initializeInstallation(f.root, { engine: installed, version: "1.1.7" }); + const before = await readFile(join(f.root, "config.json"), "utf8"); + const target = installed === "docker" ? "podman" : "docker"; + const args = source === "flag" ? ["--engine", target, ...automation] + : ["--non-interactive", "--answers", JSON.stringify({ engine: target, confirm: true })]; + await assert.rejects(run(["install", ...args], f.options), /Cannot switch engines/); + assert.equal(await readFile(join(f.root, "config.json"), "utf8"), before); + assert.deepEqual(f.calls, []); + }); + } +} + +test("Podman rejects host-root and unsupported engine names before writing an installation", async (t) => { + const f = await fixture(t); + await assert.rejects(run(["install", "--engine", "podman", "--access", "host-root", ...automation], f.options), /host-root.*not supported.*Podman/); + await assert.rejects(run(["install", "--engine", "nerdctl", ...automation], f.options), /Usage/); + await assert.rejects(loadConfig(f.root), { code: "ENOENT" }); +}); + +test("missing limit delegation fails before pulling images or committing configuration", async (t) => { + const f = await fixture(t, { info: { ...podmanInfo, host: { ...podmanInfo.host, cgroupControllers: ["pids"] } } }); + assert.equal(await run(["install", "--engine", "podman", ...automation], f.options), 1); + assert.match(f.text(), /FAIL.*(?:CPU|memory|limits)/i); + assert.ok(!f.calls.some((spec) => spec.args.includes("pull"))); + await assert.rejects(loadConfig(f.root), { code: "ENOENT" }); +}); + +for (const command of [["up"], ["down"], ["status"], ["logs"], ["backup"], ["rollback"], ["doctor", "--fix"], ["provider", "install", "opencode"], ["uninstall"], ["uninstall", "--purge-data"]]) { + test(`saved Podman engine is used by ${command.join(" ")}`, async (t) => { + const f = await fixture(t); + await initializeInstallation(f.root, { engine: "podman", version: "1.1.7", profile: "small" }); + const path = join(f.root, "config.json"); + const config = JSON.parse(await readFile(path, "utf8")); + await writeFile(path, JSON.stringify({ ...config, previousVersion: "1.1.6" })); + assert.equal(await run([...command, "--non-interactive", "--answers", '{"confirm":true,"purgeConfirm":"DELETE"}'], f.options), 0, f.text()); + assert.ok(f.calls.some((spec) => spec.args.includes("compose"))); + assert.ok(f.calls.every((spec) => spec.command === "podman"), JSON.stringify(f.calls)); + }); +} + +test("Podman update checkpoint and failed-cutover recovery stay on the saved engine", async (t) => { + const f = await fixture(t); + await initializeInstallation(f.root, { engine: "podman", version: "1.1.6", profile: "small" }); + const execute = f.options.execute; + f.options.execute = async (spec, io) => { + const code = await execute(spec, io); + return spec.args.includes("up") && !spec.args.includes("--wait") ? 17 : code; + }; + assert.equal(await run(["update", "1.1.7", ...automation], f.options), 1); + assert.ok(f.calls.some((spec) => spec.args.includes("pg_dump"))); + assert.ok(f.calls.every((spec) => spec.command === "podman")); + assert.match(f.text(), /RECOVERY_REQUIRED/); +}); + +test("Podman workspace, doctor and support bundle preserve engine and expose sanitized facts", async (t) => { + const f = await fixture(t); + await initializeInstallation(f.root, { engine: "podman", version: "1.1.7", profile: "small" }); + const workspace = join(f.root, "project"); + await mkdir(workspace); + assert.equal(await run(["workspace", "add", workspace], f.options), 0); + assert.match(await readFile(join(f.root, "compose.workspaces.yml"), "utf8"), /selinux: z/); + assert.doesNotMatch(f.text(), /sudo setfacl/); + assert.equal(await run(["doctor"], f.options), 0, f.text()); + assert.match(f.text(), /5\.4\.2/); + assert.match(f.text(), /rootless/i); + const out = join(f.root, "diagnostics.json"); + assert.equal(await run(["support-bundle", "--out", out], f.options), 0); + const bundle = JSON.parse(await readFile(out, "utf8")); + assert.equal(bundle.engine.engine, "podman"); + assert.equal(bundle.engine.rootless, true); + assert.equal(bundle.engine.cgroupVersion, "v2"); + assert.equal(bundle.engine.cpuLimitsDelegated, true); + assert.equal(bundle.engine.memoryLimitsDelegated, true); + assert.ok(!f.calls.some((spec) => spec.command === "docker")); +}); + +test("Podman dispatch remains a literal executable and rejects arbitrary compose providers", async () => { + const source = await readFile(new URL("../src/cli.mjs", import.meta.url), "utf8"); + assert.match(source, /case "podman": return spawn\("podman", args, options\)/); + assert.doesNotMatch(source, /spawn\(\s*spec\.command/); + await assert.rejects(executeCommand({ command: "podman", args: ["compose", "version"], env: { PODMAN_COMPOSE_PROVIDER: "/tmp/untrusted" } }), /untrusted command environment/); +}); + +test("Podman execution cannot inherit a Docker endpoint or context", { skip: process.platform === "win32" }, async (t) => { + const f = await fixture(t); + const executable = join(f.root, "podman"); + await writeFile(executable, '#!/bin/sh\nprintf "%s|%s|%s" "${DOCKER_HOST-unset}" "${DOCKER_CONTEXT-unset}" "$PODMAN_COMPOSE_PROVIDER"\n'); + await chmod(executable, 0o700); + const previous = { PATH: process.env.PATH, DOCKER_HOST: process.env.DOCKER_HOST, DOCKER_CONTEXT: process.env.DOCKER_CONTEXT }; + try { + process.env.PATH = `${f.root}:${process.env.PATH}`; + process.env.DOCKER_HOST = "unix:///var/run/docker.sock"; + process.env.DOCKER_CONTEXT = "production-docker"; + assert.equal(await executeCommand({ command: "podman", args: ["compose", "version"], env: { PODMAN_COMPOSE_PROVIDER: "docker-compose" } }, { stdout: f.options.stdout }), 0); + assert.equal(f.text(), "unset|unset|docker-compose"); + assert.equal(process.env.DOCKER_CONTEXT, "production-docker", "parent environment remains unchanged"); + } finally { + for (const [key, value] of Object.entries(previous)) { + if (value === undefined) delete process.env[key]; + else process.env[key] = value; + } + } +}); diff --git a/packages/run-spaceapp/tests/podman-compose.test.mjs b/packages/run-spaceapp/tests/podman-compose.test.mjs new file mode 100644 index 000000000..404a91f7e --- /dev/null +++ b/packages/run-spaceapp/tests/podman-compose.test.mjs @@ -0,0 +1,177 @@ +import assert from "node:assert/strict"; +import { chmod, mkdtemp, mkdir, readFile, readdir, rm, stat, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join, resolve } from "node:path"; +import test from "node:test"; +import { + addUser, + composeCommand, + createDefaultConfig, + initializeInstallation, + installResourceChecks, + loadConfig, + prepareInstallation, + renderEngineCompose, + renderHostAccessCompose, + renderWorkspaceCompose, + resolveInstallProfile, + saveConfig, + userSlug, + validateConfig +} from "../src/index.mjs"; + +const version = "1.1.7"; +const home = resolve(tmpdir(), "spaceapp-podman-home"); +const workspace = { + id: "project-a1b2c3d4", name: "project", hostPath: "/home/alice/project", + containerPath: "/workspaces/project", readOnly: false +}; + +async function temporaryHome(t) { + const root = await mkdtemp(join(tmpdir(), "spaceapp-podman-")); + t.after(() => rm(root, { recursive: true, force: true })); + return root; +} + +test("both engines construct identical bounded Compose actions and Podman pins its provider", () => { + const actions = [ + "up", "down", "status", "logs", "pull", "syncCredentials", "syncWorkspaces", "exportCliSettings", "importCliSettings", + "cliHostReady", "exportMemory", "importMemory", "installClaude", "installProvider", "backup", "restore", "pauseWriters", + "unpauseWriters", "stopForRestore", "stopForPortableRestore", "resetOwnerPassword", "rotateOwnerSetupToken", "removeBrowser", + "removeTemporal", "enableTemporal", "databaseSize", "storageFree", "purge", "repair", "ensureCheckpointDatabase", + "checkpointDump", "checkpointRestore" + ]; + for (const profile of ["small", "medium", "large"]) { + for (const action of actions) { + const options = { profile, provider: "opencode", backupId: "spaceapp-backup-20261024T120000000Z" }; + const docker = composeCommand(action, home, { ...options, engine: "docker" }); + const podman = composeCommand(action, home, { ...options, engine: "podman" }); + assert.equal(docker.command, "docker", action); + assert.equal(docker.env, undefined, action); + assert.equal(podman.command, "podman", action); + assert.deepEqual(podman.args, docker.args, action); + assert.deepEqual(podman.env, { PODMAN_COMPOSE_PROVIDER: "docker-compose" }); + assert.equal(podman.args[0], "compose"); + } + } + assert.ok(composeCommand("ensureCheckpointDatabase", home, { engine: "podman" }).args.includes("--wait")); + assert.ok(composeCommand("installProvider", home, { engine: "podman", provider: "opencode" }).args.includes("10001:10001")); + for (const engine of ["podman; touch /tmp/no", "/usr/bin/podman", "nerdctl", "", null]) { + assert.throws(() => composeCommand("up", home, { engine }), /engine must be docker or podman/i); + } +}); + +test("Podman engine is persisted and omitted legacy engines keep Docker semantics", async (t) => { + const root = await temporaryHome(t); + const installed = await initializeInstallation(root, { version, engine: "podman", profile: "small" }); + assert.equal(installed.config.engine, "podman"); + assert.equal((await loadConfig(root)).engine, "podman"); + assert.equal((await prepareInstallation(root, { version })).config.engine, "podman"); + const legacy = createDefaultConfig({ version }); + await saveConfig(root, legacy); + const loaded = await loadConfig(root); + assert.equal(composeCommand("up", root, loaded).command, "docker"); + assert.deepEqual(loaded, legacy); + assert.throws(() => validateConfig({ ...legacy, engine: "other" }), /engine must be docker or podman/i); +}); + +test("engine switching is refused before changing config, runtime files, permissions or secrets", async (t) => { + for (const initialEngine of [undefined, "docker", "podman"]) { + const root = await temporaryHome(t); + const config = createDefaultConfig({ version, engine: initialEngine }); + await saveConfig(root, config); + await mkdir(join(root, "secrets")); + await chmod(join(root, "secrets"), 0o755); + await writeFile(join(root, "runtime.env"), "unchanged\n"); + const before = await readFile(join(root, "config.json"), "utf8"); + await assert.rejects(prepareInstallation(root, { + version: "1.1.8", engine: initialEngine === "podman" ? "docker" : "podman" + }), /cannot switch.*engines do not share volumes/i); + assert.equal(await readFile(join(root, "config.json"), "utf8"), before); + assert.equal(await readFile(join(root, "runtime.env"), "utf8"), "unchanged\n"); + assert.deepEqual(await readdir(join(root, "secrets")), []); + await assert.rejects(stat(join(root, "backups")), { code: "ENOENT" }); + if (process.platform !== "win32") assert.equal((await stat(join(root, "secrets"))).mode & 0o777, 0o755); + } +}); + +test("Podman refuses host-root access before preparing an installation", async (t) => { + const root = join(await temporaryHome(t), "not-created"); + assert.throws(() => createDefaultConfig({ version, engine: "podman", accessMode: "host-root" }), /Podman.*host-root/); + const config = { ...createDefaultConfig({ version, engine: "podman" }), accessMode: "host-root" }; + assert.throws(() => renderHostAccessCompose(config), /Podman.*host-root/); + await assert.rejects(prepareInstallation(root, { version, engine: "podman", accessMode: "host-root" }), /Podman.*host-root/); + await assert.rejects(stat(root), { code: "ENOENT" }); +}); + +test("Podman workspace binds share SELinux labels and map host ownership consistently", () => { + for (const readOnly of [false, true]) { + const config = { ...createDefaultConfig({ version, engine: "podman" }), workspaces: [{ ...workspace, readOnly }] }; + const compose = renderWorkspaceCompose(config); + assert.equal((compose.match(/selinux: z/g) ?? []).length, 2, "core and CLI both label the shared bind"); + assert.equal((compose.match(/create_host_path: true/g) ?? []).length, 2, "Compose 2.20 must use the Bind API to retain SELinux options"); + assert.equal((compose.match(new RegExp(`read_only: ${readOnly}`, "g")) ?? []).length, 2); + for (const service of ["spaceapp-core", "spaceapp-cli", "spaceapp-browser", "codex-sessions-init"]) { + assert.match(compose, new RegExp(` ${service}:\\n userns_mode: "keep-id:uid=10001,gid=10001"\\n user: "0:0"`)); + } + assert.doesNotMatch(compose, /:U|chown|privileged:|label[=:]disable/); + assert.doesNotMatch(renderWorkspaceCompose({ ...config, engine: "docker" }), /selinux:|keep-id|user: "0:0"/); + } +}); + +test("Podman user workspaces and credentials preserve isolation with the same ownership mapping", () => { + const userId = "user:Alice"; + const config = addUser(createDefaultConfig({ version, engine: "podman" }), userId); + config.users[0].workspaces = [{ ...workspace, readOnly: true }]; + const compose = renderWorkspaceCompose(config); + const slug = userSlug(userId); + const start = compose.indexOf(` spaceapp-cli-${slug}:`); + const service = compose.slice(start, compose.indexOf(" spaceapp-browser:", start)); + assert.match(service, /userns_mode: "keep-id:uid=10001,gid=10001"\n {4}user: "0:0"/); + assert.match(service, /read_only: true\n {8}bind:\n {10}create_host_path: true\n {10}selinux: z/); + assert.ok(service.includes(`./secrets/users/${slug}/providers:/run/spaceapp-secrets/providers:ro,z`)); + assert.doesNotMatch(compose.slice(0, start), /source: "\/home\/alice\/project"/); + assert.match(compose, /\.\/cli-hosts\.json:\/etc\/spaceapp\/cli-hosts\.json:ro,z/); +}); + +test("Podman runtime labels backups, provider folders and file-based secrets without changing Docker templates", async (t) => { + const template = await readFile(new URL("../templates/compose.yml", import.meta.url), "utf8"); + const config = createDefaultConfig({ version, engine: "podman", profile: "small" }); + const rendered = renderEngineCompose(template, config); + assert.equal(renderEngineCompose(template, { ...config, engine: "docker" }), template); + assert.match(rendered, /\.\/backups:\/backups:z/); + assert.match(rendered, /\.\/secrets\/providers:\/run\/spaceapp-secrets\/providers:ro,z/); + for (const name of ["database-url", "session-secret", "setup-token", "postgres-password"]) { + assert.ok(rendered.includes(`./secrets/${name}:/run/secrets/${name}:ro,z`)); + } + assert.equal((rendered.match(/\.\/secrets\/postgres-password:\/run\/secrets\/postgres-password:ro,z/g) ?? []).length, 2); + assert.doesNotMatch(rendered, /^ {4}secrets:/m, "avoid duplicate unlabeled secret bind mounts"); + assert.match(rendered, /required: false/); + assert.match(rendered, /condition: service_healthy/); + assert.match(rendered, /init: true/); + assert.match(rendered, /mem_limit:/); + assert.match(rendered, /cpus:/); + assert.match(rendered, /spaceapp-cli-socket:\/run\/spaceapp-cli/); + const root = await temporaryHome(t); + await initializeInstallation(root, { version, engine: "podman", profile: "small" }); + assert.equal(await readFile(join(root, "compose.yml"), "utf8"), rendered); +}); + +test("resource checks identify Podman VM CPU and memory limits", () => { + const resources = { engine: "podman", cpuCount: 4, totalMemoryBytes: 8 * 1024 ** 3, freeDiskBytes: 20 * 1024 ** 3, + engineCpuCount: 2, engineMemoryBytes: 2 * 1024 ** 3 }; + const checks = installResourceChecks(resources, "small"); + assert.equal(checks.find((check) => check.name === "Podman CPU")?.ok, false); + assert.equal(checks.find((check) => check.name === "Podman memory")?.ok, false); +}); + +test("automatic profiles respect Podman machine CPUs even when the host supports large", () => { + const GiB = 1024 ** 3; + const resources = { engine: "podman", cpuCount: 16, totalMemoryBytes: 32 * GiB, freeDiskBytes: 40 * GiB, + engineCpuCount: 4, engineMemoryBytes: 8 * GiB }; + assert.equal(resolveInstallProfile("auto", resources), "medium"); + assert.equal(resolveInstallProfile("auto", { ...resources, engineMemoryBytes: 4 * GiB }), "small"); + assert.equal(resolveInstallProfile("auto", { ...resources, engineCpuCount: 8 }), "large"); + assert.equal(installResourceChecks(resources, "large").find((check) => check.name === "Podman CPU")?.ok, false); + assert.equal(resolveInstallProfile("auto", { ...resources, engine: "docker" }), "large", "preserve Docker's existing VM CPU threshold"); +}); diff --git a/packages/run-spaceapp/tests/podman-machine-cli.test.mjs b/packages/run-spaceapp/tests/podman-machine-cli.test.mjs new file mode 100644 index 000000000..a993008c6 --- /dev/null +++ b/packages/run-spaceapp/tests/podman-machine-cli.test.mjs @@ -0,0 +1,62 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { mkdtemp, rm } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { Readable, Writable } from "node:stream"; +import { run } from "../src/cli.mjs"; +import { initializeInstallation, loadConfig } from "../src/index.mjs"; + +const GiB = 1024 ** 3; +async function machineFixture(t, { freeDiskGiB = 80 } = {}) { + const root = await mkdtemp(join(tmpdir(), "spaceapp-podman-machine-")); + t.after(() => rm(root, { recursive: true, force: true })); + const calls = []; + let output = ""; + const sink = new Writable({ write(chunk, _encoding, done) { output += chunk; done(); } }); + const options = { + env: { SPACEAPP_HOME: root, SPACEAPP_TELEMETRY: "0" }, platform: "darwin", + stdin: Readable.from([]), stdout: sink, stderr: sink, + prepareDockerPath: async () => {}, + inspectResources: async () => ({ cpuCount: 8, totalMemoryBytes: 16 * GiB, freeDiskBytes: 40 * GiB }), + sleep: async () => {}, + request: async () => ({ ok: true, status: 200, json: async () => ({ ok: true, setupRequired: false, expiresAt: null }) }), + execute: async (spec, io) => { + calls.push(spec); + if (spec.command === "docker") return 127; + let text = ""; + if (spec.args[0] === "--version") text = "podman version 5.4.2"; + else if (spec.args[0] === "info") text = JSON.stringify({ + version: { Version: "5.4.2" }, + host: { security: { rootless: true }, cgroupVersion: "v2", cgroupControllers: ["cpu", "memory"], cpus: 4, memTotal: 4 * GiB }, + store: { graphRootAllocated: 100 * GiB, graphRootUsed: (100 - freeDiskGiB) * GiB } + }); + else if (spec.args[0] === "compose" && spec.args[1] === "version") text = "2.29.2"; + else if (spec.args[0] === "machine" && spec.args[1] === "info") text = JSON.stringify({ Host: { DefaultMachine: "spaceapp-machine" } }); + else if (spec.args[0] === "machine" && spec.args[1] === "inspect") text = JSON.stringify([ + { Name: "spaceapp-machine", State: "running", Rootful: false, Resources: { CPUs: 4, Memory: 4096, DiskSize: 100 } } + ]); + else if (spec.args[0] === "image") return 1; + io?.stdout?.write(text); + return 0; + } + }; + return { root, options, calls, output: () => output }; +} + +test("macOS auto profile considers the machine before rejecting host-selected large limits", async (t) => { + const f = await machineFixture(t); + const code = await run(["install", "--engine", "podman", "--profile", "auto", "--non-interactive", "--answers", '{"confirm":true,"open":false}'], f.options); + assert.equal(code, 0, f.output()); + assert.equal((await loadConfig(f.root)).profile, "small"); + assert.match(f.output(), /Podman resources select profile: small/); + assert.ok(f.calls.some((spec) => spec.args.includes("up"))); + assert.ok(f.calls.every((spec) => spec.command === "podman")); +}); + +test("macOS doctor checks maintenance headroom on an already installed machine", async (t) => { + const f = await machineFixture(t, { freeDiskGiB: 2 }); + await initializeInstallation(f.root, { engine: "podman", version: "1.1.7", profile: "small" }); + assert.equal(await run(["doctor"], f.options), 0, f.output()); + assert.match(f.output(), /PASS Podman machine disk: 2\.0 GiB free; 0\.5 GiB required/); +}); diff --git a/packages/run-spaceapp/tests/storage.test.mjs b/packages/run-spaceapp/tests/storage.test.mjs index 06d11a0d7..6a90f6e59 100644 --- a/packages/run-spaceapp/tests/storage.test.mjs +++ b/packages/run-spaceapp/tests/storage.test.mjs @@ -121,3 +121,25 @@ test("an installation that pulled its images by digest is inspected by digest, s assert.ok(!inspected.includes("ghcr.io/oll4com/spaceapp-core:1.1.7"), "the pinned digest answered, the tag was not needed"); assert.ok(inspected.includes("ghcr.io/oll4com/spaceapp-cli:1.1.7"), "an unpinned image is still found by its tag"); }); + +test("Podman storage probes the selected engine and reads its rootless resource shape", async (t) => { + const root = await mkdtemp(join(tmpdir(), "spaceapp-podman-storage-")); + t.after(() => rm(root, { recursive: true, force: true })); + const calls = []; + const execute = async (spec, io) => { + calls.push(spec); + if (spec.args[0] === "info") io.stdout.write(JSON.stringify({ host: { cpus: 6, memTotal: 10 * GiB }, store: { graphRootAllocated: 40 * GiB, graphRootUsed: 39 * GiB } })); + else if (spec.args[0] === "image") io.stdout.write("a".repeat(64)); + return 0; + }; + const plan = await inspectStoragePlan({ root, config: { ...createDefaultConfig({ version: "1.1.7", profile: "small" }), engine: "podman" }, resources, execute, operation: "repair", fetch: null }); + assert.ok(calls.every((spec) => spec.command === "podman")); + assert.deepEqual(calls[0].args, ["info", "--format", "json"]); + assert.equal(plan.missingImages.length, 0, "Podman's unprefixed cached image ID is recognized"); + assert.equal(plan.engineFreeDiskBytes, GiB); + assert.equal(plan.resources.freeDiskBytes, GiB); + assert.equal(plan.resources.engineCpuCount, 6); + assert.equal(plan.resources.engineMemoryBytes, 10 * GiB); + assert.equal(plan.resources.engine, "podman"); + assert.equal(storageChecks(plan, resources).at(-1).name, "Podman storage"); +});