all repositories

fugl.dev

Merge PR #8: Unbrick the compose project, narrow what the site builder can reach (pr/site-deploy-least-privilege)

merged by Russ T. Fugalopened by Russ T. Fugal5 files+156 −81453fd367 merged here in total

fugl.dev commit activity: 43 commits from 2026-06-21 through 2026-08-12.

description

Post-merge follow-up to PR #5. Four of the review findings, plus one that landing PR #5 caused on the live host. All five are in compose.yaml, the hook, and the runbook; nothing here touches deploy.sh.

P0 — the live stack is currently unmanageable

~/srv/stack/softserve/compose.yaml is a symlink into this repo's working tree, so the site-deploy service definition went live on the host the moment main was checked out — before a single install step ran. Its build context used ${SITE_DEPLOY_SRC:?...}, and Compose interpolates the whole file before selecting a service. A required-variable marker on a service nobody has installed yet is therefore an error for every command on every service.

Confirmed against the live project:

$ cd ~/srv/stack/softserve && docker compose config --services
error while interpolating services.site-deploy.build.context:
  required variable SITE_DEPLOY_SRC is missing a value

The containers keep running — softserve, meta-sidecar, cloudflared and ssh-deprecated are all still up — but ps, logs, restart, and stop all fail. Also reproduced on a minimal two-service file, to confirm it is interpolation order and not something specific to this project.

Both :? markers on this service become :- with a self-describing sentinel path. A missing value now degrades to "up site-deploy fails, and the error names the variable" instead of "the whole project is bricked". Verified: the project parses with SITE_DEPLOY_SRC unset.

Immediate unblock without this PR: add SITE_DEPLOY_SRC to ~/srv/stack/softserve/.env. That is install step 1 here anyway.

The same footgun already exists on cloudflared (CF_TUNNEL_CREDS_FILE:?). It is set on the live host so it is latent, and cloudflared genuinely cannot start without it — deliberately left alone rather than widening this change. Worth a card.

P1 — env_file: .env gave third-party build code the stack's credentials

site-deploy is the service that runs bun install over the site's entire dependency tree, on a network where softserve and meta-sidecar answer to their own names. env_file: .env put every variable in that file into its environment — SS_SIDECAR_SECRET among them, which is the bearer token for the sidecar API one DNS name away.

site-builder, forty lines above in the same file, carries an explicit comment refusing exactly this and interpolating only the secret it needs. This restores that precedent. CLOUDFLARE_API_TOKEN is named explicitly; nothing else from .env enters the container. Verified by resolving the project and confirming SS_SIDECAR_SECRET is absent from the result.

P2 — the mount exposed private keys and every private repo

The service bound all of ~/srv/softserve read-only. That directory holds ssh/soft_serve_host_ed25519 and ssh/soft_serve_client_ed25519 (both private keys), soft_serve.db, meta.db, and all nine bare repos including the private ones — all readable by the same build code. read_only: true is not a defence when reading is the exposure.

Replaced with two narrow read-only binds: the site's bare repo and the trigger directory, both keyed off SITE_REPO / SITE_TRIGGER_FILE. PR #5's file:// guarantee (the deployer cannot publish an unpushed commit) is untouched — only the blast radius moves.

P5 — the trigger override moved the writer but not the watcher

The hook wrote to ${SITE_TRIGGER_FILE}; the supervisor watched a hardcoded path. Setting the documented override silently killed the fast path while every log line still looked healthy. One variable now drives both halves — verified by resolving with an override set and watching both move together.

P6 / P7 — runbook and stale references

  • SITE_DEPLOY_SRC becomes install step 1, since until it is set no compose command in the runbook runs at all. It was step 4, after two steps that invoke compose.
  • Step 3 now states it is a prerequisite rather than a sanity check: the narrowed binds need deploy-triggers/ to exist first, or Docker invents it as an empty root-owned directory.
  • The stuck-lock section no longer points at ~/srv/stack/softserve-site-deploy-state/_data/. Named volumes do not live in the project directory, and under Docker Desktop for Mac they are not on the host filesystem at all.
  • .path unit and TriggerLimitBurst references removed from the hook header, touch_site_trigger, and hook.test.ts. Those have not existed since 0ff5e67 moved the scheduler into the container, and the hook is what an operator reads inside it.

Deferred

