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.
Merge change request 01M70WES5ZZDJ6RP226WB11BBZ: Let people rename and remove their own passkeys
keenan · 1h ago
4b8c9e1d
| 1 | // White-box tests for the reconciliation that makes the queue follow the refs. |
| 2 | package server |
| 3 | |
| 4 | import ( |
| 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. |
| 25 | type reconcileHarness struct { |
| 26 | n int |
| 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 | } |
| 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. |
| 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 | } |
| 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 | |
| 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: |
| 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. |
| 127 | func 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. |
| 147 | func 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. |
| 180 | func 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. |
| 196 | func 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. |
| 225 | func 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. |
| 264 | func 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). |
| 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 | ` |
| 378 |