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.

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 }