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.
Phase 4: a job can name the machine it needs
6c03e461ee409f4bd053ee552edd87b6188a5eb2 · Keenan Nemetz · 16d ago · parent b238ac24
Phase 4: a job can name the machine it needs `runs-on: [linux, arm64]` on a job, `--label linux` on a runner, and the lease hands a job only to a runner carrying every label it asked for. Extra labels on the runner are not a mismatch — the rule is set inclusion, so a machine more specific than the job needs still qualifies — and a job with no runs-on matches every runner, which is what every job did before this existed. Both spellings are accepted (`runs-on: linux` and a list), because both are the obvious way to write it and a dialect already separate from GitHub's should not also fail on the spelling a person guessed. Labels are validated in pre-receive by the same rule the runner applies to --label at startup: a label no runner can carry leaves a job queued forever and looks exactly like a busy pool, which is the failure nobody debugs because there is no error. Labels are pinned at enqueue next to the steps and the artifact patterns. A workflow edited after a push must not change where an already-queued job is allowed to run. That also settles the derived-store question: run_labels is queue state, not history, because a finished run's requirement is recoverable from the pinned workflow blob, so `vole index rebuild` does not restore labels and nothing needs it to. A label is a claim by the runner about itself, unlike --repos, which the server enforces. A runner that lies about its architecture only takes work it will do badly; a runner that could name repositories it was not granted would be an access-control hole. /runners now shows what each connected runner takes, and says "any job" for an unlabelled one — the other half of "why has my build not started", since queue depth alone cannot say that no runner carrying the needed label is connected. Enqueue takes labels as a second argument rather than a field on Run: they are lease-time policy, not part of what a run is, and every reader of a Run (a gate, a page, a record rebuild) would be looking at a field only a lease ever consulted. Measured: TestRunsOnWaitsForARunnerThatCarriesTheLabel — a runner carrying only `linux` leaves a `[linux, fast]` job queued and the runner carrying both builds it. TestLabelsDecideWhichRunnerMayTakeAJob covers no requirement, exact match, one label missing, wrong labels and extra labels; TestLabelsArePinnedAtEnqueue, TestForgettingARepositoryDropsItsLabels, TestABadRunnerLabelIsRefusedBeforeConnecting, TestRunsOnLabelsAreParsedAndValidated and TestRunnersPageShowsWhatEachRunnerTakes cover the edges.
internal/server/runners_page_test.go +34−7
| @@ -29,16 +29,16 @@ func TestRunnersPageShowsTheRunnerHoldingAJobAndTheQueueDepth(t *testing.T) { | |||
| 29 | 29 | now := time.Now().UTC() | |
| 30 | 30 | ||
| 31 | 31 | waiting := queuedRun("01WAITINGRUN00000000000000", f.repo, "waiting", now) | |
| 32 | if _, err := f.srv.Runs.Enqueue(waiting); err != nil { | ||
| 32 | if _, err := f.srv.Runs.Enqueue(waiting, nil); err != nil { | ||
| 33 | 33 | t.Fatal(err) | |
| 34 | 34 | } | |
| 35 | 35 | building := queuedRun("01BUILDINGRUN0000000000000", f.repo, "building", now) | |
| 36 | if _, err := f.srv.Runs.Enqueue(building); err != nil { | ||
| 36 | if _, err := f.srv.Runs.Enqueue(building, nil); err != nil { | ||
| 37 | 37 | t.Fatal(err) | |
| 38 | 38 | } | |
| 39 | 39 | // A runner connected as the owner, holding the second job — the state the page | |
| 40 | 40 | // exists to show. | |
| 41 | if err := f.srv.Runs.TouchSession("session-1", f.ownerID, now); err != nil { | ||
| 41 | if err := f.srv.Runs.TouchSession("session-1", f.ownerID, nil, now); err != nil { | ||
| 42 | 42 | t.Fatal(err) | |
| 43 | 43 | } | |
| 44 | 44 | if err := f.srv.Runs.SessionWorking("session-1", f.repo, building.ID, now); err != nil { | |
| @@ -78,7 +78,7 @@ func TestRunnersPageSaysWhenARepositoryIsAtItsCap(t *testing.T) { | |||
| 78 | 78 | ||
| 79 | 79 | // One running (leased) and one waiting, with a cap of one. | |
| 80 | 80 | running := queuedRun("01RUNNINGRUN00000000000000", f.repo, "running", now) | |
| 81 | if _, err := f.srv.Runs.Enqueue(running); err != nil { | ||
| 81 | if _, err := f.srv.Runs.Enqueue(running, nil); err != nil { | ||
| 82 | 82 | t.Fatal(err) | |
| 83 | 83 | } | |
| 84 | 84 | if _, err := f.srv.Runs.Lease(runs.LeaseRequest{Owner: f.ownerID, | |
| @@ -86,7 +86,7 @@ func TestRunnersPageSaysWhenARepositoryIsAtItsCap(t *testing.T) { | |||
| 86 | 86 | t.Fatal(err) | |
| 87 | 87 | } | |
| 88 | 88 | waiting := queuedRun("01WAITINGRUN00000000000000", f.repo, "waiting", now) | |
| 89 | if _, err := f.srv.Runs.Enqueue(waiting); err != nil { | ||
| 89 | if _, err := f.srv.Runs.Enqueue(waiting, nil); err != nil { | ||
| 90 | 90 | t.Fatal(err) | |
| 91 | 91 | } | |
| 92 | 92 | ||
| @@ -113,10 +113,10 @@ func TestRunnersPageKeepsAPrivateRepositoryOut(t *testing.T) { | |||
| 113 | 113 | t.Fatal(err) | |
| 114 | 114 | } | |
| 115 | 115 | private := queuedRun("01PRIVATERUN00000000000000", "secret", "private-job", now) | |
| 116 | if _, err := f.srv.Runs.Enqueue(private); err != nil { | ||
| 116 | if _, err := f.srv.Runs.Enqueue(private, nil); err != nil { | ||
| 117 | 117 | t.Fatal(err) | |
| 118 | 118 | } | |
| 119 | if err := f.srv.Runs.TouchSession("session-private", f.writerID, now); err != nil { | ||
| 119 | if err := f.srv.Runs.TouchSession("session-private", f.writerID, nil, now); err != nil { | ||
| 120 | 120 | t.Fatal(err) | |
| 121 | 121 | } | |
| 122 | 122 | if err := f.srv.Runs.SessionWorking("session-private", "secret", private.ID, now); err != nil { | |
| @@ -133,3 +133,30 @@ func TestRunnersPageKeepsAPrivateRepositoryOut(t *testing.T) { | |||
| 133 | 133 | } | |
| 134 | 134 | } | |
| 135 | 135 | } | |
| 136 | |||
| 137 | // A job waiting for a machine that is not connected is invisible from the queue side: | ||
| 138 | // the depth says one is waiting, and nothing says why nobody took it. The runner's own | ||
| 139 | // labels are the missing half, so the page shows them next to the runner. | ||
| 140 | func TestRunnersPageShowsWhatEachRunnerTakes(t *testing.T) { | ||
| 141 | f := newFixture(t) | ||
| 142 | f.publish(f.repo) | ||
| 143 | now := time.Now().UTC() | ||
| 144 | |||
| 145 | if err := f.srv.Runs.TouchSession("session-linux", f.ownerID, []string{"linux", "arm64"}, now); err != nil { | ||
| 146 | t.Fatal(err) | ||
| 147 | } | ||
| 148 | if err := f.srv.Runs.TouchSession("session-plain", f.ownerID, nil, now); err != nil { | ||
| 149 | t.Fatal(err) | ||
| 150 | } | ||
| 151 | |||
| 152 | code, body := f.get("/runners") | ||
| 153 | if code != 200 { | ||
| 154 | t.Fatalf("GET /runners = %d\n%s", code, body) | ||
| 155 | } | ||
| 156 | if !strings.Contains(body, "linux, arm64") { | ||
| 157 | t.Fatalf("the page does not show the labels a runner carries:\n%s", body) | ||
| 158 | } | ||
| 159 | if !strings.Contains(body, "any job") { | ||
| 160 | t.Fatalf("the page does not say an unlabelled runner takes any job:\n%s", body) | ||
| 161 | } | ||
| 162 | } | ||