Sign in

Search issue titles and bodies, change requests and comments. Private repositories you cannot read are not included.

vole
Clone

Read only, no credentials:

https://rodent.software/git/vole.git

Write over SSH with a registered key:

git clone ssh://git@0.0.0.0:2222/vole.git

A free and open-source forge that lives in your Git repo.

Blame · docs/architecture.md

Line attribution on main · 180 line(s)

520470e1
Keenan Nemetz
16d ago
1 # Architecture and design
2
3 This page is for people who want to understand how Vole works and why it makes
4 the choices it does. If you only want to use Vole, the guides are enough.
5
6 The goal is not novelty. The goal is a forge that stays understandable, works on
7 hardware you control, and keeps the important parts in Git.
8
9 Vole is built for one team on one server. It does not try to be a hosted service,
10 and it does not connect to other Vole instances. That keeps the design small.
11
6c84d387
Keenan Nemetz
15d ago
12 ## Trust: a credential resolves to an account
520470e1
Keenan Nemetz
16d ago
13
14 Vole uses one rule everywhere:
15
6c84d387
Keenan Nemetz
15d ago
16 > A credential proves which account you are. Vole checks every change before
17 > accepting it, and records the decision in `data/audit.log`.
520470e1
Keenan Nemetz
16d ago
18
62576fa8
Keenan Nemetz
14d ago
19 There are two credentials, and they resolve to the same account: the **name**
20 fixed at creation. Contributor branches and metadata authors are written with it,
21 and access rules, approval files, locks and CI records are keyed by it.
6c84d387
Keenan Nemetz
15d ago
22
23 - an **SSH key** for Git over SSH, administration, and CI;
24 - a **passkey** for browser sessions.
25
26 There is no password database. The browser performs a WebAuthn ceremony, Vole
27 stores only the credential's public half, and the resulting session is an opaque
28 token whose hash alone is in `users.db`.
29
7e0b5950
Keenan Nemetz
6d ago
30 There are two ways to write metadata, and they offer the same account the same
31 actions: a browser session, where each form asks for one capability, and a push,
32 where pre-receive asks the same capabilities path by path. Both are built on the
33 same metadata writers, so the file format and its validation do not fork. An
34 untrusted contributor's Git writes are confined to `refs/heads/contrib/<name>/`,
35 and the `vole` branch it may also write is checked the same way the browser's
36 forms are; CI for a contributor branch does not run until a maintainer allows the
37 exact tip. A change request opened from a
e050e14a
Keenan Nemetz
13d ago
38 branch follows it: after an accepted push, the server records the new tip as the
39 request's head, so approvals of the old tip stop counting. Everything else still
40 changes by pushing over SSH. Approvals, comments, and CI results belong to the
41 account the credential resolved to, never to a name typed into a form.
520470e1
Keenan Nemetz
16d ago
42
43 Vole does not require or check Git commit signatures. A signature would only
6c84d387
Keenan Nemetz
15d ago
44 repeat what the credential already established, and it cannot reliably prove who
45 authored a commit. Vole would rather keep one clear path than add a second one
46 that can disagree with the first.
520470e1
Keenan Nemetz
16d ago
47
48 ## What that means
49
50 On the server, Vole can show a complete history of who pushed what. A clone of a
51 repository, by itself, cannot prove who pushed a commit. The instance promises a
52 complete record; it does not promise offline proof.
53
54 That trade-off is wrong if you plan to mirror the repository somewhere controlled
55 by someone else. For a team running its own forge, it keeps the system simple and
56 auditable.
57
58 ## Where data lives
59
60 | Data | Where it lives | Rebuildable? |
61 |---|---|---|
62 | Repositories | `data/repos/<name>.git` | It is the source |
b312e166
Keenan Nemetz
10d ago
63 | Issues, requests, approvals, release pointers | The `vole` branch in each repository | It is the source |
64 | CI records: finished attempts, steps, artifact pointers, and who reported them | `data/runs.db` | It is the source; back it up |
4506a4d3
Keenan Nemetz
13d ago
65 | Accounts, instance roles, SSH keys, passkeys, enrollment links, sessions, access rules, locks, page-view counters | `data/users.db` | No; back it up |
2a8f7cef
Keenan Nemetz
9d ago
66 | Web/search index: issues, change requests, comments, state changes, activity events, default-branch summaries (head, languages, commits, authors), tags, release artifacts, and the `#42` alias numbering | `data/index.db` | Yes, with `vole admin index rebuild` |
b312e166
Keenan Nemetz
10d ago
67 | CI queue, in-progress runs, contributor CI approvals | `data/runs.db` | Partly; in-progress work and approvals are lost |
5ece70a5
Keenan Nemetz
10d ago
68 | CI logs | `data/logs/` | No; one file per attempt, back it up |
69 | LFS files, build artifacts | `data/lfs/` | No; stored by content |
b1499c72
Keenan Nemetz
12d ago
70 | Privileged-action history | `data/audit.log` | It is the record; a privileged change that cannot be written there is refused |
0c40a1d3
Keenan Nemetz
7d ago
71 | Repository secrets | `data/secrets.db`, sealed under the key in `data/secrets.key` | No; back up both, and keep the key apart from the database |
520470e1
Keenan Nemetz
16d ago
72 | SSH host key | `data/ssh_host_ed25519_key` | No; it is the secret |
73
b312e166
Keenan Nemetz
10d ago
74 Git holds the repository history. SQLite holds the account database, the CI queue
75 and records, and generated indexes. Anything that can be rebuilt comes from Git or
76 stored files. Anything that cannot be rebuilt is small and easy to back up.
520470e1
Keenan Nemetz
16d ago
77
dced06da
Keenan Nemetz
9d ago
78 The web index is derived from each repository's `vole` branch. Post-receive applies
79 a push's delta as the push lands, so a page does not pay for the work the push
80 already did. Every read still checks the branch it was built from — reading the
81 loose ref, which is a file, rather than starting a process — and repairs the rows
82 if a hook did not run. `vole admin index rebuild` is the last resort.
83
122cbe4f
Keenan Nemetz
7d ago
84 Writers of that branch take turns. A write reads the branch, decides, and moves the
85 ref only if nobody else moved it first, so two people commenting at the same time
86 cannot lose either comment; the price of that safety is that a writer which loses
87 the race reads the branch again. Under load that price is the whole cost, so writers
88 of one repository queue behind each other instead: the read, the decision, and the
89 ref move happen while one writer holds the branch, and the compare-and-swap is left
90 for a push or a hand-run git command that could not wait.
91
92 A push only queues behind those writers when it has metadata to move. Most do not:
93 a branch becomes interesting to a change request when one is opened from it, and
94 post-receive asks the index which open requests follow the branch before it reads
95 the metadata branch at all. A push to a branch nobody is proposing from therefore
96 writes an audit line and nothing else.
97
520470e1
Keenan Nemetz
16d ago
98 The `vole` branch contains:
99
100 ```text
101 issues/<ulid>/issue.md
102 issues/<ulid>/comments/<ulid>.md
b492b557
Keenan Nemetz
3d ago
103 changes/<ulid>/change.md
104 changes/<ulid>/comments/<ulid>.md
105 changes/<ulid>/approvals/<account-name>.md
520470e1
Keenan Nemetz
16d ago
106 releases/<tag>/<artifact>
107 ```
108
b312e166
Keenan Nemetz
10d ago
109 People write the issue and request files. Vole moves the head of a change request
110 that follows a branch: post-receive derives the new head from the accepted ref and
111 validates the request tree before committing it. A tagged build that publishes
112 release artifacts writes those pointers the same way. Everything that is pushed is
113 checked at the boundary.
114
59badc63
Keenan Nemetz
10d ago
115 Run records are not in Git. A runner sends its result over the authenticated SSH
116 channel that leased the job, and the server stores it in `data/runs.db`; a clone or
117 backup of a repository does not carry CI history.
520470e1
Keenan Nemetz
16d ago
118
b7396a11
Keenan Nemetz
2h ago
119 ## URL layout
120
121 A repository owns the root of the URL space. `/<name>` is its summary page,
122 `/<name>/issues` and the rest are its tabs, and `/<name>.git` is the Git
123 transport a clone uses. There is no owner segment: repository names are unique
124 on the instance, so one name is enough.
125
126 Every page the forge itself serves is under `/-/`: `/-/admin`, `/-/u/<account>`,
127 `/-/activity`, `/-/login`, and so on. A repository name can never begin with `-`,
128 so the two spaces cannot collide and no route has to know the repository names.
129 The four names the forge keeps at the root are the ones a URL cannot move:
130 `static` for assets, and `robots.txt`, `sitemap.xml`, `sitemap-pages.xml` because
131 crawlers only look there. Repository creation refuses those names.
132
133 `/<name>.git` is recognized before the router, because a wildcard in a route
134 fills a whole path segment and cannot match `<name>.git`. Keeping the suffix on
135 the transport is what lets the page (`/<name>`, which is also the Go module path)
136 and the clone URL share a name.
137
520470e1
Keenan Nemetz
16d ago
138 ## Why these choices
139
140 | Choice | Reason |
141 |---|---|
142 | Use the Git command-line tool | Full push behavior and hooks without reimplementing Git |
474a812d
Keenan Nemetz
15d ago
143 | SSH for Git writes, passkeys for the browser | Each transport gets the credential it can actually use, and both resolve to one account |
6c84d387
Keenan Nemetz
15d ago
144 | Check everything before accepting a change | The push boundary and the narrow web writers share the same metadata validation |
ab326d3a
Keenan Nemetz
14d ago
145 | Store metadata in Git, rules in SQLite | Repository content travels with clones; local settings stay local |
146 | Passkeys, not passwords | No shared secret to phish, reset, or store; recovery is an instance-admin-issued enrollment link |
520470e1
Keenan Nemetz
16d ago
147 | One program with separate serve and runner modes | Easy to deploy anywhere |
0c40a1d3
Keenan Nemetz
7d ago
148 | Two long-term secrets: the host key, and the key that seals repository secrets | A deploy key has to live somewhere, and a value in Git is a value in every clone |
520470e1
Keenan Nemetz
16d ago
149
150 ## Using Vole as a library
151
152 Vole is also a Go library. You can build tools that talk to an instance or embed
153 parts of Vole without running the `vole` command.
154
155 | Package | What it does |
156 |---|---|
157 | `client` | Talks to an instance: metadata writes, identity lookups, file transfer, reviews, and site deploys |
158 | `core/id`, `core/git`, `core/lfs` | Shared primitives and storage |
159 | `forge` | Instance setup and administration |
0c40a1d3
Keenan Nemetz
7d ago
160 | `forge/config`, `forge/identity`, `forge/access`, `forge/repo`, `forge/index`, `forge/session`, `forge/secret` | Settings, accounts, permissions, repositories, search, browser sessions, and each repository's sealed secrets |
520470e1
Keenan Nemetz
16d ago
161 | `metadata` | Issues, requests, releases, comments, approvals, and validation |
3b39a84c
Keenan Nemetz
14d ago
162 | `ci` | Workflow validation and the job queue |
163 | `ci/agent` | The embeddable runner: leasing, execution, and reporting |
164 | `ci/container` | The container a job runs in, shared by the runner and `vole workflow run` |
165 | `ci/cache` | The per-repository job cache: namespaces, environment, and eviction |
520470e1
Keenan Nemetz
16d ago
166 | `server` | Network listeners and web pages, with adapters for SSH, Git HTTP, sites, and hooks |
167
168 `cmd/vole` is only the command-line front end. Logic shared by more than one
169 caller lives in the packages above.
170
171 ## Scope
172
173 Vole is built for one team on one server. It does not support multiple tenants,
7da9111e
Keenan Nemetz
15d ago
174 federation, custom domains with automatic certificates, horizontal scaling, or a
175 Kubernetes deployment story. These are not part of Vole today.
176
70b70264
Keenan Nemetz
15d ago
177 CI isolation is a runner responsibility: every job names its OCI image in the
178 workflow, or references a definition under `.vole/images`, and the runner executes
179 it in a container. The runner pulls or builds the image and owns the container
180 runtime; the server only pins the image and definition and records what ran.