aboutsummaryrefslogtreecommitdiff
path: root/README
diff options
context:
space:
mode:
authorLena <lena@omega>2026-08-01 00:00:00 +0000
committerLena <lena@omega>2026-08-01 00:00:00 +0000
commit3284b1c4906b90ca2e3864937c66d0fa30772ebf (patch)
treead799407ac7b0a2589a344751f4c5b3cf575d5c5 /README
downloadgit-forge-cloudflare-master.tar.gz
Enter git-forge-cloudflareHEADmaster
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--README322
1 files changed, 322 insertions, 0 deletions
diff --git a/README b/README
new file mode 100644
index 0000000..6d037a5
--- /dev/null
+++ b/README
@@ -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.