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 · server/reconcile_test.go

Line attribution on main · 377 line(s)

a945ae55
Keenan Nemetz
16d ago
1 // White-box tests for the reconciliation that makes the queue follow the refs.
2 package server
3
4 import (
760b523a
Keenan Nemetz
16d ago
5 "fmt"
a945ae55
Keenan Nemetz
16d ago
6 "io"
7 "log/slog"
8 "os"
9 "path/filepath"
5f6163bb
Keenan Nemetz
2h ago
10 "strings"
a945ae55
Keenan Nemetz
16d ago
11 "testing"
5f6163bb
Keenan Nemetz
2h ago
12 "time"
a945ae55
Keenan Nemetz
16d ago
13
d37d301f
Keenan Nemetz
16h ago
14 "rodent.software/vole/ci/runs"
15 "rodent.software/vole/core/git"
5f6163bb
Keenan Nemetz
2h ago
16 voleid "rodent.software/vole/core/id"
d37d301f
Keenan Nemetz
16h ago
17 "rodent.software/vole/forge/access"
18 "rodent.software/vole/forge/identity"
19 "rodent.software/vole/forge/repo"
a945ae55
Keenan Nemetz
16d ago
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.
25 type reconcileHarness struct {
760b523a
Keenan Nemetz
16d ago
26 n int
a945ae55
Keenan Nemetz
16d ago
27 s *Server
28 repo *repo.Meta
29 runs *runs.Store
30 dir string
31 }
32
33 func 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 }
b1499c72
Keenan Nemetz
13d ago
43 acls.AuditPath = filepath.Join(dir, "audit.log")
a945ae55
Keenan Nemetz
16d ago
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.
70 func (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 }
c5902451
Keenan Nemetz
16d ago
77 file, err := g.MkTree([]git.Entry{{Mode: "100644", Type: "blob", SHA: blob, Name: "ci.yaml"}})
a945ae55
Keenan Nemetz
16d ago
78 if err != nil {
79 t.Fatal(err)
80 }
c5902451
Keenan Nemetz
16d ago
81 dir, err := g.MkTree([]git.Entry{{Mode: "040000", Type: "tree", SHA: file, Name: "workflows"}})
a945ae55
Keenan Nemetz
16d ago
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 }
c5902451
Keenan Nemetz
16d ago
89 root, err := g.MkTree([]git.Entry{
a945ae55
Keenan Nemetz
16d ago
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 }
760b523a
Keenan Nemetz
16d ago
96 commit, err := g.CommitTree(root, "", fmt.Sprintf("work %d", h.n))
a945ae55
Keenan Nemetz
16d ago
97 if err != nil {
98 t.Fatal(err)
99 }
760b523a
Keenan Nemetz
16d ago
100 h.n++
a945ae55
Keenan Nemetz
16d ago
101 if err := g.UpdateRef(ref, commit); err != nil {
102 t.Fatal(err)
103 }
104 return commit
105 }
106
107 func (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
116 const testWorkflow = `version: 1
117 jobs:
118 build:
70b70264
Keenan Nemetz
15d ago
119 image: alpine:3.22
a945ae55
Keenan Nemetz
16d ago
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.
127 func TestReconcileQueuesATipTheQueueHasNeverSeen(t *testing.T) {
44530e74
Keenan Nemetz
16d ago
128 t.Parallel()
a945ae55
Keenan Nemetz
16d ago
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.
147 func TestReconcileRebuildsAQueueThatWasDeleted(t *testing.T) {
44530e74
Keenan Nemetz
16d ago
148 t.Parallel()
a945ae55
Keenan Nemetz
16d ago
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.
180 func TestReconcileIgnoresTheMetadataBranch(t *testing.T) {
44530e74
Keenan Nemetz
16d ago
181 t.Parallel()
a945ae55
Keenan Nemetz
16d ago
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
760b523a
Keenan Nemetz
16d ago
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.
196 func TestANewerTipCancelsTheQueuedBuildOfTheOlderOne(t *testing.T) {
44530e74
Keenan Nemetz
16d ago
197 t.Parallel()
760b523a
Keenan Nemetz
16d ago
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
72797dcd
Keenan Nemetz
16d ago
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.
225 func TestRenamingARepositoryCarriesItsQueue(t *testing.T) {
44530e74
Keenan Nemetz
16d ago
226 t.Parallel()
72797dcd
Keenan Nemetz
16d ago
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
a945ae55
Keenan Nemetz
16d ago
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.
264 func TestReconcileDropsTheCursorOfADeletedBranch(t *testing.T) {
44530e74
Keenan Nemetz
16d ago
265 t.Parallel()
a945ae55
Keenan Nemetz
16d ago
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 }
5f6163bb
Keenan Nemetz
2h ago
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).
288 func 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.
320 func 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.
352 func (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.
369 const pagesWorkflow = `version: 1
370 jobs:
371 build:
372 image: alpine:3.22
373 pages:
374 path: public
375 steps:
376 - run: printf 'ok\n'
377 `