collin/anvil
30efd74dd21aab2ffb51d6f626a5a7c9e1499123 / CLAUDE.md
RenderedSource
| 1 | # anvil |
| 2 | |
| 3 | A minimal, self-hosted git forge in Rust, built on gix (gitoxide). |
| 4 | |
| 5 | ## Design rules |
| 6 | |
| 7 | - **Pure gitoxide — never the git binary.** The product (server, CI broker, |
| 8 | mirroring, deploy image) must not invoke or depend on a `git` executable. |
| 9 | When gix lacks a capability, implement the protocol on gix plumbing instead |
| 10 | of shelling out — e.g. gix can't push yet, so `anvil-git/src/push.rs` speaks |
| 11 | the send-pack wire format itself (with `gitserver-core` covering the server |
| 12 | side). Tests may use the git CLI, but only as a fixture/interop check, never |
| 13 | on the code path under test. |
| 14 | |
| 15 | Dev setup: `git config core.hooksPath .githooks` — the pre-commit hook checks |
| 16 | that `rust-toolchain.toml`'s pin satisfies Cargo.toml's `rust-version` floor, |
| 17 | then runs `cargo +nightly fmt` (nightly, for the unstable options in |
| 18 | rustfmt.toml), `cargo sort-derives` (`cargo install cargo-sort-derives`), and |
| 19 | `cargo clippy --workspace --all-targets -- -D warnings`. |
| 20 | |
| 21 | **The Rust version is set in exactly one place: `rust-toolchain.toml`.** It |
| 22 | pins the toolchain and the musl cross target for every `cargo` invocation here, |
| 23 | and `docker/runner/build.sh` parses `[toolchain] channel` out of it to bake the |
| 24 | same version into `anvil-runner:rust` — so a CI job and a checkout compile with |
| 25 | the same rustc. Bumping Rust means editing that file and Cargo.toml's floor |
| 26 | (the hook catches you if you forget the second), then rebuilding the image. |
| 27 | |
| 28 | ## Viewing attachments referenced in tasks |
| 29 | |
| 30 | TODO items and tickets may embed an uploaded image as |
| 31 | ``. These bytes live outside git |
| 32 | history, so to actually *see* one, get the bytes and `Read` the file. Two ways: |
| 33 | |
| 34 | **Preferred — over git, no credentials.** anvil mirrors every attachment into |
| 35 | `refs/anvil/attachments` (a flat tree of `<hash> → blob`, off the branch |
| 36 | namespace so a default pull never drags it down). From a clone, opt in once: |
| 37 | |
| 38 | ``` |
| 39 | git fetch origin '+refs/anvil/attachments:refs/anvil/attachments' |
| 40 | git cat-file -p "refs/anvil/attachments:<hash>" > /tmp/att && # then Read /tmp/att |
| 41 | ``` |
| 42 | |
| 43 | This uses the clone's existing git auth — no PAT — and works offline afterward. |
| 44 | |
| 45 | **Fallback — HTTP with a PAT** (no clone, or the ref isn't fetched). Credentials |
| 46 | live in the git-ignored `.anvil-credentials` at the repo root (`ANVIL_BASE_URL` + |
| 47 | a read-only `ANVIL_TOKEN`); `source` it, then: |
| 48 | |
| 49 | ``` |
| 50 | curl -fsS -H "Authorization: Bearer $ANVIL_TOKEN" \ |
| 51 | "$ANVIL_BASE_URL/{owner}/{repo}/-/attachments/{hash}" -o /tmp/att && # Read it |
| 52 | ``` |
| 53 | |
| 54 | The PAT is read-only (GET/HEAD only), safe to hold. If neither the ref nor |
| 55 | `.anvil-credentials` is available, ask the user rather than guessing. |
| 56 | |
| 57 | Attachments are often phone screenshots larger than the `Read` tool's 256KB |
| 58 | cap. Downscale before reading (macOS `sips`): |
| 59 | |
| 60 | ``` |
| 61 | sips -Z 1100 -s formatOptions 70 /tmp/att --out /tmp/att-small.jpg # then Read that |
| 62 | ``` |
| 63 | |
| 64 | note that we can't use portless for this app because of a websocets bug with http/2 |
| 65 | |
| 66 | ## Remote runners: where this stands |
| 67 | |
| 68 | anvil does **not** execute CI. Runners dial out, claim jobs, and run them on |
| 69 | their own Docker daemon. Design and rationale: `docs/remote-runners.md`. |
| 70 | |
| 71 | M1 is done: `anvil-job` (wire format), `anvil-docker` (connect/ensure_image), |
| 72 | `anvil-worker` (the runner binary), five endpoints under `/-/runner/`, |
| 73 | in-memory leases, and no Docker socket on the deployed container. |
| 74 | |
| 75 | M2 is done: `platform:` in `.anvil/ci.yml`, a `[ci] platform` default, and |
| 76 | two-tier routing — a claiming runner is offered the runs that match its |
| 77 | architecture (or name none), then the runs no *connected* runner is native to, |
| 78 | so a lone arm64 Mac still runs `linux/amd64` pipelines under Rosetta instead of |
| 79 | stranding them. `Dispatch` tracks who is connected from claims and heartbeats |
| 80 | (`RUNNER_TTL`, 5 min); `anvil-docker::check_platform` fails a job whose image |
| 81 | came back the wrong architecture. |
| 82 | |
| 83 | **Invariants to not break.** These are the reasons the split is shaped the way |
| 84 | it is, and each is easy to undo by accident: |
| 85 | |
| 86 | - The runner never parses `.anvil/ci.yml`. Image resolution, the allowlist |
| 87 | check and script assembly happen in `build_job`, so a runner cannot widen |
| 88 | what it is permitted to run. |
| 89 | - `store_artifact` stays server-side. The runner uploads a raw tar; `browse` |
| 90 | decides whether that becomes a servable directory tree, which is anvil's |
| 91 | call, not a runner's. |
| 92 | - A job's secrets live on its lease, not the vault, once dispatched. |
| 93 | `Vault::take` fails after the repo's unlock TTL lapses, so re-reading at |
| 94 | finish time would silently skip log masking on exactly the long runs whose |
| 95 | logs most need it. |
| 96 | - `anvil-job` depends on serde and nothing else. It exists so the runner does |
| 97 | not link toasty, SQLite and gix. |
| 98 | |
| 99 | **Next, in order:** |
| 100 | |
| 101 | 1. **M3, the deploy agent.** Image builds cannot be CI jobs, because job |
| 102 | containers get no Docker socket by design. `docker build --platform |
| 103 | linux/amd64` plus `docker push` belongs to a separate process on the build |
| 104 | host at a different trust level, triggered by the existing `deploy_webhook`. |
| 105 | Folding it into the dial-out channel as a privileged "publish" job kind, |
| 106 | authorized by the `is_deploy_target` check that already scopes CD to one |
| 107 | repo, would remove the last inbound path to the build host. |
| 108 | 2. **CI concurrency.** "One job at a time" was a property of the old in-process |
| 109 | loop and is gone. Nothing bounds in-flight jobs now beyond how many runners |
| 110 | exist. Needs a `max_concurrent` equivalent — more pressing now that platform |
| 111 | routing makes a second runner worth having. |
| 112 | 3. **Per-runner credentials.** One shared `[ci] runner_token` means one |
| 113 | revocation for every runner, no `last_used_at`, and any holder can claim any |
| 114 | job and receive its secrets. Wants the API-token write scope first (see |
| 115 | TODO.md). |
| 116 | 4. **Live logs.** The result POST is a single write, matching the old |
| 117 | behaviour. Streaming needs chunked append with offsets and a UI that |
| 118 | tolerates gaps. Newly worth doing now that a producer exists. |
| 119 | |
| 120 | **Operationally:** nothing runs until a runner is started and `[ci] |
| 121 | runner_token` is set; queued runs just sit, with a warning logged at startup. |
| 122 | Setting one up on the Mac mini (build, Rosetta, the launchd agent in |
| 123 | `deploy/worker/`) is written out in `docs/remote-runners.md` § Isolation on |
| 124 | macOS. Locally, `compose.override.yaml` runs two of them in containers |
| 125 | (`./deploy/build.sh --debug --worker`, then `docker compose up -d --build`) so |
| 126 | concurrency and routing are testable on one machine; that mount is a |
| 127 | development-only concession, never production's. Who is connected, and what |
| 128 | each is running, is at `/-/admin/runners` — presence comes from ordinary claims |
| 129 | and heartbeats rather than a ping of its own, so anything watching for a runner |
| 130 | to go quiet has to allow a full claim poll first. |
| 131 | Agent sessions still drive Docker locally and are the one thing the dropped |
| 132 | socket mount gives up. They are off by default. |
| 133 | |
| 134 | Current status, resume notes, the agreed next steps (a/b/c), and the roadmap live |
| 135 | in the TODO. Read it first: |
| 136 |