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