diff --git a/INSTALL.md b/INSTALL.md index f91312fc..02702801 100644 --- a/INSTALL.md +++ b/INSTALL.md @@ -68,9 +68,9 @@ local services, service overrides, and named volumes there. install.sh creates this file once and never changes it. Your changes survive every install and upgrade. -install.sh refreshes the repo compose files on each run. So do not edit -`compose.yaml`, `compose.local.yaml`, or `compose.remote.yaml` — those changes -are lost on the next install. +install.sh refreshes the repo compose files on each run. Do not edit +`compose.yaml` or `compose.docker-sbx.yaml`. The next install overwrites +these files. Example — bake locally-installed apps into the web image: diff --git a/backend/druks/setup_env.py b/backend/druks/setup_env.py index 0a4d6827..565b0a53 100644 --- a/backend/druks/setup_env.py +++ b/backend/druks/setup_env.py @@ -19,6 +19,8 @@ "DRUKS_WEB_BIND_HOST", "DRUKS_DOCKER_GID", "COMPOSE_FILE", + "COMPOSE_PROFILES", + "DRUKS_SBX_HOME", ) _ENV_KEY_PATTERN = re.compile(r"[A-Za-z_][A-Za-z0-9_]*") @@ -212,7 +214,7 @@ def _fresh_values(*, provider: str, home: str) -> tuple[tuple[tuple[str, ...], s # browser flows built from the endpoint (the GitHub manifest # callback's BroadcastChannel) are origin-scoped. (("urls", "endpoint"), "http://127.0.0.1:8001"), - (("sandbox", "service_url"), "http://127.0.0.1:8000"), + (("sandbox", "service_url"), "http://127.0.0.1:8780"), (("sandbox", "service_token"), "dev-token"), (("sandbox", "image"), "ghcr.io/czpython/druks-sandbox:latest"), ) @@ -354,9 +356,10 @@ def _render_env( ) -> str: provider = _get_string(config, ("sandbox", "provider")) - service_tokens = "" - if provider != "docker": - service_tokens = _get_string(config, ("sandbox", "service_token")) + # Rendered on every shape. drukbox requires SERVICE_TOKENS and does not + # start without it. A compose-side default would replace that safe stop + # with a known token. + service_tokens = _get_string(config, ("sandbox", "service_token")) sections = ( ( diff --git a/backend/tests/test_setup_env.py b/backend/tests/test_setup_env.py index 1d735f64..320cc948 100644 --- a/backend/tests/test_setup_env.py +++ b/backend/tests/test_setup_env.py @@ -122,7 +122,9 @@ def test_docker_shape_matches_local_wiring_and_ignores_provider_environment(tmp_ values = read_env(env_path) assert values["DEFAULT_HOST_PROVIDER"] == "docker" assert "DRUKS_AUTH_HEADER" not in values - assert "SERVICE_TOKENS" not in values + # Rendered on every shape. Without it, drukbox stops instead of falling + # back to a known token. + assert values["SERVICE_TOKENS"] == "dev-token" assert "DOCKER_HOST" not in values @@ -311,7 +313,8 @@ def test_compose_plane_env_additions_survive_rerender(tmp_path): _run(env_path, provider="docker") env_path.write_text( env_path.read_text() - + "DRUKS_UID=1000\nDRUKS_DOCKER_GID=988\nCOMPOSE_FILE=compose.yaml:compose.local.yaml\n" + + "DRUKS_UID=1000\nDRUKS_DOCKER_GID=988\nDRUKS_SBX_HOME=/home/op\n" + + "COMPOSE_FILE=compose.yaml:compose.override.yaml\nCOMPOSE_PROFILES=hosted\n" ) _run(env_path) @@ -319,7 +322,9 @@ def test_compose_plane_env_additions_survive_rerender(tmp_path): values = read_env(env_path) assert values["DRUKS_UID"] == "1000" assert values["DRUKS_DOCKER_GID"] == "988" - assert values["COMPOSE_FILE"] == "compose.yaml:compose.local.yaml" + assert values["DRUKS_SBX_HOME"] == "/home/op" + assert values["COMPOSE_FILE"] == "compose.yaml:compose.override.yaml" + assert values["COMPOSE_PROFILES"] == "hosted" assert "# OPERATOR ADDITIONS" in env_path.read_text() diff --git a/deploy/README.md b/deploy/README.md index f24b7820..5af59788 100644 --- a/deploy/README.md +++ b/deploy/README.md @@ -1,20 +1,34 @@ # Druks Deployment -The base `compose.yaml` is Druks (`web`, which embeds the DBOS durable engine -and serves the dashboard SPA), Postgres, and Redis. A shape overlay adds the -Drukbox sandbox control plane on top: `install.sh` writes `COMPOSE_FILE` to -`.env` (e.g. `compose.yaml:compose.remote.yaml`), so plain `docker compose` -commands in the install dir load the right pair. - -A **remote** install (any `DRUKS_PROVIDER` except `docker`) uses -`compose.remote.yaml`: the Drukbox control plane (`drukbox`, `drukbox-janitor`) -against a cloud provider, plus stock Caddy (identity edge + proxy, Caddyfile -bind-mounted). - -A **local** install (`DRUKS_PROVIDER=docker`) uses `compose.local.yaml`: one -`drukbox` with the host's Docker socket mounted, so sandboxes are sibling -containers on the host daemon, and no Caddy — the dashboard is reached directly -on `127.0.0.1:8001`. See [Full local](../docs/full-local.md). +`compose.yaml` holds the full stack: Druks (`web`, which embeds the DBOS +durable engine and serves the dashboard SPA), Postgres, Redis, the Drukbox +sandbox control plane (`drukbox`), the janitor, the SSH gateway, and the Caddy +edge. `install.sh` writes `COMPOSE_PROFILES` to `.env`. Then plain +`docker compose` commands in the install directory do the correct thing. + +A **local** install (`DRUKS_PROVIDER=docker`) runs bare, with no profiles. +`drukbox` mounts the Docker socket of the host. Sandboxes are sibling +containers on the host daemon. The dashboard is on `127.0.0.1:8001`, with no +Caddy. See [Full local](../docs/full-local.md). + +A **remote** install (each other `DRUKS_PROVIDER`) enables +`COMPOSE_PROFILES=hosted`: the Drukbox control plane against a cloud provider, +the periodic janitor, and stock Caddy (identity edge and proxy, with the +Caddyfile bind-mounted). + +The **docker-sbx** provider also layers `compose.docker-sbx.yaml` and enables +the `gateway` profile. The overlay connects the Drukbox services to the +[Docker Sandboxes](https://docs.docker.com/ai/sandboxes/) daemon of the host +(microVM sandboxes). The gateway is the SSH path into them. Prepare the host +first: install `docker-sbx`, put the service user in the `kvm` group, then run +`sbx login` and `sbx daemon start -d --policy balanced`. The installer stops +with a clear message when the daemon socket is missing. + +Before this layout, each shape had its own overlay file. Those files are +retired: `compose.local.yaml` is now the base with no profiles, and +`compose.remote.yaml` is the base with `COMPOSE_PROFILES=hosted`. Deployments +that fetch compose files by path must update to `compose.yaml` (and +`compose.docker-sbx.yaml` for that provider). Drukbox keeps its own schema in a `drukbox` database in the same Postgres, so there is no second datastore to run or back up separately. diff --git a/deploy/compose.docker-sbx.yaml b/deploy/compose.docker-sbx.yaml new file mode 100644 index 00000000..fd88fce7 --- /dev/null +++ b/deploy/compose.docker-sbx.yaml @@ -0,0 +1,49 @@ +# The Docker Sandboxes overlay. It connects the drukbox services, and the +# gateway that compose.yaml declares behind the "gateway" profile, to the +# sandboxd daemon on the host. sandboxd runs as one user. The overlay mounts +# four things: the daemon socket, the sbx CLI of the host, the CLI auth store, +# and the workspace root. The mount of the host CLI keeps the CLI version and +# the daemon version equal. The auth store and the workspace root keep the +# same path inside and outside the container, because the daemon resolves +# paths on its own filesystem. The services run with the uid of the daemon +# owner (the deploy user). DRUKS_SBX_HOME is the home directory of that user. +# install.sh writes it to .env, and each compose command renders the same +# mounts, also from sudo or systemd. The docker.sock mount from the base +# stays: browser-login containers use the docker provider. + +x-sbx-rig: &sbx-rig + user: "${DRUKS_UID:?set DRUKS_UID in .env — run install.sh}:${DRUKS_GID:?set DRUKS_GID in .env — run install.sh}" + volumes: + - ${DRUKS_SBX_HOME:?set DRUKS_SBX_HOME in .env — run install.sh}/.local/state/sandboxes/sandboxes/sandboxd/sandboxd.sock:/run/sandboxd.sock + - /usr/bin/sbx:/usr/local/bin/sbx:ro + - ${DRUKS_SBX_HOME:?}/.config/com.docker.sandboxes:${DRUKS_SBX_HOME:?}/.config/com.docker.sandboxes:ro + - ${DRUKS_SBX_HOME:?}/.drukbox:${DRUKS_SBX_HOME:?}/.drukbox + +# The image has no user with the deploy uid, and defaults that come from the +# home directory resolve nowhere. XDG_CONFIG_HOME points the sbx CLI to the +# mounted auth store. The workspace root is set here, and it cannot disagree +# with the mount above. +x-sbx-env: &sbx-env + XDG_CONFIG_HOME: ${DRUKS_SBX_HOME:?}/.config + DOCKER_SANDBOXES_API: unix:///run/sandboxd.sock + DOCKER_SBX_WORKSPACE_ROOT: ${DRUKS_SBX_HOME:?}/.drukbox/sbx-workspaces + +services: + drukbox: + <<: *sbx-rig + environment: + <<: *sbx-env + + drukbox-janitor: + <<: *sbx-rig + environment: + <<: *sbx-env + + # GATEWAY_SSH_HOST is the address the provider gives to callers. It comes + # from .env through the [sandbox.docker-sbx] table. Provisioning refuses to + # start without it. + drukbox-gateway: + <<: *sbx-rig + environment: + <<: *sbx-env + GATEWAY_HOST_KEY_PATH: ${DRUKS_SBX_HOME:?}/.drukbox/gateway_host_key diff --git a/deploy/compose.local.yaml b/deploy/compose.local.yaml deleted file mode 100644 index d206ab42..00000000 --- a/deploy/compose.local.yaml +++ /dev/null @@ -1,41 +0,0 @@ -# Local overlay: drukbox drives sandboxes as sibling containers on the host -# Docker daemon through the mounted socket. install.sh writes -# COMPOSE_FILE=compose.yaml:compose.local.yaml into .env, so plain -# `docker compose` loads this with the base. The dashboard is reached directly -# on 127.0.0.1:8001 — no Caddy. - -services: - drukbox: - image: ${DRUKS_SANDBOX_SERVICE_IMAGE:-ghcr.io/czpython/drukbox:latest} - network_mode: host - restart: unless-stopped - # Runs as the image's non-root appuser (uid 1001). The Docker socket is - # group-owned; install.sh records its gid (DRUKS_DOCKER_GID) so appuser may - # use it — on macOS the mounted socket is group root, so setup writes 0. - group_add: - - "${DRUKS_DOCKER_GID:-0}" - env_file: - - ./.env - environment: - DATABASE_URL: postgresql+psycopg://${DRUKS_POSTGRES_USER:-druks}:${DRUKS_POSTGRES_PASSWORD}@127.0.0.1:5432/drukbox - DEFAULT_HOST_PROVIDER: docker - TAILSCALE_ENABLED: "false" - DOCKER_SSH_USERNAME: druks - SERVICE_TOKENS: dev-token - # Loopback + port 8000 to match the service_url local setup writes; the - # image healthcheck follows below. - UVICORN_HOST: 127.0.0.1 - volumes: - - /var/run/docker.sock:/var/run/docker.sock - command: [".venv/bin/uvicorn", "api.app:app", "--port", "8000"] - healthcheck: - test: - [ - "CMD", - "python", - "-c", - "import urllib.request; urllib.request.urlopen('http://127.0.0.1:8000/healthz', timeout=2)", - ] - depends_on: - postgres: - condition: service_healthy diff --git a/deploy/compose.remote.yaml b/deploy/compose.remote.yaml deleted file mode 100644 index 2d0147e5..00000000 --- a/deploy/compose.remote.yaml +++ /dev/null @@ -1,69 +0,0 @@ -# Remote overlay: drukbox drives a cloud sandbox provider (exe/aws/…), plus -# Caddy as the identity edge + webhook TLS. install.sh writes -# COMPOSE_FILE=compose.yaml:compose.remote.yaml into .env. Provider credentials -# ride in .env (the [sandbox.] passthrough from setup); the exe shape -# additionally reaches its VMs over the host tailscaled. - -x-drukbox: &drukbox - image: ${DRUKS_SANDBOX_SERVICE_IMAGE:-ghcr.io/czpython/drukbox:latest} - network_mode: host - # Cloud VMs are the sandbox home; the mounted socket serves the docker - # provider that browser-session containers run on. install.sh records the - # socket's gid so drukbox's non-root appuser may use it. - group_add: - - "${DRUKS_DOCKER_GID:-0}" - volumes: - - /var/run/docker.sock:/var/run/docker.sock - env_file: - - ./.env - environment: - DATABASE_URL: postgresql+psycopg://${DRUKS_POSTGRES_USER:-druks}:${DRUKS_POSTGRES_PASSWORD}@127.0.0.1:5432/drukbox - # Browser containers run sshd for the druks user, not root. - DOCKER_SSH_USERNAME: druks - depends_on: - postgres: - condition: service_healthy - -services: - drukbox: - <<: *drukbox - restart: unless-stopped - - # Periodic janitor — drukbox ships no scheduler, so loop with sleep; a - # real timer belongs in the drukbox service/image, not in compose. - drukbox-janitor: - <<: *drukbox - restart: unless-stopped - entrypoint: - - sh - - -c - - "while true; do .venv/bin/python -m hosts.janitor || true; sleep 60; done" - # The image HEALTHCHECK probes the HTTP API — on host networking the - # janitor would health-check the *service's* port, doubling /healthz - # traffic and reporting a status that isn't its own. - healthcheck: - disable: true - - caddy: - # Stock Caddy — the identity edge + webhook TLS. Caddyfile is fetched next - # to this compose file by install.sh. - image: caddy:2.10-alpine - network_mode: host - restart: unless-stopped - environment: - DRUKS_UPSTREAM: ${DRUKS_UPSTREAM:-127.0.0.1:8001} - # Setup renders identity.header for Caddy; druks reads it from druks.toml. - # Caddy requires the edge's assertion, and druks maps it to an account. - # No default anywhere — the backend refuses header mode without it. - DRUKS_AUTH_HEADER: ${DRUKS_AUTH_HEADER:-} - # ``:-`` so an empty .env value still collapses to the inert loopback - # default — Caddy treats set-but-empty as a real (broken) site address. - DRUKS_WEBHOOK_HOST: ${DRUKS_WEBHOOK_HOST:-http://127.0.0.1:8081} - volumes: - - ./caddy/Caddyfile:/etc/caddy/Caddyfile:ro - - caddy_data:/data - - caddy_config:/config - -volumes: - caddy_data: - caddy_config: diff --git a/deploy/compose.yaml b/deploy/compose.yaml index 98d664a2..768c952d 100644 --- a/deploy/compose.yaml +++ b/deploy/compose.yaml @@ -1,9 +1,10 @@ -# Druks base stack: web, Postgres, Redis. The sandbox control plane (drukbox) -# and, for remote installs, Caddy come from an overlay — compose.local.yaml or -# compose.remote.yaml — selected by COMPOSE_FILE in .env, which install.sh -# writes. Host networking throughout: services reach Postgres and Redis on -# 127.0.0.1, and drukbox reaches each sandbox's sshd at the address its provider -# returns — a host-loopback port locally, a provider-reachable address remotely. +# The Druks stack: web, Postgres, Redis, and the drukbox sandbox control plane. +# This one file applies to all install shapes. install.sh writes COMPOSE_PROFILES +# to .env. The "hosted" profile starts the Caddy edge and the janitor. The +# docker-sbx provider adds one overlay file, because its drukbox has different +# mounts. All services use the host network. Services connect to Postgres and +# Redis on 127.0.0.1. drukbox connects to each sandbox at the address the +# provider returns. x-druks: &druks image: ghcr.io/czpython/druks:${DRUKS_TAG:-latest} @@ -47,6 +48,41 @@ x-druks: &druks postgres: condition: service_healthy +# The sandbox control plane. Every install has it. Only its provider +# configuration changes between shapes. That configuration comes from .env: +# DEFAULT_HOST_PROVIDER and the [sandbox.] values that druks setup +# renders. +x-drukbox: &drukbox + image: ${DRUKS_SANDBOX_SERVICE_IMAGE:-ghcr.io/czpython/drukbox:latest} + restart: unless-stopped + network_mode: host + # The docker provider uses the mounted socket: for sandboxes on the local + # shape, and for browser-login containers on every shape. install.sh records + # the gid of the socket, then the non-root appuser can use it. On macOS the + # mounted socket has group root, and install.sh writes 0. + group_add: + - "${DRUKS_DOCKER_GID:-0}" + env_file: + - ./.env + environment: + DATABASE_URL: postgresql+psycopg://${DRUKS_POSTGRES_USER:-druks}:${DRUKS_POSTGRES_PASSWORD}@127.0.0.1:5432/drukbox + # Only web calls drukbox. The call stays on loopback, also on Docker + # Desktop, where the services share the VM host network. Use the override + # variable only for a future multi-host split. SERVICE_TOKENS has no + # default here on purpose. The value comes from .env. Without a value, + # drukbox does not start. A known default token is not safe. + UVICORN_HOST: ${DRUKS_DRUKBOX_BIND_HOST:-127.0.0.1} + # Off, unless the [sandbox.] table sets TAILSCALE_ENABLED=true + # (the exe provider does). + TAILSCALE_ENABLED: ${TAILSCALE_ENABLED:-false} + # Browser-login containers run sshd for the druks user, not for root. + DOCKER_SSH_USERNAME: druks + volumes: + - /var/run/docker.sock:/var/run/docker.sock + depends_on: + postgres: + condition: service_healthy + services: web: <<: *druks @@ -68,6 +104,64 @@ services: "8001", ] + drukbox: + <<: *drukbox + + # The periodic janitor. drukbox has no scheduler, and this loop with sleep + # does the work. A real timer belongs in the drukbox service or image, not + # in compose. Only hosted installs run it. A local box reaps on the next + # request. + drukbox-janitor: + <<: *drukbox + profiles: ["hosted"] + entrypoint: + - sh + - -c + - "while true; do .venv/bin/python -m hosts.janitor || true; sleep 60; done" + # The image HEALTHCHECK probes the HTTP API. On the host network, the + # janitor would probe the port of the drukbox service. That doubles the + # /healthz traffic and reports a status that is not its own. + healthcheck: + disable: true + + # The SSH path into gateway-provider sandboxes. These sandboxes have no + # reachable sshd. druks connects to the gateway, and the gateway opens an + # exec session. That wakes a stopped sandbox. The service does nothing + # without the docker-sbx overlay, which adds the sbx mounts. install.sh + # enables the profile only for that provider. + drukbox-gateway: + <<: *drukbox + profiles: ["gateway"] + entrypoint: [".venv/bin/python", "-m", "gateway.server"] + # The image HEALTHCHECK probes the drukbox HTTP API. This service is not + # that API. + healthcheck: + disable: true + + # The identity edge and webhook TLS. Only hosted installs run it. A local + # box gets the dashboard directly on 127.0.0.1:8001. Stock image with no + # baked configuration. install.sh fetches the Caddyfile next to this file. + caddy: + profiles: ["hosted"] + image: caddy:2.10-alpine + network_mode: host + restart: unless-stopped + environment: + DRUKS_UPSTREAM: ${DRUKS_UPSTREAM:-127.0.0.1:8001} + # Setup renders identity.header for Caddy. druks reads it from + # druks.toml. Caddy requires the assertion from the edge, and druks maps + # it to an account. There is no default. The backend refuses header mode + # without it. + DRUKS_AUTH_HEADER: ${DRUKS_AUTH_HEADER:-} + # ``:-`` makes an empty .env value collapse to the inert loopback + # default. Caddy reads a set-but-empty value as a real, broken site + # address. + DRUKS_WEBHOOK_HOST: ${DRUKS_WEBHOOK_HOST:-http://127.0.0.1:8081} + volumes: + - ./caddy/Caddyfile:/etc/caddy/Caddyfile:ro + - caddy_data:/data + - caddy_config:/config + postgres: image: postgres:16-alpine restart: unless-stopped @@ -110,3 +204,5 @@ volumes: redis_data: postgres_data: druks_sandbox_keys: + caddy_data: + caddy_config: diff --git a/docs/configuration.md b/docs/configuration.md index 5169aac0..4d0922f8 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -48,8 +48,8 @@ of overriding its canonical value. On a remote shape, providers. `docker` and `exe` select shape-specific first-write templates. Every other provider name selects the generic remote shape and is validated by Drukbox. -The local `docker` shape does not render `[sandbox.]`: its Drukbox -service takes a fixed environment from `deploy/compose.local.yaml`. +The local `docker` shape does not render `[sandbox.]`. Its Drukbox +service gets its environment from the defaults in `deploy/compose.yaml`. Secrets are generated only when the TOML is first created. Preserve `[secrets]` when moving or recovering an installation. Use repeatable diff --git a/docs/full-local.md b/docs/full-local.md index 4856bcbc..1cf7e34f 100644 --- a/docs/full-local.md +++ b/docs/full-local.md @@ -3,7 +3,7 @@ The local shape keeps every component on one machine: ```text -browser -> Druks :8001 -> Drukbox :8000 -> Docker sandbox containers +browser -> Druks :8001 -> Drukbox :8780 -> Docker sandbox containers \ -> SSH from Druks to each container ``` @@ -38,7 +38,8 @@ The local shape needs no authored values, so the first run goes all the way: - renders `~/druks/.env` with `DEFAULT_HOST_PROVIDER=docker` - generates the database password and the stored-secret key - pulls images, applies migrations, and starts Druks, Postgres, Redis, and - Drukbox on `127.0.0.1:8000` (`COMPOSE_FILE=compose.yaml:compose.local.yaml`) + Drukbox on `127.0.0.1:8780` (`COMPOSE_FILE=compose.yaml:compose.override.yaml` + with no profiles: no Caddy and no janitor) Drukbox drives sandboxes through the mounted `/var/run/docker.sock`; the installer records the socket's group id in `.env` so the service's non-root diff --git a/scripts/install.sh b/scripts/install.sh index ef1f26b5..1d0a5bc9 100755 --- a/scripts/install.sh +++ b/scripts/install.sh @@ -69,13 +69,12 @@ main() { mkdir -p "$INSTALL_DIR" cd "$INSTALL_DIR" - # compose files — the base plus both shape overlays live in the repo, always - # refresh. COMPOSE_FILE in .env (written below) selects which overlay loads; - # the unused one sits inert. + # Compose files. The base holds every service. Always refresh both files. + # COMPOSE_PROFILES in .env (written below) turns on the hosted services. The + # docker-sbx overlay loads only for that provider and is inert otherwise. echo "→ fetching compose files from $REPO@$REF" fetch_from_repo deploy/compose.yaml compose.yaml - fetch_from_repo deploy/compose.local.yaml compose.local.yaml - fetch_from_repo deploy/compose.remote.yaml compose.remote.yaml + fetch_from_repo deploy/compose.docker-sbx.yaml compose.docker-sbx.yaml # compose.override.yaml holds the operator's local services and overrides; # COMPOSE_FILE lists it, so it must exist. Seed it once and never touch it @@ -83,8 +82,8 @@ main() { [ -f compose.override.yaml ] \ || printf '# Host-local Compose overrides. See INSTALL.md.\n' > compose.override.yaml - # The Caddyfile is bind-mounted by the remote overlay (stock caddy image, no - # baked config), so it refreshes on every re-run. + # The Caddyfile is bind-mounted by the caddy service (stock image, no baked + # config), so it refreshes on every re-run. echo "→ fetching deploy/caddy/Caddyfile from $REPO@$REF" mkdir -p caddy fetch_from_repo deploy/caddy/Caddyfile caddy/Caddyfile @@ -93,19 +92,22 @@ main() { echo "→ pulling $BACKEND_IMAGE" docker pull -q "$BACKEND_IMAGE" >/dev/null - # TOML setup + .env render — the required-values brain is ``druks setup`` - # (exit 0 = boot-ready, 3 = gaps). + # TOML setup and .env render. ``druks setup`` decides the required values: + # exit 0 is boot-ready, exit 3 is gaps. A gaps exit still renders .env. The + # compose-plane keys below are thus written before each exit path. The + # fetched compose.yaml and the shape selection in .env always change + # together. An interrupted upgrade cannot pair the new base with the old + # selection. set +e docker run --rm --user "$(id -u):$(id -g)" \ -v "$INSTALL_DIR:/bootstrap" "$BACKEND_IMAGE" \ druks setup /bootstrap/.env --provider "$PROVIDER" --home "$HOME" setup_rc=$? set -e - case "$setup_rc" in - 0) ;; # boot-ready — fall through to pull + boot - 3) exit 0 ;; # gaps remain — setup printed the checklist; re-run when done - *) echo "druks setup failed (exit $setup_rc)" >&2; exit "$setup_rc" ;; - esac + if [ "$setup_rc" != 0 ] && [ "$setup_rc" != 3 ]; then + echo "druks setup failed (exit $setup_rc)" >&2 + exit "$setup_rc" + fi # setup rendered the provider from druks.toml — read the artifact so the # shape branches below follow the authored configuration. @@ -128,25 +130,62 @@ main() { set_env_var DRUKS_WEB_BIND_HOST "127.0.0.1" fi - # COMPOSE_FILE → .env, so plain `docker compose` in this dir loads the right - # overlay. `local` drives sandboxes on the host Docker daemon (dashboard on - # :8001, no Caddy); `remote` runs the cloud provider + Caddy. Both shapes - # mount the Docker socket — sandboxes on local, browser-session containers - # on remote — so the socket's gid rides along and drukbox's non-root appuser - # may use it. On macOS the host path is a user-owned symlink, but the socket - # Docker Desktop mounts into containers is group root, so the host gid would - # grant nothing. + # drukbox mounts the Docker socket on every provider: for sandboxes on the + # docker provider, and for browser-login containers on all providers. The + # gid of the socket lets the non-root appuser use it. On macOS the host path + # is a user-owned symlink, but the socket that Docker Desktop mounts into + # containers has group root. The host gid would give nothing there. if [ "$(uname -s)" = "Darwin" ]; then set_env_var DRUKS_DOCKER_GID "0" else set_env_var DRUKS_DOCKER_GID "$(stat -c '%g' /var/run/docker.sock)" fi - # compose.override.yaml loads last so operator additions win over the repo - # files and are never overwritten by them. - if [ "$PROVIDER" = "docker" ]; then - set_env_var COMPOSE_FILE "compose.yaml:compose.local.yaml:compose.override.yaml" - else - set_env_var COMPOSE_FILE "compose.yaml:compose.remote.yaml:compose.override.yaml" + + # Shape selection, written to .env. Then a plain `docker compose` command in + # this directory does the correct thing. The `hosted` profile turns on the + # Caddy edge and the janitor. A `docker` box runs bare, with the dashboard + # directly on :8001. docker-sbx also layers the overlay (drukbox connected to + # the host sandboxd) and enables the SSH gateway. compose.override.yaml loads + # last. Operator additions thus win over the repo files, and the installer + # never overwrites them. + set_env_var COMPOSE_FILE "compose.yaml:compose.override.yaml" + case "$PROVIDER" in + docker) + set_env_var COMPOSE_PROFILES "" + ;; + docker-sbx) + set_env_var COMPOSE_FILE "compose.yaml:compose.docker-sbx.yaml:compose.override.yaml" + set_env_var COMPOSE_PROFILES "hosted,gateway" + # The sbx mounts live in the home directory of the daemon owner. Write + # the path to .env, and each compose command renders the same mounts, + # also from sudo or systemd. Create the writable bind source now. The + # engine would make it root-owned, and the deploy-uid services could + # not write the workspaces or the gateway host key. + set_env_var DRUKS_SBX_HOME "$HOME" + mkdir -p "$HOME/.drukbox/sbx-workspaces" + # sandboxd must run before the first compose command. A bind of a + # missing socket path makes a root-owned directory there, and that + # blocks the daemon itself. + SBX_SOCKET="$HOME/.local/state/sandboxes/sandboxes/sandboxd/sandboxd.sock" + if [ ! -S "$SBX_SOCKET" ]; then + echo "docker-sbx: no sandboxd socket at $SBX_SOCKET" >&2 + echo "install docker-sbx, then: sbx login && sbx daemon start -d --policy balanced" >&2 + exit 1 + fi + ;; + *) + set_env_var COMPOSE_PROFILES "hosted" + ;; + esac + + # Retired shape overlays. The installer does not fetch them, and nothing + # references them after the selection above. Remove stale copies, and an old + # project directory cannot mix them into the merged configuration. + rm -f compose.local.yaml compose.remote.yaml + + if [ "$setup_rc" = 3 ]; then + # Gaps remain. Setup printed the checklist. Re-run when done. + exit 0 fi echo "→ docker compose pull"