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/vole.git

Write over SSH with a registered key:

git clone ssh://git@rodent.software:2222/vole.git

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

Phase 4: a runners page, built from the queue's own presence

ac43353160989ae56b6a92ae8f1df1017c2c20c4 · Keenan Nemetz · 17d ago · parent 760b523a

Phase 4: a runners page, built from the queue's own presence

"Nobody is connected and eleven jobs are waiting" is the sentence somebody needs when
a build does not start, and until now nothing could say it: a runner existed only as
an SSH channel, invisible to both the web and the queue. `runs.db` could already
answer "who holds this lease" (lease_owner, lease_until) and "who ran this" (the
attestation), but not "who is connected and idle".

Presence is now a row per channel in the queue — `runner_sessions` — written by events
a runner already sends: connect, lease (which records the run it holds), heartbeat (a
long build must not look gone), and finish (which clears it). A clean disconnect drops
the row, and `watchLeases` forgets rows silent for ten minutes, which is the only trace
a killed process leaves. Nothing here is authoritative: presence is a derived fact
about a live channel, which is why the page applies its own two-minute window rather
than trusting a stored "online" flag.

GET /runners shows the connected runners (account, the job it is building and its
repository, or "waiting for work") and the queue depth per repository. It follows the
clone rule like every other page: a session building a private repository is not shown
at all, and a private repository's queue is not counted, because naming either would
confirm the repository exists. The header now links to it from every page.

Measured: TestARunnerSessionIsVisibleWhileItIsConnected (a connected runner is one row,
idle, then "building <run>" once it leases), TestRunnersPageShowsTheRunnerHoldingAJob
AndTheQueueDepth (the page names the account, links the run it is building and counts
one waiting and one running), TestRunnersPageKeepsAPrivateRepositoryOut (no repository
name, account, job or run id from a private repository appears).

internal/server/runners_page_test.go +99

@@ -0,0 +1,99 @@
1 package server_test
2
3 // The runners page is built from two derived facts in the queue: the presence a
4 // session writes while it is connected, and the work the queue is holding. It answers
5 // "is my build waiting because nobody is listening", which is otherwise a question
6 // about a process on somebody else's machine.
7
8 import (
9 "strings"
10 "testing"
11 "time"
12
13 "github.com/rodent-software/vole/internal/runs"
14 )
15
16 func queuedRun(id, repo, job string, created time.Time) runs.Run {
17 return runs.Run{
18 ID: id, Repo: repo, Ref: "refs/heads/main", Branch: "main",
19 SHA: strings.Repeat("ab", 20),
20 Workflow: ".vole/workflows/ci.yaml", WorkflowSHA: strings.Repeat("cd", 20),
21 Job: job, Steps: []runs.Step{{Name: "test", Run: "true"}}, Created: created,
22 }
23 }
24
25 func TestRunnersPageShowsTheRunnerHoldingAJobAndTheQueueDepth(t *testing.T) {
26 f := newFixture(t)
27 f.publish(f.repo)
28 now := time.Now().UTC()
29
30 waiting := queuedRun("01WAITINGRUN00000000000000", f.repo, "waiting", now)
31 if _, err := f.srv.Runs.Enqueue(waiting); err != nil {
32 t.Fatal(err)
33 }
34 building := queuedRun("01BUILDINGRUN0000000000000", f.repo, "building", now)
35 if _, err := f.srv.Runs.Enqueue(building); err != nil {
36 t.Fatal(err)
37 }
38 // A runner connected as the owner, holding the second job — the state the page
39 // exists to show.
40 if err := f.srv.Runs.TouchSession("session-1", f.ownerID, now); err != nil {
41 t.Fatal(err)
42 }
43 if err := f.srv.Runs.SessionWorking("session-1", f.repo, building.ID, now); err != nil {
44 t.Fatal(err)
45 }
46
47 code, body := f.get("/runners")
48 if code != 200 {
49 t.Fatalf("GET /runners = %d\n%s", code, body)
50 }
51 for _, want := range []string{
52 "keenan", // the account the key resolved to
53 "/r/demo/runs/" + building.ID, // the job it is building, linked
54 "building", // its job name
55 "Waiting", "Running", "demo", // the queue, per repository
56 } {
57 if !strings.Contains(body, want) {
58 t.Errorf("the runners page does not mention %q:\n%s", want, body)
59 }
60 }
61 if strings.Contains(body, "No runner is connected") {
62 t.Error("the page says no runner is connected while one is holding a job")
63 }
64 }
65
66 func TestRunnersPageKeepsAPrivateRepositoryOut(t *testing.T) {
67 f := newFixture(t)
68 f.publish(f.repo)
69 now := time.Now().UTC()
70 f.addWriter(t) // wilbur, who may write the public repo and nothing else
71
72 secret, err := f.srv.Repos.Create("secret", f.ownerID, "not published")
73 if err != nil {
74 t.Fatal(err)
75 }
76 if err := f.srv.Index.Upsert(secret); err != nil {
77 t.Fatal(err)
78 }
79 private := queuedRun("01PRIVATERUN00000000000000", "secret", "private-job", now)
80 if _, err := f.srv.Runs.Enqueue(private); err != nil {
81 t.Fatal(err)
82 }
83 if err := f.srv.Runs.TouchSession("session-private", f.writerID, now); err != nil {
84 t.Fatal(err)
85 }
86 if err := f.srv.Runs.SessionWorking("session-private", "secret", private.ID, now); err != nil {
87 t.Fatal(err)
88 }
89
90 code, body := f.get("/runners")
91 if code != 200 {
92 t.Fatalf("GET /runners = %d\n%s", code, body)
93 }
94 for _, hidden := range []string{"secret", "wilbur", private.ID, private.Job} {
95 if strings.Contains(body, hidden) {
96 t.Errorf("the runners page shows %q, which belongs to a private repository:\n%s", hidden, body)
97 }
98 }
99 }