aboutsummaryrefslogtreecommitdiff
path: root/README
blob: 6d037a546d7cc2b540beb9b7d259ad79d269dc9d (plain) (blame)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
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.