The lock's pid-file write window (a reclaim can fire between mkdir and the pid write) and the double ensure_checkout in main() are both in deploy.sh, which had uncommitted work in flight in the primary checkout when this branch was cut. Neither is affected by anything here.

Verification

make check green: shellcheck 8 files, sqlcheck, gocheck, tscheck 135 tests across 15 suites. Compose behaviour verified by resolving the project under several env combinations rather than by reading the YAML. Nothing here is in the code.fugl.dev request path, and site-deploy has still never been started on the host.

Refs: kanban #26

discussion

  1. Russ T. Fugalcommented

    Rebased onto c502f01 (PR #6). No conflicts — PR #6 is entirely in deploy.sh and supervisor.ts, this branch is entirely in compose.yaml, the hook, the runbook, and hook.test.ts. make check green after the rebase: 137 tests across 15 suites.

  2. Russ T. Fugalcommented

    Independent agent review of this branch found five issues. All five are folded in commit dffc70b. Two were real regressions introduced by the narrowing in the first commit, and both are worth recording because they share a cause.

    The narrowing turned latent config drift into hard failure. The old wholesale /soft-serve mount was hiding two variables that had quietly stopped agreeing with each other:

    1. SITE_REPO did not drive SITE_REPO_URL. The bind source already followed ${SITE_REPO}; the URL was a hardcoded russ/code literal. Setting SITE_REPO alone gave you a container that mounted one repo and cloned from another. Under the wholesale mount every repo was mounted, so the hardcoded URL still resolved and nothing looked wrong — which is exactly why it survived. Now derived: file:///soft-serve/repos/${SITE_REPO:-russ/code}.git.

    2. SITE_TRIGGER_FILE moved the env vars but not the mount. My previous commit claimed one variable drove writer and watcher together. True of the two env vars, false of the bind, which stayed hardcoded at deploy-triggers/. Any override outside that directory left the supervisor watching an unmounted path — trigger dir absent forever, six-hour tick silently the only route, every other log line healthy. That is the same failure mode the commit set out to eliminate, reintroduced one layer down. Fixed with SITE_TRIGGER_DIR as a single knob driving bind source, bind target, and the default SITE_TRIGGER_FILE on both containers.

    3. create_host_path: false was missing on both new binds. ssh-deprecated has carried it for the same reason with a comment calling it load-bearing, thirty lines below where I added the narrow binds. Compose invents an empty directory at a missing bind source, and here that lands inside softserve's data root: a typo in SITE_REPO fabricates repos/<typo>.git/ in the tree softserve serves; up site-deploy before the site repo exists fabricates the real path as an empty directory. A service this branch presents as read-only least-privilege should not be able to corrupt the server's repo list by starting. Now it refuses to start instead.

    4. Stale cross-reference — the compose comment pointed at install step 2 for creating deploy-triggers/, which the first commit renumbered to step 3.

    5. README overclaimed the missing-bind consequence — it described one outcome for both mounts. A missing repo bind means nothing to clone; a missing trigger bind costs only the fast path. Now distinguished, since the runbook uses this to justify step 3 being load-bearing.

    Findings 1 and 3 are the same mistake twice: a precedent already in this file that I did not apply. env_file on site-builder in the original review, create_host_path on ssh-deprecated here.

    Verified by resolving the project under each override rather than by reading YAML — SITE_REPO moves URL and bind together, SITE_TRIGGER_DIR moves both env vars and both bind sides, create_host_path: false survives into the resolved config. make check green: 137 tests across 15 suites.

    The reviewer also confirmed several things I had asserted but not proven: git clone/fetch over file:// goes through git-upload-pack and writes nothing on the source side, so the read-only bind is genuinely sufficient; the resolved environment contains no SS_SIDECAR_SECRET; and the :- sentinel does unbrick docker compose config with SITE_DEPLOY_SRC unset. It could not empirically test fs.watch propagation across the bind on Docker Desktop VirtioFS without starting the container, so that remains reasoning rather than measurement — install step 6 is still the place it gets proven.

5 files changed

This view needs a browser with declarative shadow DOM: Chrome 111, Safari 16.4, or Firefox 123. Read the source instead.

This view needs a browser with declarative shadow DOM: Chrome 111, Safari 16.4, or Firefox 123. Read the source instead.

clone

$ git clone https://git.fugl.dev/fugl.devanonymous, no account
$ git clone ssh://git.fugl.dev/fugl.devneeds the bastion ProxyCommand