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.
Add phased implementation roadmap
17311880abe22e0b4e1ae7cca51fdb5ccfd8b73b · Keenan Nemetz · 18d ago · parent e4a9dc50
Add phased implementation roadmap Commits to single-tenant self-hosted scope with no federation, which removes the peer replication protocol and multi-tenant storage design from the plan. Covers Phase 0 spikes through Phase 8 operations, with measurable exit criteria and the metadata write-path risk called out.
docs/roadmap.md +234
| @@ -0,0 +1,234 @@ | |||
| 1 | # Vole — Implementation Roadmap | ||
| 2 | |||
| 3 | Phased plan for building the forge described in [the README](../README.md). | ||
| 4 | |||
| 5 | ## Scope | ||
| 6 | |||
| 7 | **Single-tenant, self-hosted.** One Vole instance serves one team on one box. There is no | ||
| 8 | multi-tenancy, no hosted SaaS posture, and no organization/team hierarchy beyond a flat | ||
| 9 | list of users and repos. | ||
| 10 | |||
| 11 | **No federation.** Instances do not talk to each other. Read the README's "replicated | ||
| 12 | issues" bullet literally instead: because issues, change requests, and ACLs live in git | ||
| 13 | objects on sidecar refs, **every clone of a repo carries the full forge state**. Replication | ||
| 14 | is `git fetch`, not a protocol. State survives deleting the instance entirely. | ||
| 15 | |||
| 16 | These two constraints are what keep the project small enough to finish. They are assumed | ||
| 17 | throughout; see [Non-goals](#non-goals) for what they rule out. | ||
| 18 | |||
| 19 | ## Architectural decisions | ||
| 20 | |||
| 21 | | Decision | Choice | Why | | ||
| 22 | |---|---|---| | ||
| 23 | | State model | Git is authoritative; SQLite is a derived read model | Rebuildable index means storage is a swappable detail | | ||
| 24 | | Git integration | Shell out to the `git` binary | Hooks, CAS ref updates, and full `receive-pack` semantics for free; go-git lacks all three | | ||
| 25 | | Transport | Smart HTTP via `git http-backend` | Battle-tested; SSH transport deferred | | ||
| 26 | | Metadata storage | Sharded sidecar refs (`refs/vole/issues/<shard>`, `refs/vole/crs`, `refs/vole/acl`) | Parallel writers, no single-ref contention, still fetchable as one glob | | ||
| 27 | | Metadata writes | Compare-and-swap ref updates, retry on loss | No lock server, no lost updates | | ||
| 28 | | ACL enforcement | `pre-receive` hook, mirrored in web handlers | Web-only gating makes `git push` the bypass | | ||
| 29 | | Binary layout | One static CGO-free binary, three modes (`serve`, `runner`, `hook`) | Drop-anywhere deployment | | ||
| 30 | | Index store | SQLite via `modernc.org/sqlite` | CGO-free; rebuildable, so swapping engines later is cheap | | ||
| 31 | | Config | TOML on disk, no database-backed settings | One box, one file, greppable | | ||
| 32 | |||
| 33 | ## Non-goals | ||
| 34 | |||
| 35 | Explicitly **not** built. Each is additive later; none is load-bearing for the concept. | ||
| 36 | |||
| 37 | - Multi-tenant isolation, orgs, nested teams, per-tenant resource quotas | ||
| 38 | - Federation, peer replication, cross-instance search, global entity IDs | ||
| 39 | - SSH transport (HTTP + PATs first; SSH keys are Phase 7 work if demanded) | ||
| 40 | - Container-isolated CI steps (shell steps only in v1) | ||
| 41 | - Horizontal scaling, HA, multi-writer Postgres | ||
| 42 | - Kubernetes deployment story | ||
| 43 | |||
| 44 | ## Phases | ||
| 45 | |||
| 46 | Each phase ends with something runnable and a measurement that proves it, not a checklist | ||
| 47 | of merged PRs. | ||
| 48 | |||
| 49 | ### Phase 0 — Spikes and decisions (~1 week) | ||
| 50 | |||
| 51 | Throwaway code. Nothing merges. The point is to kill bad assumptions while they're cheap. | ||
| 52 | |||
| 53 | **Spike A — metadata write path.** Drive `git update-ref --stdin` from N concurrent Go | ||
| 54 | writers committing to a metadata tree. Measure commits/sec and CAS-retry rate with (a) a | ||
| 55 | single unsharded ref and (b) 256 hash-sharded refs. Also measure batched commits (coalescing | ||
| 56 | N entity writes into one commit) against per-entity commits. | ||
| 57 | |||
| 58 | **Spike B — smart HTTP through Go.** Proxy `git http-backend` as a subprocess, set the CGI | ||
| 59 | env correctly, run a 1 GB push, a `pre-receive` rejection, and a client abort mid-transfer. | ||
| 60 | Confirm hook stderr reaches the client and no orphaned `receive-pack` processes survive. | ||
| 61 | |||
| 62 | **Spike C — serving files from git trees.** Render a 10k-file tree as static pages with a | ||
| 63 | read-through cache. Record p50/p99 and memory for cold and warm cache. | ||
| 64 | |||
| 65 | **Exit criteria:** commits/sec at the 95th percentile under 32 writers with retry rate | ||
| 66 | recorded; a 1 GB push succeeds and a rejected push reports a real reason to the client; | ||
| 67 | p99 for a warm static file served from a 10k-file tree. Decisions written into the | ||
| 68 | [decision log](#decision-log). | ||
| 69 | |||
| 70 | ### Phase 1 — It's a git server | ||
| 71 | |||
| 72 | The unglamorous foundation. If this phase isn't solid, nothing above it is trustworthy. | ||
| 73 | |||
| 74 | - Bare-repo store on disk with a stable repo identity; create/rename/delete via CLI and web | ||
| 75 | - Smart HTTP read and write, auth via HTTP basic + personal access tokens | ||
| 76 | - Users, sessions, PATs (hashed at rest, scoped, revocable) | ||
| 77 | - ACL v1: owner / write / read, stored on `refs/vole/acl` | ||
| 78 | - `pre-receive` + `update` hooks enforcing ACLs, protected branches, and object size limits | ||
| 79 | - Web UI: repo list, tree/blob browser, raw view, commit log | ||
| 80 | - Instance signing key generated at first boot and persisted (used from Phase 7) | ||
| 81 | - `vole index rebuild` implemented early and exercised by tests, even though the index has | ||
| 82 | little to hold yet | ||
| 83 | |||
| 84 | **Exit criteria:** authenticated clone/push/fetch works against a fresh instance; an | ||
| 85 | unauthorized push is rejected by the hook with an actionable message; `vole index rebuild` | ||
| 86 | reproduces state after deleting the index file; a rejected push leaves no partial ref update. | ||
| 87 | |||
| 88 | ### Phase 2 — Metadata layer and issues | ||
| 89 | |||
| 90 | The phase where the core bet is either proven or abandoned. | ||
| 91 | |||
| 92 | - Metadata tree schema: file-per-entity, sharded directories, stable IDs generated as | ||
| 93 | monotonic counters committed alongside the entities | ||
| 94 | - CAS commit helper shared by all metadata writers, with retry and a bounded write queue | ||
| 95 | - Derived index with incremental update on ref change and full rebuild; every query path | ||
| 96 | works from a cold index | ||
| 97 | - Issues: create, comment, edit, close/reopen, labels, assignees, cross-references | ||
| 98 | (`#12`), full-text search over the index | ||
| 99 | - Markdown rendering with HTML sanitization | ||
| 100 | - Notifications: in-app first, email via SMTP config second | ||
| 101 | |||
| 102 | **Exit criteria:** deleting the SQLite file and running `vole index rebuild` reproduces | ||
| 103 | every issue byte-for-byte (diffed, not eyeballed); 32 concurrent commenters lose zero | ||
| 104 | comments; `git fetch 'refs/vole/*:refs/vole/*'` on a fresh clone yields a browsable issue | ||
| 105 | history with `git log`. | ||
| 106 | |||
| 107 | ### Phase 3 — Change requests | ||
| 108 | |||
| 109 | - CR entity on `refs/vole/crs` referencing base branch + head branch + head SHA | ||
| 110 | - Diff view (three-dot against merge base), file tree, per-file collapse for large diffs | ||
| 111 | - Line-anchored review comments that survive force-pushes via `(path, blob-sha, line)` anchoring | ||
| 112 | - Review states: comment / approve / request changes | ||
| 113 | - Merge as a CAS update of the base ref from the reviewed head SHA; reject stale merges | ||
| 114 | rather than clobbering | ||
| 115 | - Protected branches: required approvals, required status checks (wired in Phase 4) | ||
| 116 | - Conflict detection surfaced before merge is attempted | ||
| 117 | |||
| 118 | **Exit criteria:** a full CR flow on a real repo with review comments that stay attached | ||
| 119 | across a force-push; a merge raced by a concurrent push is rejected, not applied; a | ||
| 120 | protected branch cannot be pushed by a non-owner even with a valid token. | ||
| 121 | |||
| 122 | ### Phase 4 — CI/CD | ||
| 123 | |||
| 124 | - `.vole/workflows/*.yaml` parsed and validated at push time; invalid workflows fail loudly | ||
| 125 | on the CR rather than silently doing nothing | ||
| 126 | - Push-event queue persisted in SQLite, with at-least-once delivery | ||
| 127 | - Runner protocol: `vole runner` registers, long-polls for work, leases a job, heartbeats; | ||
| 128 | an expired lease requeues the job | ||
| 129 | - Job execution: checkout by exact SHA from the server, sequential shell steps, per-step | ||
| 130 | timeout, streaming logs | ||
| 131 | - Run records pin SHA + workflow file hash + toolchain env; signed with the instance key | ||
| 132 | - Statuses surfaced on CRs and satisfying required-check gating from Phase 3 | ||
| 133 | - Log and artifact storage onto the content-addressed store (shared with Phase 5) | ||
| 134 | |||
| 135 | **Exit criteria:** a push runs a pipeline end-to-end; killing a runner mid-job results in | ||
| 136 | the job being requeued and completing on another runner; a run record can be verified | ||
| 137 | against the SHA it claims; a workflow that mutates its own definition mid-run still executes | ||
| 138 | the version it was queued with. | ||
| 139 | |||
| 140 | ### Phase 5 — LFS | ||
| 141 | |||
| 142 | - `POST /objects/batch` implementing `application/vnd.git-lfs+json`, upload and download | ||
| 143 | - Content-addressed object store with size limits and reference counting | ||
| 144 | - Server-side pointer hygiene: warn on committed binaries above a threshold, provide a | ||
| 145 | suggested `git lfs track` command in the rejection message | ||
| 146 | - `pre-receive` rejects LFS pointers whose objects are missing, before the ref moves | ||
| 147 | - Migrate CI artifacts onto the same store with the same GC story | ||
| 148 | |||
| 149 | **Exit criteria:** clone of an LFS repo pulls real objects through the proxy; a pointer | ||
| 150 | pushed without its object is rejected at hook time; GC never collects an object reachable | ||
| 151 | from a live ref. | ||
| 152 | |||
| 153 | ### Phase 6 — Site hosting | ||
| 154 | |||
| 155 | - A designated pages ref per repo, served as static files directly from the git tree | ||
| 156 | - Per-repo hostname (`<repo>.<instance-domain>`) plus optional custom domain | ||
| 157 | - Automatic TLS via ACME when a domain is configured, with DNS-01 for wildcards | ||
| 158 | - ETag/`Last-Modified` from blob SHAs; correct `Content-Type` from extension and content sniff | ||
| 159 | - Cache invalidation on push to the pages ref, without a full rebuild | ||
| 160 | |||
| 161 | **Exit criteria:** a pushed doc tree is live in under 5 seconds; conditional requests return | ||
| 162 | `304`; a custom domain gets a certificate with no manual steps; serving a repo with a | ||
| 163 | path-traversal-shaped filename is safe. | ||
| 164 | |||
| 165 | ### Phase 7 — Signing and audit | ||
| 166 | |||
| 167 | Scope is provenance and tamper-evidence for a single instance — no peer trust, no federation. | ||
| 168 | |||
| 169 | - User ed25519 key upload; commit signature verification with a visible verified/unverified badge | ||
| 170 | - All metadata commits signed with the instance key; verification of instance-signed | ||
| 171 | metadata on read so a hand-edited ref is flagged | ||
| 172 | - Append-only instance audit log (auth events, key changes, ACL changes, repo deletion) | ||
| 173 | as a hash chain in a dedicated ref, signed | ||
| 174 | - `git verify-commit` interop: signatures produced must verify with stock git tooling | ||
| 175 | |||
| 176 | **Exit criteria:** a commit signed by a registered key shows verified; a metadata commit | ||
| 177 | altered out-of-band is flagged on read; the audit chain detects a deleted middle entry; | ||
| 178 | signatures verify with `git verify-commit` outside Vole. | ||
| 179 | |||
| 180 | ### Phase 8 — Operate it | ||
| 181 | |||
| 182 | - Metrics and traces (job queue depth, CAS retry rate, push latency, index lag) | ||
| 183 | - Backup and restore: documented artifact set, plus a restore drill in CI | ||
| 184 | - Quotas: per-repo size, object count, LFS bytes | ||
| 185 | - Metadata GC that never breaks history: entities are tombstoned, not dropped | ||
| 186 | - Upgrade path with an explicit schema-version step and a downgrade refusal that explains itself | ||
| 187 | - Multi-arch container images and a `vole init` flow that produces a working instance in | ||
| 188 | one command | ||
| 189 | - Operator docs: TLS, SMTP, runner registration, backup cadence | ||
| 190 | |||
| 191 | **Exit criteria:** restore-from-backup drill passes in CI; upgrade N→N+1 succeeds on a repo | ||
| 192 | with live LFS objects and completes with no downtime; `vole init` produces a working | ||
| 193 | instance with one command on a clean box. | ||
| 194 | |||
| 195 | ## Risks | ||
| 196 | |||
| 197 | **Metadata writes are ref CAS on a shared ref.** The whole state model rides on | ||
| 198 | `git update-ref` throughput, which is roughly hundreds of writes/sec per ref before retry | ||
| 199 | storms. Mitigations, in order of preference: shard refs (Spike A measures the gain), | ||
| 200 | coalesce writes into batched commits, and only then consider a write-ahead log in front of | ||
| 201 | the ref. This is settled in Phase 0, *before* the schema is built around per-entity commits. | ||
| 202 | |||
| 203 | **Derived index can silently drift from refs.** Any path that writes refs without updating | ||
| 204 | the index is a bug that shows up as missing data. Mitigation: the index is never written | ||
| 205 | except through one function that is given the ref transaction it is updating; a scheduled | ||
| 206 | `rebuild` diff against incremental state runs in CI. | ||
| 207 | |||
| 208 | **Hook-enforced policy can be bypassed by server-side operations.** Merges and other | ||
| 209 | server-initiated ref updates must route through the same validation as `pre-receive`, or | ||
| 210 | the ACL model has two implementations that will disagree. Mitigation: one policy function, | ||
| 211 | called from both, with a shared test fixture table. | ||
| 212 | |||
| 213 | **Diffs and file serving get expensive at scale before anything else does.** Static serving | ||
| 214 | straight from a tree is elegant and fast until it isn't. Spike C exists to find the cliff | ||
| 215 | before Phase 6 discovers it. | ||
| 216 | |||
| 217 | ## Decision log | ||
| 218 | |||
| 219 | Filled in as phases land. Append; don't rewrite. | ||
| 220 | |||
| 221 | | Date | Decision | Rationale | | ||
| 222 | |---|---|---| | ||
| 223 | | — | Single-tenant, no federation | Keeps scope finite; removes global IDs, peer protocol, and multi-writer storage | | ||
| 224 | | — | Git authoritative, SQLite derived | Rebuildability makes the storage choice reversible | | ||
| 225 | |||
| 226 | ## Open questions | ||
| 227 | |||
| 228 | - Workflow YAML dialect: own minimal schema, or a subset of GitHub Actions for familiarity? | ||
| 229 | Leaning own schema — a subset that silently differs from the real thing is worse than a | ||
| 230 | clearly separate one. | ||
| 231 | - Whether Phase 1 ships SSH transport early because operators expect it, despite HTTP | ||
| 232 | being sufficient. Defer until someone actually complains. | ||
| 233 | - Email deliverability: rely on operator SMTP, or bundle a fallback? Leaning operator SMTP | ||
| 234 | only, with a loud warning when unconfigured. | ||