diff options
| author | Lena <lena@omega> | 2026-08-01 00:00:00 +0000 |
|---|---|---|
| committer | Lena <lena@omega> | 2026-08-01 00:00:00 +0000 |
| commit | 3284b1c4906b90ca2e3864937c66d0fa30772ebf (patch) | |
| tree | ad799407ac7b0a2589a344751f4c5b3cf575d5c5 /README | |
| download | git-forge-cloudflare-3284b1c4906b90ca2e3864937c66d0fa30772ebf.tar.gz | |
Runs git-forge on Cloudflare Containers with R2 for durable storage. A
Worker routes every request to one container through a Durable Object.
Inside it, syncd mirrors the data directory to the bucket and restores
it at boot, holding a write until it is durable.
git-forge is neither modified nor vendored: the image builds it from
its own repository at the commit named in forge.rev.
Diffstat (limited to 'README')
| -rw-r--r-- | README | 322 |
1 files changed, 322 insertions, 0 deletions
@@ -0,0 +1,322 @@ +git-forge-cloudflare +==================== +Runs git-forge on Cloudflare Containers with R2 for durable storage. A Worker +routes every request to a single container through one Durable Object. Inside +the container, syncd mirrors the data directory to an R2 bucket and restores +it at boot. + +git-forge is neither modified nor vendored here. The image builds it from its +own repository, at the commit named in `forge.rev`. + +Container disk is erased whenever the instance sleeps or its host is +recycled, and git-forge needs a real filesystem and one running process. +Cloudflare supplies the second: a Durable Object name resolves to one instance +globally. syncd supplies the first: the container keeps its filesystem on +ephemeral disk, R2 holds the durable copy, and git-forge is unaware of either. + + +Architecture +------------ + Worker -> Durable Object "forge" -> Container + :8080 git-forge, unmodified + :8081 syncd, in this repo + | + Outbound -> R2, logs, mail + + worker.js routes requests and answers the intercepted hosts + syncd mirrors and restores the datadir, makes writes durable + Containerfile git-forge, syncd, git, cgit, highlight, tini + wrangler.jsonc the bindings + +The Worker intercepts three hostnames, none of which resolve in DNS: + + r2.forge.internal objects, served from the R2 binding + logs.forge.internal stdout, logged where `wrangler tail` can see it + mail.forge.internal Mailgun's api shape, answered by whatever sends + +syncd carries no R2 credential: it sends plain HTTP to a hostname the Worker +intercepts on the same machine. Anything in the container that can make a +request has the whole bucket, cgit included; a binding has no narrower form. +Logs travel the same path because Cloudflare does not forward container +stdout to Workers observability. + + +Storage +------- + manifest.json which repositories exist. Authoritative + stale.json directories the manifest stopped listing, + and when + config/... the config directory, file by file + config/git/objects/... its audit log + config/REFS its refs + repos/{path}.git/objects/... immutable, uploaded once, never rewritten + repos/{path}.git/REFS `git show-ref` output, one PUT + +Git objects are content addressed and append only, so only refs change, and +syncd stores them as state rather than files: `gc --auto` moves loose refs +into `packed-refs`, and a restored loose ref would override it and resurrect +an old head. Objects go up first and refs last, the order git pushes in, so a +crash within a flush leaves the state from before the write. + +The manifest is what makes deletion work: restore creates only the +directories it names, so rewriting it deletes. The objects of a repository the +manifest no longer lists stay in the bucket for seven days, reported at every +boot and once a day while the container runs, and are removed by the first +check after that. The delay is the window to put back a manifest restored +from a backup or written short: a boot that believed one would otherwise +remove every repository it omits. + +A bucket holding objects under `repos/` and no manifest is fatal rather than +empty. A forge that never made a repository writes no manifest and starts +normally. + +Boot restores `config/` in full, then creates one empty directory per +repository in the manifest, which is all git-forge's startup scan looks at. +syncd hydrates a repository on its first request: `git init --bare`, copy the +objects, replay the refs. The issue and wiki siblings hydrate with it. A +repository that should have hydrated and did not fails loudly: git refuses an +empty directory, and git-forge does not treat that as an empty repository. + + +Durability +---------- +syncd puts a write in R2 before reporting success. It forwards the request to +git-forge and, for anything other than a known read, buffers the response, +flushes, and returns once the flush completes. A mutex serialises flushes and +each one rescans the whole datadir, so every request covers its own writes +whatever URL it had: + + held everything that is not a GET or HEAD, except git-upload-pack + streamed every GET and HEAD, except /confirm-email and /oidc/... + +A held response is capped at 8 MiB and answered 503 past it; the cap is for +cgit, which answers a snapshot to POST as well. + +One write is asynchronous: the date a push token was last used, which +git-forge treats as telemetry. A sweep every 15 seconds carries it up, and +backstops any misclassification above. + +tini is PID 1 and syncd its child, with git-forge as syncd's child, so the +container dies if syncd dies. On SIGTERM syncd signals git-forge, waits for +it to drain, flushes, and exits. + + +Limits +------ +Cloudflare caps a push at 100 MB of new objects at the edge, more on Business +and Enterprise, and nothing here can raise it. Clones are unbounded. git-forge +separately caps a wiki push at 16 MiB. Import an existing repository in +ranges, so that no single pack exceeds the cap: + + git push origin <sha>:refs/heads/master + +Local disk bounds the datadir. Instance type `basic` has 4 GB, and `git gc` +needs room to repack on top of it; `wrangler.jsonc` picks the type, and 20 GB +is the largest. + +Boot costs one round trip per object under `config/`. The sweep packs the +audit log at five hundred loose objects. A flush walks the whole datadir and +forks `git show-ref` per directory, so its cost, and every held write's +latency, grows with the number of repositories. + + +Test +---- + ./test + +Builds the image and exercises it end to end: create the first admin, create +a repository, push, destroy the container, then start a new one with only the +bucket to work from, and again after the audit log is packed. It needs docker, +go, git, curl and openssl on the host, and the git-forge checkout +`fetch-forge` reads. `fake-r2.go` stands in for the object handler in +`worker.js`, so no Cloudflare account is needed. + +`go test` covers syncd against a bucket: a flush racing a hydration, a gc, a +push, and a deletion, and the prune waiting out its grace. + +The rest of `worker.js` has no coverage: it runs only in a deployment. + + +Deploy +------ +Needs a Cloudflare account on Workers Paid with R2 enabled, wrangler, and +docker. + + ./fetch-forge + wrangler r2 bucket create git-forge + wrangler secret put GIT_FORGE_COOKIE_KEY at least 32 characters + wrangler deploy + +`fetch-forge` copies git-forge's source into `forge/` for the image build, +from `../git-forge` at the commit in `forge.rev`, or from `GIT_FORGE_SRC` and +`GIT_FORGE_REV`. `forge/` is derived and uncommitted. Upgrading means editing +`forge.rev`; the pin exists because this repository asserts which of +git-forge's routes write, which one removes a repository, and what a +repository holds besides objects and refs. syncd logs the revision at startup. + +`GIT_FORGE_COOKIE_KEY` must be a secret: the datadir is ephemeral, so a +generated key would sign everybody out on every restart. syncd refuses to +start without one. + +A custom domain is one route, with workers.dev switched off: + + "routes": [{ "pattern": "git.example.com", "custom_domain": true }], + "workers_dev": false, + +Switching workers.dev off matters: base_url is decided by the request that +starts the container, so two hostnames on one worker means whichever wakes it +wins and every form posted to the other returns 403 until it restarts. + +Leave `base_url` unset. worker.js supplies it from the origin of the request +that started the container, always as https. Set it in `wrangler.jsonc` only +when a custom domain fronts a hostname requests do not arrive on, and then +exactly. Put nothing else in `vars`: an environment value pins what the admin +page could otherwise change. worker.js sets `GIT_FORGE_CLIENT_IP_HEADER` to +`CF-Connecting-IP`, which the edge overwrites, so rate limits count real +addresses rather than syncd's. + +Against an empty bucket git-forge prints a one time setup token and serves +`/setup`. The container starts on the first request and prints the token +then, so start `wrangler tail` beforehand. A restart before setup issues a new +token; `wrangler deploy` forces a restart. + +The container stops after ten idle minutes and starts again on the next +request. A Durable Object alarm stops it with SIGTERM once nothing is in +flight; a request arriving mid-drain receives an error. Retry. + + +Mail +---- +git-forge's Mailgun driver issues one form-encoded POST, and `worker.js` +answers that shape, so any sender serves git-forge without changing it and +every key stays outside the container. The binding is declared and sends +nothing until git-forge's config points at the intercepted host; until then +git-forge's default driver drops each message with a log line naming the +recipient and subject. + +Edit the copy of `forge.json` in the bucket, since the web interface does not +expose mail settings, then redeploy: + + wrangler r2 object get git-forge/config/forge.json --remote --pipe >f + "$EDITOR" f + wrangler r2 object put git-forge/config/forge.json --remote --file=f + wrangler deploy + +`forge.json` reaches the bucket after the container has run once. git-forge +requires `domain` to be non-empty though nothing uses it: + + "mail": { + "driver": "mailgun", + "from": "git-forge <forge@example.com>", + "mailgun": { + "domain": "forge", + "base_url": "https://mail.forge.internal/v3" + } + } + +Without a sending domain onboarded to Email Service, a binding reaches only +addresses verified in the account. With no binding declared, `worker.js` logs +each message and reports success. The spool lives on container disk and is +not mirrored. + + +Sign in with a provider +----------------------- +git-forge implements generic OpenID Connect: discovery, authorization code +with PKCE, and a signed id_token carrying a verified address. Google works, +as does anything that publishes a discovery document; GitHub does not, since +its only OIDC issuer mints workload tokens. + +Declare the provider in the bucket's copy of `forge.json`, as with mail: + + "oidc": [{ + "key": "google", + "label": "Google", + "issuer": "https://accounts.google.com", + "client_id": "<id>.apps.googleusercontent.com", + "allow": ["someone@gmail.com", "@example.com"] + }] + +Supply the secret through the environment, never the file: + + wrangler secret put GIT_FORGE_OIDC_GOOGLE_CLIENT_SECRET + +git-forge derives the variable name from the key, upper-cased. Register the +redirect uri `{base_url}/oidc/{key}/callback` with the provider exactly. A +secret added while a container runs takes effect after a restart; both sides +report what they hold, without its value, in `wrangler tail`. + +An account minted this way is named `{key}:{name}` and holds no grants; the +admins receive mail when one first appears. Set `allow`, or every account the +provider vouches for can mint one. + +The container contacts the provider directly. This is the only feature that +depends on `enableInternet` in `worker.js` and on the ca bundle `main.go` +assembles; a forge with no provider can set it false. + + +Operations +---------- +R2 holds everything that has to survive. Two backups: + + every repository, cloned readable without any of this + the bucket, mirrored to another bucket exact, and restores as is + +Cloning omits the account table, so keep `users.json` beside it, and treat it +as credential material in full: + + wrangler r2 object get git-forge/config/users.json --remote --pipe + +R2 speaks the S3 api at `https://<account>.r2.cloudflarestorage.com`, so any +S3 tool mirrors the bucket. + +A repository deleted through the web is reported at every boot, and daily +while the container runs, for seven days: + + stale: notes.git, 412 objects no manifest lists, removed after 2026-09-18 + +To bring it back within that window, put `manifest.json` and `repos.json` +back as they were and redeploy; after it, a clone is the only recovery. + +Reverting any other mistake means editing the bucket and restarting, as with +mail. A restore reads the working tree, not the audit log, so putting the old +content back is the revert: + + wrangler r2 object get git-forge/config/repos.json --remote --pipe >f + "$EDITOR" f + wrangler r2 object put git-forge/config/repos.json --remote --file=f + wrangler deploy + +Do this while the forge is idle: syncd leaves a change made behind its back +alone, but a simultaneous change through the web wins. + +What the log means when something is wrong: + + restore: ... fatal, deliberately: an empty datadir + would be flushed over the real one + the change could not be made a flush failed, so the write is on a disk + durable that does not survive: a 503 on purpose. + The next flush carries it up + Not a git repository: hydration did not fire for a repository + /var/lib/git-forge/repos/... the matcher missed. The objects are still + in the bucket + config: audit log not the bucket is missing an object the log's + replayed refs name. This costs history and nothing + else + no sender bound mail is configured but the binding is not + GIT_FORGE_COOKIE_KEY must be the secret is unset + set + +Memory and disk are billed on the instance type while the container is +awake; a sleeping container costs nothing. + + +Debugging +--------- + wrangler tail logs, both processes + wrangler r2 object get git-forge/manifest.json --remote --pipe + wrangler r2 object get git-forge/repos/foo.git/REFS --remote --pipe + +`--remote` is mandatory: without it wrangler reads a local simulated bucket +and finds nothing. `REFS` is `git show-ref` output. syncd logs one line per +flush that uploaded anything and one per hydration. |