From 9758868ace0e421eadf1902e62fa72984415a1b1 Mon Sep 17 00:00:00 2001 From: Paulo Alvarado Date: Fri, 21 Aug 2026 12:56:32 +0200 Subject: [PATCH 1/3] One compose file, shapes chosen by profiles and env MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Deployment shapes were a file per shape (compose.local.yaml, compose.remote.yaml) stitched onto the base via COMPOSE_FILE. Each new provider that needed different services was a new whole file duplicating the others — a combinatorial pile, since a shape is really a combination of orthogonal axes (which provider, whether an edge exists), not a single choice. Collapse to one compose.yaml. Every service lives there; the two genuinely optional ones — the Caddy identity edge and the janitor — are gated by profiles, and the per-provider drukbox differences (provider, tailnet, service token, bind host) were already env values, so they ride in .env exactly as exe vs generic- remote already did. install.sh writes COMPOSE_PROFILES instead of picking a file: empty for a local docker box, caddy,janitor for a hosted one. drukbox now listens on its image default port (8780) in every shape; the local overlay's :8000 override and matching healthcheck were incidental drift, so the docker shape's service_url moves to 8780 to match. (The from-source `make dev` flow in development.md keeps :8000 — that is bare uvicorn's default, unrelated.) A provider whose drukbox needs a structurally different shape — different mounts, a different user, an extra service — still warrants an overlay, because env can substitute values but cannot restructure a service. compose.docker-sbx.yaml is the first: it reshapes the drukbox services to drive the host's Docker Sandboxes daemon (microVM sandboxes) and adds the SSH gateway, selected by install.sh with COMPOSE_PROFILES=caddy,janitor,gateway when the provider is docker-sbx. Adding an env-only provider now costs zero new files. Co-Authored-By: Claude Fable 5 --- INSTALL.md | 4 +- backend/druks/setup_env.py | 3 +- backend/tests/test_setup_env.py | 6 ++- deploy/README.md | 34 ++++++------ deploy/compose.docker-sbx.yaml | 63 +++++++++++++++++++++++ deploy/compose.local.yaml | 41 --------------- deploy/compose.remote.yaml | 69 ------------------------- deploy/compose.yaml | 91 ++++++++++++++++++++++++++++++--- docs/configuration.md | 2 +- docs/full-local.md | 3 +- scripts/install.sh | 47 ++++++++++------- 11 files changed, 207 insertions(+), 156 deletions(-) create mode 100644 deploy/compose.docker-sbx.yaml delete mode 100644 deploy/compose.local.yaml delete mode 100644 deploy/compose.remote.yaml diff --git a/INSTALL.md b/INSTALL.md index f91312fc..c46fb0a6 100644 --- a/INSTALL.md +++ b/INSTALL.md @@ -69,8 +69,8 @@ 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. +`compose.yaml` or `compose.docker-sbx.yaml` — those changes are lost on the +next install. 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..b5a7cd39 100644 --- a/backend/druks/setup_env.py +++ b/backend/druks/setup_env.py @@ -19,6 +19,7 @@ "DRUKS_WEB_BIND_HOST", "DRUKS_DOCKER_GID", "COMPOSE_FILE", + "COMPOSE_PROFILES", ) _ENV_KEY_PATTERN = re.compile(r"[A-Za-z_][A-Za-z0-9_]*") @@ -212,7 +213,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"), ) diff --git a/backend/tests/test_setup_env.py b/backend/tests/test_setup_env.py index 1d735f64..ee9475d2 100644 --- a/backend/tests/test_setup_env.py +++ b/backend/tests/test_setup_env.py @@ -311,7 +311,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\n" + + "COMPOSE_FILE=compose.yaml:compose.override.yaml\nCOMPOSE_PROFILES=caddy,janitor\n" ) _run(env_path) @@ -319,7 +320,8 @@ 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["COMPOSE_FILE"] == "compose.yaml:compose.override.yaml" + assert values["COMPOSE_PROFILES"] == "caddy,janitor" assert "# OPERATOR ADDITIONS" in env_path.read_text() diff --git a/deploy/README.md b/deploy/README.md index f24b7820..d44b0f7c 100644 --- a/deploy/README.md +++ b/deploy/README.md @@ -1,20 +1,24 @@ # 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 whole stack: Druks (`web`, which embeds the DBOS +durable engine and serves the dashboard SPA), Postgres, Redis, the Drukbox +sandbox control plane (`drukbox`), the janitor, and the Caddy edge. `install.sh` +writes `COMPOSE_PROFILES` to `.env` to turn on the shape-specific extras, so +plain `docker compose` commands in the install dir do the right thing. + +A **local** install (`DRUKS_PROVIDER=docker`) runs bare — no profiles: `drukbox` +mounts the host's Docker socket, sandboxes are sibling containers on the host +daemon, and the dashboard is reached directly on `127.0.0.1:8001`, no Caddy. See +[Full local](../docs/full-local.md). + +A **remote** install (any other `DRUKS_PROVIDER`) enables `COMPOSE_PROFILES=` +`caddy,janitor`: the Drukbox control plane against a cloud provider, the periodic +janitor, and stock Caddy (identity edge + proxy, Caddyfile bind-mounted). + +The **docker-sbx** provider additionally layers `compose.docker-sbx.yaml` and +the `gateway` profile: it reshapes the Drukbox services to drive the host's +[Docker Sandboxes](https://docs.docker.com/ai/sandboxes/) daemon (microVM +sandboxes) and adds the SSH gateway that fronts them. 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..cdddaed7 --- /dev/null +++ b/deploy/compose.docker-sbx.yaml @@ -0,0 +1,63 @@ +# Docker Sandboxes overlay. Reshapes the drukbox services to drive the host's +# per-user sandboxd (microVM sandboxes) and adds the SSH gateway that fronts +# them. install.sh layers this file and turns on COMPOSE_PROFILES=...,gateway +# when the provider is docker-sbx. +# +# The rig is what a container needs to reach a per-user daemon: its socket, the +# host's sbx CLI (mounting it keeps CLI and daemon versions agreeing), the CLI +# auth store, and the workspace root — the last three at a path identical inside +# and out, because the daemon resolves paths on its own filesystem. The services +# run as the daemon owner's uid (the deploy user); DRUKS_SBX_HOME is that user's +# home, defaulting to it. The base drukbox's docker.sock mount rides along +# (browser-login containers still 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:-${HOME}}/.local/state/sandboxes/sandboxes/sandboxd/sandboxd.sock:/run/sandboxd.sock + - /usr/bin/sbx:/usr/local/bin/sbx:ro + - ${DRUKS_SBX_HOME:-${HOME}}/.config/com.docker.sandboxes:${DRUKS_SBX_HOME:-${HOME}}/.config/com.docker.sandboxes:ro + - ${DRUKS_SBX_HOME:-${HOME}}/.drukbox:${DRUKS_SBX_HOME:-${HOME}}/.drukbox + +# The image has no user at the deploy uid, so home-derived defaults resolve +# nowhere. XDG_CONFIG_HOME points the sbx CLI at the mounted auth store; the +# workspace root is pinned so it can never disagree with the mount above. +x-sbx-env: &sbx-env + XDG_CONFIG_HOME: ${DRUKS_SBX_HOME:-${HOME}}/.config + DOCKER_SANDBOXES_API: unix:///run/sandboxd.sock + DOCKER_SBX_WORKSPACE_ROOT: ${DRUKS_SBX_HOME:-${HOME}}/.drukbox/sbx-workspaces + +services: + drukbox: + <<: *sbx-rig + environment: + <<: *sbx-env + + drukbox-janitor: + <<: *sbx-rig + environment: + <<: *sbx-env + + # The SSH path to sandboxes: microVMs have no dialable sshd, so druks connects + # here and the gateway opens an sbx exec session, waking stopped sandboxes. + # GATEWAY_SSH_HOST — the address the provider advertises to callers — rides in + # .env via the [sandbox.docker-sbx] passthrough; provisioning refuses without + # it. New service, so it inherits nothing from the base drukbox anchor. + drukbox-gateway: + <<: *sbx-rig + image: ${DRUKS_SANDBOX_SERVICE_IMAGE:-ghcr.io/czpython/drukbox:latest} + network_mode: host + restart: unless-stopped + profiles: ["gateway"] + entrypoint: [".venv/bin/python", "-m", "gateway.server"] + env_file: + - ./.env + environment: + <<: *sbx-env + GATEWAY_HOST_KEY_PATH: ${DRUKS_SBX_HOME:-${HOME}}/.drukbox/gateway_host_key + depends_on: + postgres: + condition: service_healthy + # The image HEALTHCHECK probes the drukbox HTTP API, which this is not. + healthcheck: + disable: true 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..93af2dc9 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. +# Druks stack: web, Postgres, Redis, and the sandbox control plane (drukbox). +# One file for every install shape — install.sh writes COMPOSE_PROFILES in .env +# to turn on the hosted extras (Caddy identity edge, the janitor), and a provider +# whose drukbox needs a different shape (docker-sbx) layers a thin overlay. +# 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. x-druks: &druks image: ghcr.io/czpython/druks:${DRUKS_TAG:-latest} @@ -47,6 +48,40 @@ x-druks: &druks postgres: condition: service_healthy +# The sandbox control plane. Present in every install; only its provider-facing +# configuration varies, and that rides in .env (DEFAULT_HOST_PROVIDER and the +# [sandbox.] passthrough that druks setup renders). +x-drukbox: &drukbox + image: ${DRUKS_SANDBOX_SERVICE_IMAGE:-ghcr.io/czpython/drukbox:latest} + restart: unless-stopped + network_mode: host + # The mounted socket serves the docker provider (sandboxes locally, browser- + # login containers on every provider). install.sh records the socket's gid so + # drukbox's non-root 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 + # Bind loopback on a real host, 0.0.0.0 only where Docker Desktop needs it — + # the same per-OS value web uses. Only web calls drukbox, over loopback. + UVICORN_HOST: ${DRUKS_WEB_BIND_HOST:-0.0.0.0} + # Off unless a provider turns it on via its [sandbox.] passthrough + # (exe sets TAILSCALE_ENABLED=true). + TAILSCALE_ENABLED: ${TAILSCALE_ENABLED:-false} + # Browser-login containers run sshd for the druks user, not root. + DOCKER_SSH_USERNAME: druks + # The token web presents: dev-token on a local box, the generated one on a + # hosted box (druks setup writes it to .env for every non-docker provider). + SERVICE_TOKENS: ${SERVICE_TOKENS:-dev-token} + volumes: + - /var/run/docker.sock:/var/run/docker.sock + depends_on: + postgres: + condition: service_healthy + services: web: <<: *druks @@ -68,6 +103,48 @@ services: "8001", ] + drukbox: + <<: *drukbox + + # Periodic janitor — drukbox ships no scheduler, so loop with sleep; a real + # timer belongs in the drukbox service/image, not in compose. Hosted installs + # only (COMPOSE_PROFILES=...,janitor); a local box reaps on the next request. + drukbox-janitor: + <<: *drukbox + profiles: ["janitor"] + 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 + + # The identity edge + webhook TLS. Hosted installs only + # (COMPOSE_PROFILES=caddy); a local box reaches the dashboard directly on + # 127.0.0.1:8001. Stock image, no baked config — install.sh fetches the + # Caddyfile next to this file. + caddy: + profiles: ["caddy"] + 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 + postgres: image: postgres:16-alpine restart: unless-stopped @@ -110,3 +187,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..061a9074 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -49,7 +49,7 @@ 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`. +service takes its environment from `deploy/compose.yaml` defaults. 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..960ce98d 100644 --- a/docs/full-local.md +++ b/docs/full-local.md @@ -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`, + no profiles — no Caddy, 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..2f92a990 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. COMPOSE_PROFILES + # in .env (written below) turns on the hosted extras; the docker-sbx overlay is + # fetched too and loads only for that provider. Both sit 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 @@ -128,26 +127,38 @@ 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 — sandboxes on the docker + # provider, browser-login containers everywhere — so its 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. 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 + + # Shape selection, written to .env so plain `docker compose` in this dir does + # the right thing. COMPOSE_PROFILES turns on the hosted extras: a `docker` + # box runs bare (dashboard direct on :8001, no edge, no janitor); any other + # provider adds the Caddy edge + the janitor. docker-sbx additionally layers + # the overlay (drukbox reshaped for the host sandboxd) and the SSH gateway. # 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" - fi + case "$PROVIDER" in + docker) + set_env_var COMPOSE_FILE "compose.yaml:compose.override.yaml" + 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 "caddy,janitor,gateway" + ;; + *) + set_env_var COMPOSE_FILE "compose.yaml:compose.override.yaml" + set_env_var COMPOSE_PROFILES "caddy,janitor" + ;; + esac echo "→ docker compose pull" docker compose pull From bef302978add002b9b06c5e80c393ce1bd930397 Mon Sep 17 00:00:00 2001 From: Paulo Alvarado Date: Fri, 21 Aug 2026 15:36:48 +0200 Subject: [PATCH 2/3] Review fixes: fail closed, migrate upgrades, prepare the host MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The gateway now extends the base drukbox anchor, and it gets the Postgres DATABASE_URL instead of the sqlite default from .env. Setup migrates the retired :8000 service_url of old docker-shape installs to :8780, and it renders SERVICE_TOKENS on every shape — the compose default is gone, and a missing token stops drukbox instead of accepting a known one. drukbox binds 127.0.0.1 through its own variable, not through the web bind variable that macOS sets to 0.0.0.0. install.sh writes the shape selection to .env before each exit path, and it removes the retired overlay files. The docker-sbx branch pins DRUKS_SBX_HOME (setup preserves it), creates the .drukbox bind source with the correct owner, and stops with instructions when the sandboxd socket is missing. The overlay requires DRUKS_SBX_HOME and does not fall back to the ambient HOME. One "hosted" profile replaces the paired caddy/janitor profiles. The full-local diagram shows the correct port. The deploy README documents the host preparation and maps the retired overlay files to their replacements. Comments and docs use simplified English (ASD-STE100). Co-Authored-By: Claude Fable 5 --- INSTALL.md | 6 +- backend/druks/setup_env.py | 18 +++++- backend/tests/test_setup_env.py | 35 ++++++++++-- deploy/README.md | 46 ++++++++++------ deploy/compose.docker-sbx.yaml | 64 +++++++++------------- deploy/compose.yaml | 97 +++++++++++++++++++-------------- docs/configuration.md | 4 +- docs/full-local.md | 6 +- scripts/install.sh | 84 ++++++++++++++++++---------- 9 files changed, 220 insertions(+), 140 deletions(-) diff --git a/INSTALL.md b/INSTALL.md index c46fb0a6..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` or `compose.docker-sbx.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 b5a7cd39..c3c6ceb5 100644 --- a/backend/druks/setup_env.py +++ b/backend/druks/setup_env.py @@ -20,6 +20,7 @@ "DRUKS_DOCKER_GID", "COMPOSE_FILE", "COMPOSE_PROFILES", + "DRUKS_SBX_HOME", ) _ENV_KEY_PATTERN = re.compile(r"[A-Za-z_][A-Za-z0-9_]*") @@ -118,6 +119,16 @@ def run_setup( _set_value(document, value_path, value) else: document = tomlkit.parse(toml_path.read_text()) + # Old docker-shape installs have service_url on port 8000, written by a + # retired compose override. The stack now uses the default port of the + # image. Change only the exact value the old template wrote. Keep an + # operator's own URL. + if ( + _get_string(document, ("sandbox", "provider")) == "docker" + and _get_string(document, ("sandbox", "service_url")) == "http://127.0.0.1:8000" + ): + _set_value(document, ("sandbox", "service_url"), "http://127.0.0.1:8780") + is_changed = True for assignment in set_values: value_path, value = _parse_assignment(assignment) @@ -355,9 +366,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 ee9475d2..8d3190a9 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 @@ -159,6 +161,30 @@ def test_foreign_provider_table_is_a_named_gap(tmp_path): assert "OTHER_TOKEN" not in read_env(env_path) +def test_docker_shape_service_url_migrates_off_the_retired_port(tmp_path): + env_path = tmp_path / ".env" + _run(env_path, provider="docker") + toml_path = tmp_path / "druks.toml" + toml_path.write_text( + toml_path.read_text().replace("http://127.0.0.1:8780", "http://127.0.0.1:8000") + ) + + _run(env_path) + + assert 'service_url = "http://127.0.0.1:8780"' in toml_path.read_text() + + +def test_docker_shape_custom_service_url_is_left_alone(tmp_path): + env_path = tmp_path / ".env" + _run(env_path, provider="docker") + _run(env_path, set_values=("sandbox.service_url=http://127.0.0.1:9999",)) + + _run(env_path) + + toml_path = tmp_path / "druks.toml" + assert 'service_url = "http://127.0.0.1:9999"' in toml_path.read_text() + + def test_docker_shape_renders_no_provider_environment(tmp_path): env_path = tmp_path / ".env" @@ -311,8 +337,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\n" - + "COMPOSE_FILE=compose.yaml:compose.override.yaml\nCOMPOSE_PROFILES=caddy,janitor\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) @@ -320,8 +346,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["DRUKS_SBX_HOME"] == "/home/op" assert values["COMPOSE_FILE"] == "compose.yaml:compose.override.yaml" - assert values["COMPOSE_PROFILES"] == "caddy,janitor" + assert values["COMPOSE_PROFILES"] == "hosted" assert "# OPERATOR ADDITIONS" in env_path.read_text() diff --git a/deploy/README.md b/deploy/README.md index d44b0f7c..5af59788 100644 --- a/deploy/README.md +++ b/deploy/README.md @@ -1,24 +1,34 @@ # Druks Deployment -`compose.yaml` holds the whole stack: Druks (`web`, which embeds the DBOS +`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, and the Caddy edge. `install.sh` -writes `COMPOSE_PROFILES` to `.env` to turn on the shape-specific extras, so -plain `docker compose` commands in the install dir do the right thing. - -A **local** install (`DRUKS_PROVIDER=docker`) runs bare — no profiles: `drukbox` -mounts the host's Docker socket, sandboxes are sibling containers on the host -daemon, and the dashboard is reached directly on `127.0.0.1:8001`, no Caddy. See -[Full local](../docs/full-local.md). - -A **remote** install (any other `DRUKS_PROVIDER`) enables `COMPOSE_PROFILES=` -`caddy,janitor`: the Drukbox control plane against a cloud provider, the periodic -janitor, and stock Caddy (identity edge + proxy, Caddyfile bind-mounted). - -The **docker-sbx** provider additionally layers `compose.docker-sbx.yaml` and -the `gateway` profile: it reshapes the Drukbox services to drive the host's -[Docker Sandboxes](https://docs.docker.com/ai/sandboxes/) daemon (microVM -sandboxes) and adds the SSH gateway that fronts them. +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 index cdddaed7..fd88fce7 100644 --- a/deploy/compose.docker-sbx.yaml +++ b/deploy/compose.docker-sbx.yaml @@ -1,31 +1,32 @@ -# Docker Sandboxes overlay. Reshapes the drukbox services to drive the host's -# per-user sandboxd (microVM sandboxes) and adds the SSH gateway that fronts -# them. install.sh layers this file and turns on COMPOSE_PROFILES=...,gateway -# when the provider is docker-sbx. -# -# The rig is what a container needs to reach a per-user daemon: its socket, the -# host's sbx CLI (mounting it keeps CLI and daemon versions agreeing), the CLI -# auth store, and the workspace root — the last three at a path identical inside -# and out, because the daemon resolves paths on its own filesystem. The services -# run as the daemon owner's uid (the deploy user); DRUKS_SBX_HOME is that user's -# home, defaulting to it. The base drukbox's docker.sock mount rides along -# (browser-login containers still use the docker provider). +# 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:-${HOME}}/.local/state/sandboxes/sandboxes/sandboxd/sandboxd.sock:/run/sandboxd.sock + - ${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:-${HOME}}/.config/com.docker.sandboxes:${DRUKS_SBX_HOME:-${HOME}}/.config/com.docker.sandboxes:ro - - ${DRUKS_SBX_HOME:-${HOME}}/.drukbox:${DRUKS_SBX_HOME:-${HOME}}/.drukbox + - ${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 at the deploy uid, so home-derived defaults resolve -# nowhere. XDG_CONFIG_HOME points the sbx CLI at the mounted auth store; the -# workspace root is pinned so it can never disagree with the mount above. +# 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:-${HOME}}/.config + XDG_CONFIG_HOME: ${DRUKS_SBX_HOME:?}/.config DOCKER_SANDBOXES_API: unix:///run/sandboxd.sock - DOCKER_SBX_WORKSPACE_ROOT: ${DRUKS_SBX_HOME:-${HOME}}/.drukbox/sbx-workspaces + DOCKER_SBX_WORKSPACE_ROOT: ${DRUKS_SBX_HOME:?}/.drukbox/sbx-workspaces services: drukbox: @@ -38,26 +39,11 @@ services: environment: <<: *sbx-env - # The SSH path to sandboxes: microVMs have no dialable sshd, so druks connects - # here and the gateway opens an sbx exec session, waking stopped sandboxes. - # GATEWAY_SSH_HOST — the address the provider advertises to callers — rides in - # .env via the [sandbox.docker-sbx] passthrough; provisioning refuses without - # it. New service, so it inherits nothing from the base drukbox anchor. + # 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 - image: ${DRUKS_SANDBOX_SERVICE_IMAGE:-ghcr.io/czpython/drukbox:latest} - network_mode: host - restart: unless-stopped - profiles: ["gateway"] - entrypoint: [".venv/bin/python", "-m", "gateway.server"] - env_file: - - ./.env environment: <<: *sbx-env - GATEWAY_HOST_KEY_PATH: ${DRUKS_SBX_HOME:-${HOME}}/.drukbox/gateway_host_key - depends_on: - postgres: - condition: service_healthy - # The image HEALTHCHECK probes the drukbox HTTP API, which this is not. - healthcheck: - disable: true + GATEWAY_HOST_KEY_PATH: ${DRUKS_SBX_HOME:?}/.drukbox/gateway_host_key diff --git a/deploy/compose.yaml b/deploy/compose.yaml index 93af2dc9..768c952d 100644 --- a/deploy/compose.yaml +++ b/deploy/compose.yaml @@ -1,10 +1,10 @@ -# Druks stack: web, Postgres, Redis, and the sandbox control plane (drukbox). -# One file for every install shape — install.sh writes COMPOSE_PROFILES in .env -# to turn on the hosted extras (Caddy identity edge, the janitor), and a provider -# whose drukbox needs a different shape (docker-sbx) layers a thin overlay. -# 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} @@ -48,34 +48,35 @@ x-druks: &druks postgres: condition: service_healthy -# The sandbox control plane. Present in every install; only its provider-facing -# configuration varies, and that rides in .env (DEFAULT_HOST_PROVIDER and the -# [sandbox.] passthrough that druks setup renders). +# 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 mounted socket serves the docker provider (sandboxes locally, browser- - # login containers on every provider). install.sh records the socket's gid so - # drukbox's non-root appuser may use it — on macOS the mounted socket is group - # root, so setup writes 0. + # 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 - # Bind loopback on a real host, 0.0.0.0 only where Docker Desktop needs it — - # the same per-OS value web uses. Only web calls drukbox, over loopback. - UVICORN_HOST: ${DRUKS_WEB_BIND_HOST:-0.0.0.0} - # Off unless a provider turns it on via its [sandbox.] passthrough - # (exe sets TAILSCALE_ENABLED=true). + # 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 root. + # Browser-login containers run sshd for the druks user, not for root. DOCKER_SSH_USERNAME: druks - # The token web presents: dev-token on a local box, the generated one on a - # hosted box (druks setup writes it to .env for every non-docker provider). - SERVICE_TOKENS: ${SERVICE_TOKENS:-dev-token} volumes: - /var/run/docker.sock:/var/run/docker.sock depends_on: @@ -106,39 +107,55 @@ services: drukbox: <<: *drukbox - # Periodic janitor — drukbox ships no scheduler, so loop with sleep; a real - # timer belongs in the drukbox service/image, not in compose. Hosted installs - # only (COMPOSE_PROFILES=...,janitor); a local box reaps on the next request. + # 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: ["janitor"] + 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 host networking the janitor - # would health-check the *service's* port, doubling /healthz traffic and - # reporting a status that isn't its own. + # 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 identity edge + webhook TLS. Hosted installs only - # (COMPOSE_PROFILES=caddy); a local box reaches the dashboard directly on - # 127.0.0.1:8001. Stock image, no baked config — install.sh fetches the - # Caddyfile next to this file. + # 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: ["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 edge's assertion, and druks maps it to an account. - # No default anywhere — the backend refuses header mode without it. + # 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:-} - # ``:-`` so an empty .env value still collapses to the inert loopback - # default — Caddy treats set-but-empty as a real (broken) site address. + # ``:-`` 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 diff --git a/docs/configuration.md b/docs/configuration.md index 061a9074..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 its environment from `deploy/compose.yaml` defaults. +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 960ce98d..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,8 +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:8780` (`COMPOSE_FILE=compose.yaml:compose.override.yaml`, - no profiles — no Caddy, no janitor) + 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 2f92a990..1d0a5bc9 100755 --- a/scripts/install.sh +++ b/scripts/install.sh @@ -69,9 +69,9 @@ main() { mkdir -p "$INSTALL_DIR" cd "$INSTALL_DIR" - # compose files — the base holds every service, always refresh. COMPOSE_PROFILES - # in .env (written below) turns on the hosted extras; the docker-sbx overlay is - # fetched too and loads only for that provider. Both sit inert otherwise. + # 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.docker-sbx.yaml compose.docker-sbx.yaml @@ -82,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 @@ -92,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. @@ -127,39 +130,64 @@ main() { set_env_var DRUKS_WEB_BIND_HOST "127.0.0.1" fi - # drukbox mounts the Docker socket on every provider — sandboxes on the docker - # provider, browser-login containers everywhere — so its 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 - # Shape selection, written to .env so plain `docker compose` in this dir does - # the right thing. COMPOSE_PROFILES turns on the hosted extras: a `docker` - # box runs bare (dashboard direct on :8001, no edge, no janitor); any other - # provider adds the Caddy edge + the janitor. docker-sbx additionally layers - # the overlay (drukbox reshaped for the host sandboxd) and the SSH gateway. - # compose.override.yaml loads last so operator additions win over the repo - # files and are never overwritten by them. + # 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_FILE "compose.yaml:compose.override.yaml" 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 "caddy,janitor,gateway" + 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_FILE "compose.yaml:compose.override.yaml" - set_env_var COMPOSE_PROFILES "caddy,janitor" + 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" docker compose pull From b6b1b59a007fb532ca6a2863a190e76a9eebd66a Mon Sep 17 00:00:00 2001 From: Paulo Alvarado Date: Fri, 21 Aug 2026 15:45:27 +0200 Subject: [PATCH 3/3] No migration for the retired :8000 service_url No legacy local-shape installs exist that upgrade in place. A stale value is a one-line manual edit, not a code path to keep forever. Co-Authored-By: Claude Fable 5 --- backend/druks/setup_env.py | 10 ---------- backend/tests/test_setup_env.py | 24 ------------------------ 2 files changed, 34 deletions(-) diff --git a/backend/druks/setup_env.py b/backend/druks/setup_env.py index c3c6ceb5..565b0a53 100644 --- a/backend/druks/setup_env.py +++ b/backend/druks/setup_env.py @@ -119,16 +119,6 @@ def run_setup( _set_value(document, value_path, value) else: document = tomlkit.parse(toml_path.read_text()) - # Old docker-shape installs have service_url on port 8000, written by a - # retired compose override. The stack now uses the default port of the - # image. Change only the exact value the old template wrote. Keep an - # operator's own URL. - if ( - _get_string(document, ("sandbox", "provider")) == "docker" - and _get_string(document, ("sandbox", "service_url")) == "http://127.0.0.1:8000" - ): - _set_value(document, ("sandbox", "service_url"), "http://127.0.0.1:8780") - is_changed = True for assignment in set_values: value_path, value = _parse_assignment(assignment) diff --git a/backend/tests/test_setup_env.py b/backend/tests/test_setup_env.py index 8d3190a9..320cc948 100644 --- a/backend/tests/test_setup_env.py +++ b/backend/tests/test_setup_env.py @@ -161,30 +161,6 @@ def test_foreign_provider_table_is_a_named_gap(tmp_path): assert "OTHER_TOKEN" not in read_env(env_path) -def test_docker_shape_service_url_migrates_off_the_retired_port(tmp_path): - env_path = tmp_path / ".env" - _run(env_path, provider="docker") - toml_path = tmp_path / "druks.toml" - toml_path.write_text( - toml_path.read_text().replace("http://127.0.0.1:8780", "http://127.0.0.1:8000") - ) - - _run(env_path) - - assert 'service_url = "http://127.0.0.1:8780"' in toml_path.read_text() - - -def test_docker_shape_custom_service_url_is_left_alone(tmp_path): - env_path = tmp_path / ".env" - _run(env_path, provider="docker") - _run(env_path, set_values=("sandbox.service_url=http://127.0.0.1:9999",)) - - _run(env_path) - - toml_path = tmp_path / "druks.toml" - assert 'service_url = "http://127.0.0.1:9999"' in toml_path.read_text() - - def test_docker_shape_renders_no_provider_environment(tmp_path): env_path = tmp_path / ".env"