anvilsign in

collin/anvil · 47826aaf

Run two CI runners from the dev compose override

Collin Richards · 2026-08-24 09:52 UTC · 47826aafbbc9fb69e85c2509d3983e702529ce0c · parent 7ddd780b · browse files

modified.gitignore+2 −0
⋯ 4 unchanged lines
55 *.db-shm
66 anvil.toml
77 /deploy/anvild
8+# Staged by `./deploy/build.sh --worker` for the local runner containers.
9+/deploy/anvil-worker
810 # CA bundle deploy/dev.sh builds so the dev container trusts portless (docs/oidc.md).
911 /deploy/dev-ca.crt
1012 # Local-only API credentials for fetching attachments (never committed).
⋯ 1 unchanged line
modifiedCLAUDE.md+4 −1
⋯ 112 unchanged lines
113113 runner_token` is set; queued runs just sit, with a warning logged at startup.
114114 Setting one up on the Mac mini (build, Rosetta, the launchd agent in
115115 `deploy/worker/`) is written out in `docs/remote-runners.md` § Isolation on
116-macOS.
116+macOS. Locally, `compose.override.yaml` runs two of them in containers
117+(`./deploy/build.sh --debug --worker`, then `docker compose up -d --build`) so
118+concurrency and routing are testable on one machine; that mount is a
119+development-only concession, never production's.
117120 Agent sessions still drive Docker locally and are the one thing the dropped
118121 socket mount gives up. They are off by default.
119122
⋯ 3 unchanged lines
modifiedCargo.lock+1 −0
⋯ 294 unchanged lines
295295 "clap",
296296 "futures-util",
297297 "reqwest",
298+ "rustls",
298299 "serde_json",
299300 "tar",
300301 "tokio",
⋯ 5518 unchanged lines
modifiedcompose.override.yaml+67 −3
⋯ 3 unchanged lines
44 # the SSO issuer is a public HTTPS URL with a normal CA, and there is
55 # deliberately no Docker socket.
66 #
7-# The image carries a prebuilt binary, so stage one first (--debug compiles in
8-# a fraction of the time a release build takes):
7+# The images carry prebuilt binaries, so stage them first (--debug compiles in
8+# a fraction of the time a release build takes; --worker adds anvil-worker for
9+# the two runner containers below):
910 #
10-# ./deploy/build.sh --debug
11+# ./deploy/build.sh --debug --worker
1112 # docker compose up -d --build
1213 #
1314 # Then http://127.0.0.1:20640. `docker compose logs -f`, `docker compose down`.
1415 #
1516 # deploy/dev.sh remains the fuller path: it also generates deploy/dev-ca.crt
1617 # (mounted below), waits for /-/healthz, and points portless at the container.
18+
19+# Two CI runners, identical but for their names. anvil executes no jobs itself
20+# (docs/remote-runners.md), so without these a queued run sits forever; two of
21+# them rather than one is what makes concurrent pipelines and the "is any
22+# runner connected" side of platform routing testable on one machine.
23+#
24+# LOCAL ONLY, and the reason is worth being explicit about: a containerized
25+# runner needs the host's Docker socket, which is the root-equivalent hold that
26+# moving CI off the forge removed. Real runners are native processes on their
27+# own host (docs/remote-runners.md § Isolation on macOS). This file already
28+# hands anvil the same socket for agent sessions, so the local trust boundary
29+# is unchanged -- the deployed compose.yaml grants neither.
30+x-runner: &runner
31+ image: anvil-worker-dev:latest
32+ build:
33+ context: .
34+ dockerfile: deploy/worker/Dockerfile
35+ platforms:
36+ - linux/amd64
37+ restart: unless-stopped
38+ depends_on:
39+ - anvil
40+ environment: &runner-env
41+ # Container-to-container over the hagrid network, by compose service name:
42+ # ANVIL_BASE_URL is what browsers use and does not resolve in here.
43+ ANVIL_URL: ${ANVIL_RUNNER_URL:-http://anvil:3000}
44+ # Must match `[ci] runner_token` baked in from deploy/anvil.dev.toml.
45+ ANVIL_RUNNER_TOKEN: ${ANVIL_RUNNER_TOKEN:-dev-runner-token}
46+ RUST_LOG: ${ANVIL_RUNNER_LOG:-anvil_worker=info}
47+ volumes:
48+ # The runner is a Docker client: it creates each job's sandbox as a sibling
49+ # container on this host's daemon. The JOB container still gets no socket
50+ # and no mounts -- the checkout is uploaded and artifacts downloaded through
51+ # the API (crates/anvil-worker/src/executor.rs).
52+ #
53+ # `label=disable` rather than a `:z` relabel, same as anvil above: :z would
54+ # rewrite the SELinux label on the host's socket, which every other
55+ # container on this machine also uses.
56+ - /var/run/docker.sock:/var/run/docker.sock
57+ group_add:
58+ - "${DOCKER_GID:-970}"
59+ security_opt:
60+ - label=disable
61+ networks:
62+ - hagrid
63+
1764 services:
1865 anvil:
1966 # A distinct tag, so a local build carrying the DEV config can never be
⋯ 64 unchanged lines
84131 # matches exactly.
85132 ANVIL_BASE_URL: ${ANVIL_BASE_URL:-https://anvil.localhost}
86133
134+ # A third is four lines: copy one of these and bump the name. The names are
135+ # what run headers and `[ci]` logs show, so keep them distinct -- an unnamed
136+ # runner falls back to its hostname, which in a container is a hex id.
137+ runner-1:
138+ <<: *runner
139+ container_name: anvil-runner-1
140+ environment:
141+ <<: *runner-env
142+ ANVIL_RUNNER_NAME: dev-1
143+
144+ runner-2:
145+ <<: *runner
146+ container_name: anvil-runner-2
147+ environment:
148+ <<: *runner-env
149+ ANVIL_RUNNER_NAME: dev-2
150+
87151 volumes:
88152 anvil-dev-data:
89153 name: anvil-dev-data
⋯ 2 unchanged lines
modifiedcrates/anvil-worker/Cargo.toml+1 −0
⋯ 21 unchanged lines
2222 tar.workspace = true
2323 futures-util.workspace = true
2424 reqwest.workspace = true
25+rustls.workspace = true
2526 serde_json.workspace = true
2627 tokio.workspace = true
2728 tracing.workspace = true
⋯ 1 unchanged line
modifiedcrates/anvil-worker/src/client.rs+12 −0
⋯ 28 unchanged lines
2929 }
3030
3131 impl Client {
32+ /// Build the client every request goes through.
33+ ///
34+ /// The rustls *ring* provider is installed process-wide first: the
35+ /// workspace builds reqwest with `-no-provider` on purpose (aws-lc-rs
36+ /// needs cmake and breaks the zig musl cross-build), so a process that
37+ /// does not install one panics inside `build()` — before it has claimed
38+ /// anything, and identically on every host.
3239 pub fn new(base: &str, token: &str, info: RunnerInfo) -> Result<Self, String> {
40+ static TLS_PROVIDER: std::sync::Once = std::sync::Once::new();
41+ TLS_PROVIDER.call_once(|| {
42+ let _ = rustls::crypto::ring::default_provider().install_default();
43+ });
44+
3345 let http = reqwest::Client::builder()
3446 .timeout(REQUEST_TIMEOUT)
3547 .build()
⋯ 152 unchanged lines
modifieddeploy/anvil.dev.toml+6 −0
⋯ 32 unchanged lines
3333 clone_user = "git"
3434
3535 [ci]
36+# The secret the local runners present (compose.override.yaml's `runner-1` and
37+# `runner-2`). Committed like the rest of this file: it authenticates runners
38+# to an instance that only listens on 127.0.0.1. Production's token lives in
39+# the gitignored deploy/anvil.toml. Empty here would refuse every claim with a
40+# 503 and leave queued runs sitting.
41+runner_token = "dev-runner-token"
3642 # The job container is a sibling on the host's Docker daemon, as in production.
3743 memory_mb = 2048
3844 cpus = 2.0
⋯ 18 unchanged lines
modifieddeploy/build.sh+20 −9
⋯ 8 unchanged lines
99 #
1010 # ./deploy/build.sh release — what gets deployed
1111 # ./deploy/build.sh --debug unoptimized, for the local compose.override.yaml
12+# ./deploy/build.sh --worker also stage anvil-worker, for the local runners
13+#
14+# --worker is a local-development thing only: production runners are native
15+# processes on the build host (docs/remote-runners.md), and nothing on hagrid
16+# builds or runs the worker image.
1217 #
1318 # Prereqs (one-time):
1419 # zig (dnf install zig, or brew install zig)
⋯ 4 unchanged lines
1924 TARGET="x86_64-unknown-linux-musl"
2025 PROFILE=release
2126 CARGO_FLAGS=(--release)
27+# Binary name → where the Dockerfile that COPYs it expects to find it.
28+BINS=(anvild)
2229
23-case "${1:-}" in
24- --debug) PROFILE=debug; CARGO_FLAGS=() ;;
25- "") ;;
26- *) echo "usage: $0 [--debug]" >&2; exit 64 ;;
27-esac
30+for arg in "$@"; do
31+ case "$arg" in
32+ --debug) PROFILE=debug; CARGO_FLAGS=() ;;
33+ --worker) BINS+=(anvil-worker) ;;
34+ *) echo "usage: $0 [--debug] [--worker]" >&2; exit 64 ;;
35+ esac
36+done
2837
2938 cd "$(dirname "$0")/.."
3039
⋯ 4 unchanged lines
3544
3645 # Static musl even for --debug: the runtime image is Ubuntu, and a binary
3746 # linked against Fedora's glibc would not run there.
38-echo "==> cross-compiling anvild for $TARGET ($PROFILE, native, via zig)"
39-cargo zigbuild --target "$TARGET" --bin anvild "${CARGO_FLAGS[@]}"
47+for bin in "${BINS[@]}"; do
48+ echo "==> cross-compiling $bin for $TARGET ($PROFILE, native, via zig)"
49+ cargo zigbuild --target "$TARGET" --bin "$bin" "${CARGO_FLAGS[@]}"
4050
41-echo "==> staging binary at deploy/anvild"
42-cp "target/$TARGET/$PROFILE/anvild" deploy/anvild
51+ echo "==> staging binary at deploy/$bin"
52+ cp "target/$TARGET/$PROFILE/$bin" "deploy/$bin"
53+done
4354
4455 # Deliberately left in place: `docker compose build` runs after this and needs
4556 # it in the context. It is gitignored, and the next build overwrites it.
⋯ 1 unchanged line
addeddeploy/worker/Dockerfile+31 −0
1+# anvil-worker image — LOCAL DEVELOPMENT ONLY.
2+#
3+# Production runners are native processes on their own host (a launchd agent on
4+# the Mac mini, see ../../docs/remote-runners.md § Isolation on macOS). This
5+# image exists so `compose.override.yaml` can bring up two runners next to the
6+# local forge and exercise concurrency and platform routing without a second
7+# machine. Do not deploy it: a containerized runner needs the host's Docker
8+# socket mounted in, which is the root-equivalent hold moving CI off the forge
9+# was meant to remove. That trade is already made locally — the dev compose
10+# file mounts the same socket into anvil for agent sessions.
11+#
12+# Same shape as ../../Dockerfile: no compilation here, just a COPY of the
13+# static x86_64-musl binary that `./deploy/build.sh --worker` stages.
14+
15+FROM ubuntu:26.04
16+
17+# ca-certificates so the claim loop can talk to an https:// forge. The local
18+# one is plain http over the compose network, but the image should not be the
19+# reason a runner cannot reach a real instance.
20+RUN apt-get update \
21+ && apt-get install -y --no-install-recommends ca-certificates \
22+ && rm -rf /var/lib/apt/lists/* \
23+ && useradd --system --user-group --home-dir /nonexistent worker
24+
25+COPY deploy/anvil-worker /usr/local/bin/anvil-worker
26+
27+# Unprivileged in the container; compose grants the docker group separately
28+# (`group_add`), which is the only host access the runner needs.
29+USER worker
30+
31+ENTRYPOINT ["/usr/local/bin/anvil-worker"]
modifieddocs/remote-runners.md+27 −0
⋯ 305 unchanged lines
306306 `--name` matters: the default reads `$HOSTNAME`, which launchd does not set, so
307307 an unnamed runner is called `runner`.
308308
309+## Two runners locally
310+
311+`compose.override.yaml` brings up `runner-1` and `runner-2` next to the local
312+forge, so the parts of this design that only appear with more than one runner —
313+concurrent pipelines, and the "no connected runner is native to this platform"
314+half of routing — are testable without a second machine:
315+
316+```sh
317+./deploy/build.sh --debug --worker # stages deploy/anvild and deploy/anvil-worker
318+docker compose up -d --build
319+docker compose logs -f runner-1 runner-2
320+# anvil-worker dev-1 (linux/amd64) → http://anvil:3000
321+```
322+
323+They reach the forge as `http://anvil:3000` over the compose network (the
324+browser-facing `base_url` does not resolve inside a container) and authenticate
325+with the `runner_token` committed in `deploy/anvil.dev.toml`. Both are amd64
326+here, so to watch the fallback tier work, give a pipeline `platform:
327+linux/arm64` and confirm it still gets claimed. Adding a third is a copy of the
328+four-line service block with a new name.
329+
330+These runners **are** containerized, and that is the one thing production must
331+never copy: the socket mount is the root-equivalent hold that moving CI off the
332+forge removed. It is acceptable locally only because the same file already
333+mounts that socket into anvil for agent sessions, so the machine's trust
334+boundary is unchanged. The deployed `compose.yaml` grants neither.
335+
309336 ## Two processes on the build host
310337
311338 Image building **cannot be a CI job**. Job containers get no Docker socket by
⋯ 86 unchanged lines