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.

Code

392 commit(s) on main

Merge change request 01M70WES5ZZDJ6RP226WB11BBZ: Let people rename and remove their own passkeys keenan · 1h ago 4b8c9e1d
history blame raw 10.7 KiB
1// White-box tests for the reconciliation that makes the queue follow the refs.
2package server
3
4import (
5 "fmt"
6 "io"
7 "log/slog"
8 "os"
9 "path/filepath"
10 "strings"
11 "testing"
12 "time"
13
14 "rodent.software/vole/ci/runs"
15 "rodent.software/vole/core/git"
16 voleid "rodent.software/vole/core/id"
17 "rodent.software/vole/forge/access"
18 "rodent.software/vole/forge/identity"
19 "rodent.software/vole/forge/repo"
20)
21
22// reconcileHarness is the smallest server that can reconcile: the refs and the
23// queue. Everything else the Server holds is for serving, not for deciding what work
24// is owed.
25type reconcileHarness struct {
26 n int
27 s *Server
28 repo *repo.Meta
29 runs *runs.Store
30 dir string
31}
32
33func newReconcileHarness(t *testing.T) *reconcileHarness {
34 t.Helper()
35 dir := t.TempDir()
36 if err := identity.Init(filepath.Join(dir, "users.db")); err != nil {
37 t.Fatal(err)
38 }
39 acls, err := access.Open(filepath.Join(dir, "users.db"))
40 if err != nil {
41 t.Fatal(err)
42 }
43 acls.AuditPath = filepath.Join(dir, "audit.log")
44 t.Cleanup(func() { acls.Close() })
45
46 repos := repo.New(filepath.Join(dir, "repos"), "vole-under-test", acls)
47 if err := os.MkdirAll(repos.Root, 0o755); err != nil {
48 t.Fatal(err)
49 }
50 m, err := repos.Create("demo", "owner-id", "test repo")
51 if err != nil {
52 t.Fatal(err)
53 }
54 st, err := runs.Open(filepath.Join(dir, "runs.db"))
55 if err != nil {
56 t.Fatal(err)
57 }
58 t.Cleanup(func() { st.Close() })
59
60 log := slog.New(slog.NewTextHandler(io.Discard, &slog.HandlerOptions{Level: slog.LevelError}))
61 return &reconcileHarness{
62 s: &Server{Repos: repos, Runs: st, Log: log},
63 repo: m,
64 runs: st,
65 dir: dir,
66 }
67}
68
69// commit pushes a tree containing a workflow to a ref, the way a push would.
70func (h *reconcileHarness) commit(t *testing.T, ref, workflow string) string {
71 t.Helper()
72 g := h.repo.Git()
73 blob, err := g.HashObject([]byte(workflow))
74 if err != nil {
75 t.Fatal(err)
76 }
77 file, err := g.MkTree([]git.Entry{{Mode: "100644", Type: "blob", SHA: blob, Name: "ci.yaml"}})
78 if err != nil {
79 t.Fatal(err)
80 }
81 dir, err := g.MkTree([]git.Entry{{Mode: "040000", Type: "tree", SHA: file, Name: "workflows"}})
82 if err != nil {
83 t.Fatal(err)
84 }
85 readme, err := g.HashObject([]byte("demo\n"))
86 if err != nil {
87 t.Fatal(err)
88 }
89 root, err := g.MkTree([]git.Entry{
90 {Mode: "040000", Type: "tree", SHA: dir, Name: ".vole"},
91 {Mode: "100644", Type: "blob", SHA: readme, Name: "README.md"},
92 })
93 if err != nil {
94 t.Fatal(err)
95 }
96 commit, err := g.CommitTree(root, "", fmt.Sprintf("work %d", h.n))
97 if err != nil {
98 t.Fatal(err)
99 }
100 h.n++
101 if err := g.UpdateRef(ref, commit); err != nil {
102 t.Fatal(err)
103 }
104 return commit
105}
106
107func (h *reconcileHarness) queued(t *testing.T) int {
108 t.Helper()
109 list, err := h.runs.Recent("demo", 50)
110 if err != nil {
111 t.Fatal(err)
112 }
113 return len(list)
114}
115
116const testWorkflow = `version: 1
117jobs:
118 build:
119 image: alpine:3.22
120 steps:
121 - name: test
122 run: printf 'ok\n'
123`
124
125// A ref tip no cursor has recorded is work owed — that is the whole reason the queue
126// is reconciled from git rather than from the spool file a crash may have eaten.
127func TestReconcileQueuesATipTheQueueHasNeverSeen(t *testing.T) {
128 t.Parallel()
129 h := newReconcileHarness(t)
130 h.commit(t, "refs/heads/main", testWorkflow)
131
132 h.s.reconcileRefs()
133 if n := h.queued(t); n != 1 {
134 t.Fatalf("queued %d run(s) after reconciling, want 1", n)
135 }
136
137 // The cursor is the record that the tip was handled: a second pass must not queue
138 // the same work again.
139 h.s.reconcileRefs()
140 if n := h.queued(t); n != 1 {
141 t.Fatalf("queued %d run(s) after a second pass, want the tip to be handled once", n)
142 }
143}
144
145// The promise this buys: deleting the queue loses at most the job that was running.
146// The refs still say what is live, so the work comes back.
147func TestReconcileRebuildsAQueueThatWasDeleted(t *testing.T) {
148 t.Parallel()
149 h := newReconcileHarness(t)
150 h.commit(t, "refs/heads/main", testWorkflow)
151 h.s.reconcileRefs()
152
153 // What `rm data/runs.db` does, including the WAL files SQLite leaves beside it.
154 if err := h.runs.Close(); err != nil {
155 t.Fatal(err)
156 }
157 for _, name := range []string{"runs.db", "runs.db-wal", "runs.db-shm"} {
158 if err := os.Remove(filepath.Join(h.dir, name)); err != nil && !os.IsNotExist(err) {
159 t.Fatal(err)
160 }
161 }
162 st, err := runs.Open(filepath.Join(h.dir, "runs.db"))
163 if err != nil {
164 t.Fatal(err)
165 }
166 h.s.Runs = st
167 h.runs = st
168 if n := h.queued(t); n != 0 {
169 t.Fatalf("a fresh queue already has %d run(s)", n)
170 }
171
172 h.s.reconcileRefs()
173 if n := h.queued(t); n != 1 {
174 t.Fatalf("queued %d run(s) after the queue was deleted, want the live tip re-queued", n)
175 }
176}
177
178// The metadata branch is full of entities and run records; a build must never be
179// queued for it, however its tip moves.
180func TestReconcileIgnoresTheMetadataBranch(t *testing.T) {
181 t.Parallel()
182 h := newReconcileHarness(t)
183 commit := h.commit(t, "refs/heads/main", testWorkflow)
184 if err := h.repo.Git().UpdateRef("refs/heads/vole", commit); err != nil {
185 t.Fatal(err)
186 }
187
188 h.s.reconcileRefs()
189 if n := h.queued(t); n != 1 {
190 t.Fatalf("queued %d run(s), want only the branch run and nothing for refs/heads/vole", n)
191 }
192}
193
194// Pushing twice must not leave the first commit's build queued behind the second's:
195// a new tip cancels what was waiting for the old one.
196func TestANewerTipCancelsTheQueuedBuildOfTheOlderOne(t *testing.T) {
197 t.Parallel()
198 h := newReconcileHarness(t)
199 first := h.commit(t, "refs/heads/main", testWorkflow)
200 h.s.reconcileRefs()
201
202 second := h.commit(t, "refs/heads/main", testWorkflow)
203 h.s.reconcileRefs()
204
205 bySHA := map[string]string{}
206 list, err := h.runs.Recent("demo", 10)
207 if err != nil {
208 t.Fatal(err)
209 }
210 for _, r := range list {
211 bySHA[r.SHA] = r.Status
212 }
213 if got := bySHA[first]; got != runs.StatusCanceled {
214 t.Fatalf("the run for the replaced tip is %q, want %q", got, runs.StatusCanceled)
215 }
216 if got := bySHA[second]; got != runs.StatusQueued {
217 t.Fatalf("the run for the new tip is %q, want %q", got, runs.StatusQueued)
218 }
219}
220
221// A rename must carry the queue with it. Rows are keyed by repository name, so a
222// stranded job is one no runner is ever told about — the lease filters by the names an
223// account may run, and the old name no longer exists — and a stranded cursor makes the
224// next reconciliation build the current tip a second time.
225func TestRenamingARepositoryCarriesItsQueue(t *testing.T) {
226 t.Parallel()
227 h := newReconcileHarness(t)
228 h.commit(t, "refs/heads/main", testWorkflow)
229 h.s.reconcileRefs()
230
231 if err := h.s.Repos.Rename("demo", "renamed"); err != nil {
232 t.Fatal(err)
233 }
234 if err := h.runs.RenameRepo("demo", "renamed"); err != nil {
235 t.Fatal(err)
236 }
237 if old, err := h.runs.Recent("demo", 10); err != nil {
238 t.Fatal(err)
239 } else if len(old) != 0 {
240 t.Fatalf("%d run(s) stayed under the old name", len(old))
241 }
242 kept, err := h.runs.Recent("renamed", 10)
243 if err != nil {
244 t.Fatal(err)
245 }
246 if len(kept) != 1 {
247 t.Fatalf("%d run(s) under the new name, want the queued one to have moved", len(kept))
248 }
249
250 // The cursor came along, so the sweep has nothing new to queue.
251 h.s.reconcileRefs()
252 after, err := h.runs.Recent("renamed", 10)
253 if err != nil {
254 t.Fatal(err)
255 }
256 if len(after) != len(kept) {
257 t.Fatalf("the sweep queued %d more run(s) after the rename: the cursor did not follow",
258 len(after)-len(kept))
259 }
260}
261
262// A deleted branch drops its cursor, so the branch coming back at a new tip is work
263// owed rather than work suppressed by a note about a branch that no longer exists.
264func TestReconcileDropsTheCursorOfADeletedBranch(t *testing.T) {
265 t.Parallel()
266 h := newReconcileHarness(t)
267 h.commit(t, "refs/heads/main", testWorkflow)
268 h.s.reconcileRefs()
269
270 if err := h.repo.Git().DeleteRef("refs/heads/main"); err != nil {
271 t.Fatal(err)
272 }
273 h.s.reconcileRefs()
274
275 cursors, err := h.runs.Cursors("demo")
276 if err != nil {
277 t.Fatal(err)
278 }
279 if _, ok := cursors["refs/heads/main"]; ok {
280 t.Fatalf("the cursor for a deleted branch survived: %v", cursors)
281 }
282}
283
284// A combined result is queued as a run for the base branch, and the commit it builds
285// is the commit the landing lands. The push that follows therefore owes nothing: the
286// same tree, workflow and job already ran for this branch. Queueing them again spends
287// the work twice and shows one commit twice in the run list (issue #249).
288func TestALandedCombinedResultIsNotQueuedAgain(t *testing.T) {
289 t.Parallel()
290 h := newReconcileHarness(t)
291 sha := h.commit(t, "refs/heads/main", testWorkflow)
292 h.queueCombined(t, sha, "build")
293
294 h.s.reconcileRefs()
295
296 list, err := h.runs.Recent("demo", 10)
297 if err != nil {
298 t.Fatal(err)
299 }
300 if len(list) != 1 {
301 t.Fatalf("queued %d run(s) for the landed commit, want the combined result alone: %+v",
302 len(list), list)
303 }
304 if !strings.HasPrefix(list[0].Ref, runs.CandidateRefRoot) {
305 t.Fatalf("the run for the landed commit is %q, want the combined result that tested it", list[0].Ref)
306 }
307 // Skipping the run must not leave the branch owed forever: the cursor is the note
308 // that this tip was handled.
309 cursors, err := h.runs.Cursors("demo")
310 if err != nil {
311 t.Fatal(err)
312 }
313 if got := cursors["refs/heads/main"]; got != sha {
314 t.Fatalf("the branch cursor is %q, want %q", got, sha)
315 }
316}
317
318// A combined result never deploys the site, so a job that does is not the same work.
319// The branch still runs it, and that run is what publishes the site for the landing.
320func TestAJobThatDeploysTheSiteStillRunsAfterTheCombinedResult(t *testing.T) {
321 t.Parallel()
322 h := newReconcileHarness(t)
323 sha := h.commit(t, "refs/heads/main", pagesWorkflow)
324 h.queueCombined(t, sha, "build")
325
326 h.s.reconcileRefs()
327
328 list, err := h.runs.Recent("demo", 10)
329 if err != nil {
330 t.Fatal(err)
331 }
332 var branch *runs.Run
333 for _, r := range list {
334 if r.Ref == "refs/heads/main" {
335 branch = r
336 }
337 }
338 if branch == nil {
339 t.Fatalf("the site was not published by a run on the branch: %+v", list)
340 }
341 if branch.PagesPath != "public" {
342 t.Fatalf("the branch run deploys %q, want public", branch.PagesPath)
343 }
344 if len(list) != 2 {
345 t.Fatalf("queued %d run(s), want the combined result and the site run: %+v", len(list), list)
346 }
347}
348
349// queueCombined writes the run the combined-result reconciler would have written
350// before the landing: the same job, for the base branch, at the commit that is now
351// the tip.
352func (h *reconcileHarness) queueCombined(t *testing.T, sha, job string) {
353 t.Helper()
354 id, err := voleid.New(time.Now().UTC())
355 if err != nil {
356 t.Fatal(err)
357 }
358 if _, err := h.runs.Enqueue(runs.Run{
359 ID: id, Repo: "demo", Ref: runs.CandidateRefRoot + id + "/aaaa-bbbb",
360 Branch: "main", SHA: sha, Workflow: ".vole/workflows/ci.yaml",
361 WorkflowSHA: "cc", Job: job, Status: runs.StatusSuccess,
362 Created: time.Now().UTC(),
363 }, nil); err != nil {
364 t.Fatal(err)
365 }
366}
367
368// pagesWorkflow declares a site, which a combined result is not allowed to deploy.
369const pagesWorkflow = `version: 1
370jobs:
371 build:
372 image: alpine:3.22
373 pages:
374 path: public
375 steps:
376 - run: printf 'ok\n'
377`
378