anvilsign in

collin/anvil · 6ba1be4c

Deploy through compose.yaml and the shared hag tool

Collin Richards · 2026-08-24 08:58 UTC · 6ba1be4c75f32951328adf6d017dec45c3eee18f · parent 02f1ec0b · browse files

modified.dockerignore+4 −0
⋯ 4 unchanged lines
55 *.db-shm
66 anvil.toml
77 .git
8+# Deploy plumbing, not image content: compose.yaml describes how to run the
9+# image and .env holds the host's secrets. Neither belongs inside it.
10+compose.yaml
11+.env
modifiedDEPLOY.md+105 −60
⋯ 6 unchanged lines
77 `hagrid` Docker network) reverse-proxies to it and provides HTTPS via Let's
88 Encrypt. The web port is **not** published to the host.
99 - **SSH** — Caddy only fronts HTTP(S), so anvil's SSH server is **published
10- directly to the host** on `:2222`. Git-over-SSH connects to
11- `anvil.richardscollin.com:2222`.
10+ directly to the host**: `:2222` in the container, mapped to `:22` on the
11+ droplet's default public IPv4 (§4). Git-over-SSH connects to
12+ `git@anvil.richardscollin.com` on the default port.
1213 - **Runtime** — fully self-contained: SQLite and the SSH crypto are compiled in,
1314 and gix is pure-Rust. No git, OpenSSH, or system sqlite in the image.
1415
⋯ 5 unchanged lines
2021 anvil.richardscollin.com -> <hagrid's public IP>
2122 ```
2223
23-This one record covers both the web (443, via Caddy) and SSH (2222, direct to
24-the host).
24+This one record covers both the web (443, via Caddy) and SSH (22, direct to
25+the host). The `A` record must be the same address `compose.yaml` binds the SSH
26+port to — today `165.232.162.167` (§4).
2527
2628 ## 2. Caddy + index (in the hagrid repo)
2729
⋯ 14 unchanged lines
4244
4345 Then reload Caddy (`./hagrid.sh reload`, or `./hagrid.sh deploy` to push to the
4446 host). Caddy resolves `anvil:3000` by container name over the `hagrid` network,
45-so the anvil container must join that network (the run script does this).
47+so the anvil container must join that network (`compose.yaml` does this).
48+
49+## 3. Deploying: `compose.yaml` + `hag`
50+
51+`compose.yaml` at the repo root describes the deployment — the image, the
52+network, the volume, the published SSH port. Running it on hagrid is
53+[`hag`](https://anvil.richardscollin.com/collin/hagrid), the shared deploy tool
54+for every project on that host: it builds the image, pushes it to
55+`registry.vibe.richardscollin.com`, copies `compose.yaml` up, then pulls and
56+recreates the container there. Install it once with `~/Code/hagrid/hag install`.
57+
58+anvil needs one step in front of that, because the image carries a **prebuilt**
59+binary — `deploy/deploy.sh` is the whole deploy:
4660
47-## 3. Build the image on your Mac, ship it to hagrid
61+```sh
62+./deploy/deploy.sh # cross-compile, build, push, restart on hagrid
63+./deploy/deploy.sh -n # dry run: print every step, change nothing
64+```
4865
49-**One-shot:** `./deploy/deploy.sh` does this whole section *and* section 4 in
50-one go — it opens a single multiplexed SSH connection to hagrid (so the key
51-passphrase is asked at most once), runs `build.sh` over it, then pipes
52-`run.sh` to the host to restart the container. The rest of this section
53-describes the individual steps it composes.
66+which is:
67+
68+1. `deploy/build.sh` — `cargo zigbuild --release --target
69+ x86_64-unknown-linux-musl` cross-compiles a fully static binary natively
70+ (~2 min, no emulation) and stages it at `deploy/anvild`,
71+2. `hag deploy` — `docker compose build --push` builds the thin image that just
72+ `COPY`s that binary in (the `Dockerfile` compiles nothing), pushes it, then
73+ drives the host.
5474
5575 **Do not build on the VPS** — a release build needs ~2–4 GB peak and OOMs a
56-cheap, swap-less droplet. Instead, cross-compile a static binary on your Mac
57-(native speed, no QEMU) and copy it into a thin image.
76+cheap, swap-less droplet. The VPS never compiles anything; it only pulls.
5877
5978 One-time toolchain setup:
6079
6180 ```sh
62-brew install zig
81+dnf install zig # or: brew install zig
6382 cargo install cargo-zigbuild
6483 rustup target add x86_64-unknown-linux-musl
84+docker login registry.vibe.richardscollin.com # on this machine and hagrid
6585 ```
6686
67-Then, from a checkout of this repo on your Mac:
87+Every build is tagged twice: with the short git sha of the checkout (plus a
88+`-dirty` suffix when the working tree has uncommitted changes) and with
89+`latest`. The deploy pins the host to the exact sha it just pushed, so `hag
90+status` always names the running build. To roll back, run an older tag on the
91+host:
6892
6993 ```sh
70-./deploy/build.sh
94+ssh hagrid 'cd anvil && IMAGE_TAG=<sha> docker compose up -d --no-build'
7195 ```
7296
73-That:
74-1. `cargo zigbuild --release --target x86_64-unknown-linux-musl` — cross-compiles
75- a fully static `x86_64`-musl binary natively (~2 min, no emulation),
76-2. stages it at `deploy/anvild` and builds a thin image that just `COPY`s it in
77- (the `Dockerfile` does no compilation — fast),
78-3. ships it: `docker save | gzip | ssh hagrid 'docker load'`.
79-
80-The VPS never compiles anything.
81-
82-## 4. Run the container on hagrid
83-
84-On the hagrid host (only runs docker — no build):
97+Managing it afterwards needs no ssh and no remembering where it lives:
8598
8699 ```sh
87-./deploy/run.sh
100+hag status # what the host is running
101+hag logs -f # its logs
102+hag restart # recreate its containers
103+hag down # stop it
104+hag config # what hag resolved for this project
105+hag ls # every compose project on the host
88106 ```
89107
90-which does:
108+## 4. What `compose.yaml` runs
91109
92-```sh
93-docker run -d --name anvil --network hagrid --restart unless-stopped \
94- -p 2222:2222 \
95- -v anvil-data:/data \
96- anvil:latest
110+```yaml
111+image: registry.vibe.richardscollin.com/anvil:${IMAGE_TAG:-latest}
112+container_name: anvil
113+restart: unless-stopped
114+ports: ["165.232.162.167:22:2222"]
115+volumes: [anvil-data:/data]
116+networks: [hagrid]
97117 ```
98118
99-- `--network hagrid` — so Caddy can reach `anvil:3000`.
100-- `-p 2222:2222` — publishes SSH to the host.
101-- `-v anvil-data:/data` — a named volume holding the SQLite DB, the bare repos,
102- and the persistent SSH **host key**. Use a named volume (not a host bind
103- mount) so it's owned by the in-container `anvil` user.
119+- `networks: [hagrid]` — an external network, so Caddy can reach `anvil:3000`.
120+ The web port is deliberately **not** published.
121+- `ports` — git-over-SSH, published on the droplet's *default* public IPv4 only.
122+ The reserved IP (`137.184.249.48`) arrives on the anchor address `10.15.0.6`,
123+ where the host's own sshd listens, so binding one specific IP keeps the two
124+ off each other. `ANVIL_SSH_BIND_IP` / `ANVIL_SSH_PORT` in the host's `.env`
125+ override it.
126+- `anvil-data:/data` — a named volume holding the SQLite DB, the bare repos and
127+ the persistent SSH **host key**. Pinned with `name:` to that exact string, so
128+ compose adopts the volume the pre-compose deploys created instead of deriving
129+ a fresh empty `anvil_anvil-data` from the project name. A named volume (not a
130+ host bind mount) keeps it owned by the in-container `anvil` user.
104131 - **No Docker socket.** anvil does not execute CI — runners dial in and run
105132 jobs on their own daemons (see [docs/remote-runners.md](docs/remote-runners.md)).
106133 The container has no reason to reach Docker, so the mount is gone, and with
⋯ 1 unchanged line
108135 host.
109136
110137 > **Agent sessions are the exception.** They still drive Docker locally, so
111-> turning them on means putting the socket back
112-> (`ANVIL_DOCKER_SOCK=/var/run/docker.sock ./deploy/run.sh`) and accepting that
113-> the container again has **root-equivalent** control of the host. They are off
114-> by default and absent from `deploy/anvil.toml`; read `docs/untrusted-mode.md`
138+> turning them on means putting the socket back — a `volumes:` entry for
139+> `/var/run/docker.sock` and a matching `group_add:` — and accepting that the
140+> container again has **root-equivalent** control of the host. They are off by
141+> default and absent from `deploy/anvil.toml`; read `docs/untrusted-mode.md`
115142 > §7 before changing that.
116143
117144 The baked config lives at `/etc/anvil/anvil.toml` (see `deploy/anvil.toml`).
118145 Override it by bind-mounting your own file over that path.
119146
120-> **If you must build on the VPS anyway** (not recommended): give it swap and
121-> cap parallelism, or it will OOM —
122-> ```sh
123-> sudo fallocate -l 4G /swapfile && sudo chmod 600 /swapfile \
124-> && sudo mkswap /swapfile && sudo swapon /swapfile # persist in /etc/fstab
125-> # then build with CARGO_BUILD_JOBS=1 (slow, but survives 1 GB RAM)
126-> ```
147+**Host-side config.** `compose.yaml` loads `~/anvil/.env` on the host if it
148+exists, and skips it if not — `hag` never copies a local `.env` up, so the
149+workstation and the host keep separate config. Today that file carries the SSO
150+client secret (§ [docs/oidc.md](docs/oidc.md)):
151+
152+```sh
153+ssh hagrid 'printf "ANVIL_OIDC_CLIENT_SECRET=%s\n" \
154+ "$(tr -d "[:space:]" < ~/.config/anvil/oidc-client-secret)" > ~/anvil/.env \
155+ && chmod 600 ~/anvil/.env'
156+```
157+
158+Leave the variable out entirely rather than setting it empty: empty would
159+override the baked config and turn a confidential OIDC client into a public one.
127160
161+**Migrating from the `docker run` deploys.** The old container was created by
162+`docker run`, so it carries no compose labels; compose reads `container_name:
163+anvil` as taken by a foreign container and refuses to adopt it. Clear it once,
164+before the first compose deploy:
165+
166+```sh
167+ssh hagrid 'docker rm -f anvil'
168+```
169+
170+The `anvil-data` volume is untouched by that and is what the new container
171+picks up.
172+
128173 ## 5. First run: create your account, key, and the repo
129174
130175 ```sh
⋯ 18 unchanged lines
149194 From your local anvil checkout:
150195
151196 ```sh
152-git remote add origin ssh://git@anvil.richardscollin.com:2222/collin/anvil.git
197+git remote add origin ssh://git@anvil.richardscollin.com/collin/anvil.git
153198 git push -u origin main
154199 ```
155200
⋯ 29 unchanged lines
185230
186231 **Architecture.** The Mac is arm64 and hagrid is x86_64, so a job running
187232 `cargo test` on the runner tests an architecture you do not ship. Images are
188-unaffected (`deploy/build.sh` already cross-compiles and builds
189-`--platform linux/amd64`), and per-pipeline platform selection is designed but
233+unaffected (`deploy/build.sh` cross-compiles, and `compose.yaml` pins
234+`platforms: [linux/amd64]`), and per-pipeline platform selection is designed but
190235 not yet wired to a config key.
191236
192237 ## 8. The redeploy webhook (CD)
⋯ 43 unchanged lines
236281 request, runs the actual redeploy. Verify the `X-Anvil-Deploy-Secret` header
237282 (set `[ci] deploy_secret`) before doing anything. Because anvil cross-compiles
238283 (section 3), "redeploy anvil" usually means: the receiver pulls the freshly
239-built image and re-runs `deploy/run.sh` — it does **not** build in-place.
284+built image and runs `docker compose up -d --no-build` in `~/anvil` — it does
285+**not** build in-place.
240286
241287 **Building an image is not a CI job.** Job containers get no Docker socket by
242288 design, so `docker build`/`docker push` cannot happen inside a pipeline. That
⋯ 6 unchanged lines
249295
250296 ## Operations
251297
252-- **Update**: from the Mac, `./deploy/deploy.sh` (build + ship + restart in
253- one command, one passphrase prompt); or on hagrid, re-run `./deploy/run.sh`
254- to recreate the container from the already-loaded image (the `anvil-data`
255- volume persists). NOTE: adding new DB tables in a
298+- **Update**: `./deploy/deploy.sh` (cross-compile + push + restart in one
299+ command); or `hag restart` to recreate the container from the image already
300+ on the host (the `anvil-data` volume persists). NOTE: adding new DB tables in a
256301 future version won't auto-apply to an existing database yet (Toasty migration
257302 support is pending) — the git repos on disk are unaffected, but repo metadata
258303 in SQLite may need recreating until migrations land.
259304 - **Backup**: snapshot the `anvil-data` volume, e.g.
260305 `docker run --rm -v anvil-data:/data -v "$PWD":/out debian:bookworm-slim \
261306 tar czf /out/anvil-data.tgz -C /data .`
262-- **Logs**: `docker logs -f anvil`.
307+- **Logs**: `hag logs -f` (or `docker logs -f anvil` on the host).
263308 - **System routes** live under `/-/` (e.g. sign in at
264309 `https://anvil.richardscollin.com/-/login`); `/{username}` is the user/repo
265310 namespace.
modifiedREADME.md+16 −0
⋯ 33 unchanged lines
3434 container, and (with [portless](https://www.npmjs.com/package/portless)) serves
3535 it over HTTPS at `https://anvil.localhost`.
3636
37+## Deploying
38+
39+`compose.yaml` describes the deployment; `hag`, the shared deploy tool for
40+every project on hagrid, runs it there. The image carries a prebuilt static
41+binary rather than compiling in Docker, so one step comes first:
42+
43+```sh
44+./deploy/deploy.sh # cross-compile, build, push, then restart on the host
45+./deploy/deploy.sh -n # dry run: print every step, change nothing
46+hag status # what the host is running
47+hag logs -f # its logs
48+```
49+
50+Full details, including first-run setup and the CD webhook, are in
51+[DEPLOY.md](DEPLOY.md).
52+
3753 ## Docs
3854
3955 - [CI artifacts](docs/ci-artifacts.md)
⋯ 6 unchanged lines
addedcompose.yaml+77 −0
1+# anvil on hagrid, deployed with `hag` -- the shared deploy tool for every
2+# project on that host (build, push to the private registry, then pull and
3+# recreate there). Both this machine and hagrid need
4+# `docker login registry.vibe.richardscollin.com` once.
5+#
6+# The image carries a PREBUILT binary: the Dockerfile only COPYs in
7+# deploy/anvild, which deploy/build.sh cross-compiles as a static x86_64-musl
8+# executable. So `hag deploy` on its own is not enough -- use
9+# ./deploy/deploy.sh, which stages the binary and then calls it.
10+#
11+# IMAGE_TAG picks which build runs. hag sets it to the git sha it just pushed,
12+# so `hag status` names the exact build, and rolling back on the host is
13+# `IMAGE_TAG=<sha> docker compose up -d --no-build`.
14+services:
15+ anvil:
16+ image: registry.vibe.richardscollin.com/anvil:${IMAGE_TAG:-latest}
17+ build:
18+ context: .
19+ # hagrid is x86_64 and the staged binary is x86_64-musl. Pinning the
20+ # platform stops an arm64 workstation from producing an image whose
21+ # base layers the host cannot run.
22+ platforms:
23+ - linux/amd64
24+ container_name: anvil
25+ restart: unless-stopped
26+
27+ # Host config that must not be baked into the image -- today that is
28+ # ANVIL_OIDC_CLIENT_SECRET (docs/oidc.md). Optional so the container still
29+ # starts where there is none, matching how run.sh only passed the secret
30+ # when it found one: an empty value here would override the baked config
31+ # and turn a confidential OIDC client into a public one.
32+ env_file:
33+ - path: .env
34+ required: false
35+
36+ # [ci] deploy_webhook posts to a receiver on the host, so the container
37+ # needs a route to it. Docker Desktop supplies this name; Linux does not.
38+ extra_hosts:
39+ - "host.docker.internal:host-gateway"
40+
41+ ports:
42+ # Git-over-SSH only. The web port stays unpublished -- Caddy reaches
43+ # anvil:3000 over the hagrid network and terminates TLS.
44+ #
45+ # Published on the droplet's DEFAULT public IPv4 alone. The reserved IP
46+ # (137.184.249.48) arrives on the anchor address 10.15.0.6, where the
47+ # host's own sshd listens, so binding one specific IP here keeps the two
48+ # off each other.
49+ - "${ANVIL_SSH_BIND_IP:-165.232.162.167}:${ANVIL_SSH_PORT:-22}:2222"
50+
51+ # NO DOCKER SOCKET. anvil does not execute CI any more -- runners dial in
52+ # and run jobs on their own daemons (docs/remote-runners.md), so the
53+ # container has no reason to reach Docker. Leaving the mount out is what
54+ # removes the root-equivalent hold the internet-facing process used to have
55+ # on the host. Agent sessions (crates/anvil-agent) are the one thing this
56+ # gives up; they are off in deploy/anvil.toml and off by default. Read
57+ # docs/untrusted-mode.md before putting the mount back.
58+ volumes:
59+ - anvil-data:/data
60+
61+ networks:
62+ - hagrid
63+
64+volumes:
65+ # `name:` pins the volume to the exact name the pre-compose deploys used, so
66+ # this adopts the existing SQLite DB, bare repos and SSH host key rather than
67+ # coming up against an empty `anvil_anvil-data` that compose would otherwise
68+ # derive from the project name. A named volume (not a bind mount) keeps it
69+ # owned by the in-container `anvil` user.
70+ anvil-data:
71+ name: anvil-data
72+
73+# Caddy runs on this network and reverse-proxies to the container by name.
74+# The hagrid repo owns the network's lifecycle.
75+networks:
76+ hagrid:
77+ external: true
modifieddeploy/build.sh+10 −18
11 #!/usr/bin/env bash
2-# Cross-compile anvil natively on the build host (Mac) and ship the image to
3-# hagrid. NOT run on the VPS — it never compiles anything.
2+# Stage the anvild binary that the image COPYs in. NOT run on the VPS — it
3+# never compiles anything.
44 #
5-# Uses cargo-zigbuild to cross-compile a fully static x86_64-musl binary at
6-# native speed (no QEMU), stages it at deploy/anvild, builds a thin image that
7-# just COPYs it in, and pipes the image to hagrid via docker load.
5+# Uses cargo-zigbuild to cross-compile a fully static x86_64-musl executable at
6+# native speed (no QEMU) and leaves it at deploy/anvild, which is where the
7+# Dockerfile expects it. Building and pushing the image is compose's job from
8+# there (see compose.yaml); ./deploy/deploy.sh runs both halves.
89 #
910 # Prereqs (one-time):
10-# brew install zig
11+# zig (dnf install zig, or brew install zig)
1112 # cargo install cargo-zigbuild
1213 # rustup target add x86_64-unknown-linux-musl
1314 set -euo pipefail
1415
15-IMAGE="${ANVIL_IMAGE:-anvil:latest}"
16-REMOTE="${ANVIL_REMOTE:-hagrid}"
1716 TARGET="x86_64-unknown-linux-musl"
1817
1918 cd "$(dirname "$0")/.."
⋯ 8 unchanged lines
2827
2928 echo "==> staging binary at deploy/anvild"
3029 cp "target/$TARGET/release/anvild" deploy/anvild
31-
32-echo "==> building $IMAGE (just COPYs the binary — fast)"
33-docker build --platform linux/amd64 -t "$IMAGE" .
3430
35-echo "==> shipping $IMAGE to $REMOTE"
36-# ANVIL_SSH_OPTS lets deploy.sh point us at its multiplexed master connection.
37-# shellcheck disable=SC2086
38-docker save "$IMAGE" | gzip | ssh ${ANVIL_SSH_OPTS:-} "$REMOTE" 'docker load'
39-
40-rm -f deploy/anvild
41-echo "==> done. On $REMOTE, run ./deploy/run.sh to (re)start the container."
31+# Deliberately left in place: `docker compose build` runs after this and needs
32+# it in the context. It is gitignored, and the next build overwrites it.
33+echo "==> staged. Build the image with: docker compose build (or ./deploy/deploy.sh)"
modifieddeploy/deploy.sh+32 −19
11 #!/usr/bin/env bash
2-# One-shot build + deploy from the Mac: cross-compile, ship the image to
3-# hagrid, and (re)start the container there — over a single multiplexed SSH
4-# connection, so the key passphrase is asked at most once.
2+# Build and deploy anvil to hagrid.
3+#
4+# Two halves, because the image carries a prebuilt binary rather than compiling
5+# in Docker:
6+#
7+# 1. build.sh cross-compiles anvild and stages it at deploy/anvild,
8+# 2. `hag deploy` does the rest from compose.yaml — build the image, push it
9+# to the registry, copy compose.yaml to the host, pull and recreate there.
10+#
11+# hag is the shared deploy tool for every project on hagrid and lives in the
12+# hagrid repo, so nothing here is anvil-specific any more. Install it once with
13+# `~/Code/hagrid/hag install`.
14+#
15+# Usage: ./deploy/deploy.sh [hag deploy options]
16+# e.g. ./deploy/deploy.sh -n dry run (skips the compile)
17+# ./deploy/deploy.sh --host other deploy elsewhere
518 #
6-# Usage: ./deploy/deploy.sh (env overrides: ANVIL_REMOTE, ANVIL_IMAGE, ...)
19+# Afterwards: `hag status`, `hag logs -f`, `hag restart`, `hag down`.
720 set -euo pipefail
821
9-REMOTE="${ANVIL_REMOTE:-hagrid}"
10-SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
11-
12-# Master connection socket. Everything below reuses it via `ssh -S`.
13-CTL="${TMPDIR:-/tmp}/anvil-deploy-$$.sock"
14-cleanup() { ssh -S "$CTL" -O exit "$REMOTE" 2>/dev/null || true; }
15-trap cleanup EXIT
22+if ! command -v hag >/dev/null; then
23+ echo "deploy.sh: hag is not on PATH — run: ~/Code/hagrid/hag install" >&2
24+ exit 1
25+fi
1626
17-echo "==> opening SSH master connection to $REMOTE (passphrase asked once)"
18-ssh -MNf -S "$CTL" "$REMOTE"
27+# A dry run reports what would happen and changes nothing, so spending two
28+# minutes on a release build first would defeat the point. hag reads the image
29+# reference out of compose.yaml, which does not need the binary to exist.
30+DRY_RUN=0
31+for arg in "$@"; do
32+ case "$arg" in
33+ -n | --dry-run) DRY_RUN=1 ;;
34+ esac
35+done
1936
20-# build.sh ships the image with `ssh $ANVIL_SSH_OPTS`, riding the master.
21-export ANVIL_SSH_OPTS="-S $CTL"
22-"$SCRIPT_DIR/build.sh"
37+[ "$DRY_RUN" = 1 ] || "$(dirname "$0")/build.sh"
2338
24-echo "==> (re)starting anvil on $REMOTE"
25-# run.sh is standalone (docker only), so pipe it over — no checkout needed.
26-ssh -S "$CTL" "$REMOTE" 'bash -s' < "$SCRIPT_DIR/run.sh"
39+exec hag deploy "$@"
modifieddeploy/dev.sh+1 −1
11 #!/usr/bin/env bash
22 # Run anvil locally in Docker, reachable at https://anvil.localhost.
33 #
4-# The same image shape as production (deploy/build.sh + run.sh), but built and
4+# The same image shape as production (deploy/build.sh + compose.yaml), but built and
55 # run on this machine: a container publishing 3000 to a fixed host port, with
66 # portless reverse-proxying a stable `.localhost` name onto it. Running in
77 # Docker rather than `cargo run` is what makes CI testable — the runner drives
⋯ 132 unchanged lines
deleteddeploy/run.sh+0 −70
1-#!/usr/bin/env bash
2-# (Re)start the anvil container on hagrid from an ALREADY-LOADED image.
3-#
4-# Build and ship the image first with deploy/build.sh on a capable machine
5-# (the VPS can't compile it). This script only runs docker — no build — so it's
6-# safe on the low-RAM box. Standalone: needs only docker + the loaded image.
7-set -euo pipefail
8-
9-IMAGE="${ANVIL_IMAGE:-anvil:latest}"
10-NETWORK="${ANVIL_NETWORK:-hagrid}"
11-SSH_PORT="${ANVIL_SSH_PORT:-22}"
12-# Publish SSH on the droplet's DEFAULT public IPv4 only. The reserved IP
13-# (137.184.249.48) is reached via the anchor IP 10.15.0.6, where the host's
14-# own sshd listens — binding a specific IP here keeps the two off each other.
15-SSH_BIND_IP="${ANVIL_SSH_BIND_IP:-165.232.162.167}"
16-# NO DOCKER SOCKET. anvil does not execute CI any more -- runners dial in and
17-# run jobs on their own daemons (docs/remote-runners.md), so the container has
18-# no reason to reach Docker at all. Dropping the mount removes what used to be
19-# a root-equivalent hold on the host from the internet-facing process.
20-#
21-# The one thing this gives up is agent sessions, which still drive Docker
22-# locally (crates/anvil-agent). They are off in deploy/anvil.toml and off by
23-# default, so nothing here regresses. Set ANVIL_DOCKER_SOCK=/var/run/docker.sock
24-# to put the mount back if you turn them on -- and re-read docs/untrusted-mode.md
25-# before you do.
26-DOCKER_SOCK="${ANVIL_DOCKER_SOCK:-}"
27-
28-DOCKER_ARGS=()
29-if [[ -n "$DOCKER_SOCK" ]]; then
30- DOCKER_ARGS=(-v "${DOCKER_SOCK}:/var/run/docker.sock"
31- --group-add "$(stat -c '%g' "$DOCKER_SOCK")")
32- echo "==> WARNING: mounting ${DOCKER_SOCK} (root-equivalent on this host)"
33-fi
34-
35-# Single sign-on's client secret, if this instance uses one (docs/oidc.md).
36-#
37-# Read from a file on the host by default, because deploy/deploy.sh pipes this
38-# script over ssh (`ssh host 'bash -s' < run.sh`) and no environment travels
39-# with it — an env var alone would silently vanish on exactly the path that
40-# matters. ANVIL_OIDC_CLIENT_SECRET still wins when running this by hand.
41-#
42-# Passed only when non-empty: an empty value would override the baked config
43-# with "no secret" and turn a confidential client into a public one.
44-OIDC_SECRET_FILE="${ANVIL_OIDC_SECRET_FILE:-$HOME/.config/anvil/oidc-client-secret}"
45-OIDC_SECRET="${ANVIL_OIDC_CLIENT_SECRET:-}"
46-if [[ -z "$OIDC_SECRET" && -r "$OIDC_SECRET_FILE" ]]; then
47- OIDC_SECRET="$(tr -d '[:space:]' <"$OIDC_SECRET_FILE")"
48-fi
49-
50-OIDC_ENV=()
51-if [[ -n "$OIDC_SECRET" ]]; then
52- OIDC_ENV=(-e "ANVIL_OIDC_CLIENT_SECRET=${OIDC_SECRET}")
53- echo "==> single sign-on: client secret loaded"
54-else
55- echo "==> single sign-on: no client secret found (${OIDC_SECRET_FILE})"
56-fi
57-
58-docker rm -f anvil 2>/dev/null || true
59-docker run -d \
60- --name anvil \
61- --network "$NETWORK" \
62- --restart unless-stopped \
63- -p "${SSH_BIND_IP}:${SSH_PORT}:2222" \
64- -v anvil-data:/data \
65- "${DOCKER_ARGS[@]}" \
66- "${OIDC_ENV[@]}" \
67- "$IMAGE"
68-
69-echo "==> anvil (re)started from $IMAGE (web: anvil:3000 via Caddy, ssh: ${SSH_BIND_IP}:${SSH_PORT})"
70-echo "==> CI needs a runner: anvil-worker --url https://anvil.richardscollin.com --token ..."
modifieddocs/oidc.md+16 −7
⋯ 50 unchanged lines
5151
5252 `ANVIL_OIDC_ISSUER`, `ANVIL_OIDC_CLIENT_ID`, `ANVIL_OIDC_CLIENT_SECRET` and
5353 `ANVIL_OIDC_REDIRECT_URI` override the file. **Keep the secret out of the
54-config file**: those get committed. `deploy/run.sh` reads it from
55-`~/.config/anvil/oidc-client-secret` on the host (or the environment, which
56-wins), and `deploy/dev.sh` passes `ANVIL_OIDC_CLIENT_SECRET` through when set.
54+config file**: those get committed. In production it comes from `~/anvil/.env`
55+on the host, which `compose.yaml` loads if present and skips if not, and
56+`deploy/dev.sh` passes `ANVIL_OIDC_CLIENT_SECRET` through when set.
5757 A client registered as public needs no secret at all — PKCE protects the code
5858 either way, which is how the local dev client is set up.
5959
⋯ 32 unchanged lines
9292 --grant you@example.com:admin'
9393 ```
9494
95-It prints the secret once. Put it at `~/.config/anvil/oidc-client-secret`
96-(mode 600) on the host, which is where `deploy/run.sh` looks — `deploy.sh`
97-pipes that script over ssh with no environment attached, so a file is the only
98-thing that survives the trip.
95+It prints the secret once. Put it in `~/anvil/.env` on the host (mode 600),
96+which is the compose project directory `hag` deploys into:
97+
98+```sh
99+ssh hagrid 'printf "ANVIL_OIDC_CLIENT_SECRET=%s\n" "<secret>" > ~/anvil/.env \
100+ && chmod 600 ~/anvil/.env'
101+```
102+
103+It lives on the host rather than in the repo because `hag` copies only
104+`compose.yaml` up — a local `.env` never travels, so the workstation and the
105+host keep separate config. Omit the variable entirely rather than setting it
106+empty: empty overrides the baked config and turns a confidential client into a
107+public one.
99108
100109 Redirect URIs are matched exactly, so development and production need separate
101110 entries (pass `--redirect` twice) or separate clients. Production's client here
⋯ 57 unchanged lines
modifieddocs/remote-runners.md+6 −6
⋯ 46 unchanged lines
4747
4848 ## What this buys hagrid
4949
50-anvild stops needing Docker at all. `deploy/run.sh` drops
51-`-v /var/run/docker.sock:/var/run/docker.sock` and `--group-add`, which deletes
50+anvild stops needing Docker at all. `compose.yaml` carries no
51+`/var/run/docker.sock` mount and no `group_add`, which deletes
5252 the warning in [DEPLOY.md](../DEPLOY.md) §4 about the container holding
5353 root-equivalent control of the host. The internet-facing process stops being a
5454 host-escape vector.
⋯ 144 unchanged lines
199199
200200 The build host is arm64; hagrid is x86_64. Two separate concerns:
201201
202-**What the shipped image is.** Already solved: `deploy/build.sh:33` cross-
203-compiles with zigbuild and builds `--platform linux/amd64`. Nothing to do,
202+**What the shipped image is.** Already solved: `deploy/build.sh` cross-compiles
203+with zigbuild and `compose.yaml` pins `platforms: [linux/amd64]`. Nothing to do,
204204 though note the `Dockerfile`'s `RUN apt-get …` does execute amd64 binaries
205205 under emulation. Since `anvild` is a static musl binary,
206206 `gcr.io/distroless/static:nonroot` would make that build pure `COPY` and
⋯ 97 unchanged lines
304304
305305 **M1 — the split. Done.** `anvil-job`, `anvil-docker` and `anvil-worker`
306306 crates, the five endpoints, in-memory leases, `[ci] runner_token`,
307-`connect_with_defaults`, `run_worker` → `run_dispatcher`, socket mount dropped
308-from `deploy/run.sh`. Deploys keep using the existing webhook.
307+`connect_with_defaults`, `run_worker` → `run_dispatcher`, and no socket mount in
308+the deployed container. Deploys keep using the existing webhook.
309309
310310 **M2 — platform.** The plumbing is already live: `JobSpec.platform` reaches
311311 `CreateContainerOptions` and `CreateImageOptions`, and a runner advertises its
⋯ 23 unchanged lines