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 | |||
| 5 | 5 | *.db-shm | |
| 6 | 6 | anvil.toml | |
| 7 | 7 | .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 | |||
| 7 | 7 | `hagrid` Docker network) reverse-proxies to it and provides HTTPS via Let's | |
| 8 | 8 | Encrypt. The web port is **not** published to the host. | |
| 9 | 9 | - **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. | |
| 12 | 13 | - **Runtime** — fully self-contained: SQLite and the SSH crypto are compiled in, | |
| 13 | 14 | and gix is pure-Rust. No git, OpenSSH, or system sqlite in the image. | |
| 14 | 15 | ||
| ⋯ 5 unchanged lines | |||
| 20 | 21 | anvil.richardscollin.com -> <hagrid's public IP> | |
| 21 | 22 | ``` | |
| 22 | 23 | ||
| 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). | |
| 25 | 27 | ||
| 26 | 28 | ## 2. Caddy + index (in the hagrid repo) | |
| 27 | 29 | ||
| ⋯ 14 unchanged lines | |||
| 42 | 44 | ||
| 43 | 45 | Then reload Caddy (`./hagrid.sh reload`, or `./hagrid.sh deploy` to push to the | |
| 44 | 46 | 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: | |
| 46 | 60 | ||
| 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 | + | ``` | |
| 48 | 65 | ||
| 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. | |
| 54 | 74 | ||
| 55 | 75 | **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. | |
| 58 | 77 | ||
| 59 | 78 | One-time toolchain setup: | |
| 60 | 79 | ||
| 61 | 80 | ```sh | |
| 62 | - | brew install zig | |
| 81 | + | dnf install zig # or: brew install zig | |
| 63 | 82 | cargo install cargo-zigbuild | |
| 64 | 83 | rustup target add x86_64-unknown-linux-musl | |
| 84 | + | docker login registry.vibe.richardscollin.com # on this machine and hagrid | |
| 65 | 85 | ``` | |
| 66 | 86 | ||
| 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: | |
| 68 | 92 | ||
| 69 | 93 | ```sh | |
| 70 | - | ./deploy/build.sh | |
| 94 | + | ssh hagrid 'cd anvil && IMAGE_TAG=<sha> docker compose up -d --no-build' | |
| 71 | 95 | ``` | |
| 72 | 96 | ||
| 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: | |
| 85 | 98 | ||
| 86 | 99 | ```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 | |
| 88 | 106 | ``` | |
| 89 | 107 | ||
| 90 | - | which does: | |
| 108 | + | ## 4. What `compose.yaml` runs | |
| 91 | 109 | ||
| 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] | |
| 97 | 117 | ``` | |
| 98 | 118 | ||
| 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. | |
| 104 | 131 | - **No Docker socket.** anvil does not execute CI — runners dial in and run | |
| 105 | 132 | jobs on their own daemons (see [docs/remote-runners.md](docs/remote-runners.md)). | |
| 106 | 133 | The container has no reason to reach Docker, so the mount is gone, and with | |
| ⋯ 1 unchanged line | |||
| 108 | 135 | host. | |
| 109 | 136 | ||
| 110 | 137 | > **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` | |
| 115 | 142 | > §7 before changing that. | |
| 116 | 143 | ||
| 117 | 144 | The baked config lives at `/etc/anvil/anvil.toml` (see `deploy/anvil.toml`). | |
| 118 | 145 | Override it by bind-mounting your own file over that path. | |
| 119 | 146 | ||
| 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. | |
| 127 | 160 | ||
| 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 | + | ||
| 128 | 173 | ## 5. First run: create your account, key, and the repo | |
| 129 | 174 | ||
| 130 | 175 | ```sh | |
| ⋯ 18 unchanged lines | |||
| 149 | 194 | From your local anvil checkout: | |
| 150 | 195 | ||
| 151 | 196 | ```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 | |
| 153 | 198 | git push -u origin main | |
| 154 | 199 | ``` | |
| 155 | 200 | ||
| ⋯ 29 unchanged lines | |||
| 185 | 230 | ||
| 186 | 231 | **Architecture.** The Mac is arm64 and hagrid is x86_64, so a job running | |
| 187 | 232 | `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 | |
| 190 | 235 | not yet wired to a config key. | |
| 191 | 236 | ||
| 192 | 237 | ## 8. The redeploy webhook (CD) | |
| ⋯ 43 unchanged lines | |||
| 236 | 281 | request, runs the actual redeploy. Verify the `X-Anvil-Deploy-Secret` header | |
| 237 | 282 | (set `[ci] deploy_secret`) before doing anything. Because anvil cross-compiles | |
| 238 | 283 | (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. | |
| 240 | 286 | ||
| 241 | 287 | **Building an image is not a CI job.** Job containers get no Docker socket by | |
| 242 | 288 | design, so `docker build`/`docker push` cannot happen inside a pipeline. That | |
| ⋯ 6 unchanged lines | |||
| 249 | 295 | ||
| 250 | 296 | ## Operations | |
| 251 | 297 | ||
| 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 | |
| 256 | 301 | future version won't auto-apply to an existing database yet (Toasty migration | |
| 257 | 302 | support is pending) — the git repos on disk are unaffected, but repo metadata | |
| 258 | 303 | in SQLite may need recreating until migrations land. | |
| 259 | 304 | - **Backup**: snapshot the `anvil-data` volume, e.g. | |
| 260 | 305 | `docker run --rm -v anvil-data:/data -v "$PWD":/out debian:bookworm-slim \ | |
| 261 | 306 | 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). | |
| 263 | 308 | - **System routes** live under `/-/` (e.g. sign in at | |
| 264 | 309 | `https://anvil.richardscollin.com/-/login`); `/{username}` is the user/repo | |
| 265 | 310 | namespace. | |
modifiedREADME.md+16 −0
| ⋯ 33 unchanged lines | |||
| 34 | 34 | container, and (with [portless](https://www.npmjs.com/package/portless)) serves | |
| 35 | 35 | it over HTTPS at `https://anvil.localhost`. | |
| 36 | 36 | ||
| 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 | + | ||
| 37 | 53 | ## Docs | |
| 38 | 54 | ||
| 39 | 55 | - [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
| 1 | 1 | #!/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. | |
| 4 | 4 | # | |
| 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. | |
| 8 | 9 | # | |
| 9 | 10 | # Prereqs (one-time): | |
| 10 | - | # brew install zig | |
| 11 | + | # zig (dnf install zig, or brew install zig) | |
| 11 | 12 | # cargo install cargo-zigbuild | |
| 12 | 13 | # rustup target add x86_64-unknown-linux-musl | |
| 13 | 14 | set -euo pipefail | |
| 14 | 15 | ||
| 15 | - | IMAGE="${ANVIL_IMAGE:-anvil:latest}" | |
| 16 | - | REMOTE="${ANVIL_REMOTE:-hagrid}" | |
| 17 | 16 | TARGET="x86_64-unknown-linux-musl" | |
| 18 | 17 | ||
| 19 | 18 | cd "$(dirname "$0")/.." | |
| ⋯ 8 unchanged lines | |||
| 28 | 27 | ||
| 29 | 28 | echo "==> staging binary at deploy/anvild" | |
| 30 | 29 | 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" . | |
| 34 | 30 | ||
| 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
| 1 | 1 | #!/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 | |
| 5 | 18 | # | |
| 6 | - | # Usage: ./deploy/deploy.sh (env overrides: ANVIL_REMOTE, ANVIL_IMAGE, ...) | |
| 19 | + | # Afterwards: `hag status`, `hag logs -f`, `hag restart`, `hag down`. | |
| 7 | 20 | set -euo pipefail | |
| 8 | 21 | ||
| 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 | |
| 16 | 26 | ||
| 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 | |
| 19 | 36 | ||
| 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" | |
| 23 | 38 | ||
| 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
| 1 | 1 | #!/usr/bin/env bash | |
| 2 | 2 | # Run anvil locally in Docker, reachable at https://anvil.localhost. | |
| 3 | 3 | # | |
| 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 | |
| 5 | 5 | # run on this machine: a container publishing 3000 to a fixed host port, with | |
| 6 | 6 | # portless reverse-proxying a stable `.localhost` name onto it. Running in | |
| 7 | 7 | # 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 | |||
| 51 | 51 | ||
| 52 | 52 | `ANVIL_OIDC_ISSUER`, `ANVIL_OIDC_CLIENT_ID`, `ANVIL_OIDC_CLIENT_SECRET` and | |
| 53 | 53 | `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. | |
| 57 | 57 | A client registered as public needs no secret at all — PKCE protects the code | |
| 58 | 58 | either way, which is how the local dev client is set up. | |
| 59 | 59 | ||
| ⋯ 32 unchanged lines | |||
| 92 | 92 | --grant you@example.com:admin' | |
| 93 | 93 | ``` | |
| 94 | 94 | ||
| 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. | |
| 99 | 108 | ||
| 100 | 109 | Redirect URIs are matched exactly, so development and production need separate | |
| 101 | 110 | entries (pass `--redirect` twice) or separate clients. Production's client here | |
| ⋯ 57 unchanged lines | |||
modifieddocs/remote-runners.md+6 −6
| ⋯ 46 unchanged lines | |||
| 47 | 47 | ||
| 48 | 48 | ## What this buys hagrid | |
| 49 | 49 | ||
| 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 | |
| 52 | 52 | the warning in [DEPLOY.md](../DEPLOY.md) §4 about the container holding | |
| 53 | 53 | root-equivalent control of the host. The internet-facing process stops being a | |
| 54 | 54 | host-escape vector. | |
| ⋯ 144 unchanged lines | |||
| 199 | 199 | ||
| 200 | 200 | The build host is arm64; hagrid is x86_64. Two separate concerns: | |
| 201 | 201 | ||
| 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, | |
| 204 | 204 | though note the `Dockerfile`'s `RUN apt-get …` does execute amd64 binaries | |
| 205 | 205 | under emulation. Since `anvild` is a static musl binary, | |
| 206 | 206 | `gcr.io/distroless/static:nonroot` would make that build pure `COPY` and | |
| ⋯ 97 unchanged lines | |||
| 304 | 304 | ||
| 305 | 305 | **M1 — the split. Done.** `anvil-job`, `anvil-docker` and `anvil-worker` | |
| 306 | 306 | 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. | |
| 309 | 309 | ||
| 310 | 310 | **M2 — platform.** The plumbing is already live: `JobSpec.platform` reaches | |
| 311 | 311 | `CreateContainerOptions` and `CreateImageOptions`, and a runner advertises its | |
| ⋯ 23 unchanged lines | |||