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/sshserver/runner.go +15−5

@@ -53,6 +53,11 @@ type runnerRequest struct {
53 53 // repository no allowlist names. Empty means "no narrowing", which keeps the list
54 54 // following allowlist edits instead of freezing it at connect time.
55 55 Repos []string `json:"repos,omitempty"`
56 // Labels are what this machine is (--label), and a job is only leased to a runner
57 // carrying every label the job asked for with runs-on. They describe a machine, not
58 // a permission: a runner that claims a label it does not have only hurts itself,
59 // which is why this is a client claim where --repos is server-enforced.
60 Labels []string `json:"labels,omitempty"`
56 61 // Wait is how many seconds an idle lease may park before answering "nothing to
57 62 // do". Zero answers at once, which is what a one-shot runner wants: it is here to
58 63 // run the job it was started for, not to wait for the next one.
@@ -142,7 +147,7 @@ func (s *Server) serveRunner(ch ssh.Channel, user, accountID string) {
142 147 _ = enc.Encode(runnerReply{Op: "error", Error: err.Error()})
143 148 return
144 149 }
145 if err := cfg.Runs.TouchSession(sessionID, accountID, time.Now().UTC()); err != nil {
150 if err := cfg.Runs.TouchSession(sessionID, accountID, nil, time.Now().UTC()); err != nil {
146 151 cfg.Log.Warn("runner: cannot record the session", "user", user, "err", err)
147 152 }
148 153 defer cfg.Runs.DropSession(sessionID)
@@ -181,7 +186,10 @@ func (s *Server) serveRunner(ch ssh.Channel, user, accountID string) {
181 186 // wait closes a channel somebody is holding. Asking first and waiting second
182 187 // loses exactly that race.
183 188 wake := cfg.Runs.Work()
184 lease := runs.LeaseRequest{Owner: owner, Repos: want, TTL: cfg.Lease, MaxPerRepo: cfg.MaxJobsPerRepo}
189 lease := runs.LeaseRequest{
190 Owner: owner, Repos: want, Labels: req.Labels,
191 TTL: cfg.Lease, MaxPerRepo: cfg.MaxJobsPerRepo,
192 }
185 193 run, err := cfg.Runs.Lease(lease, now)
186 194 if err != nil {
187 195 _ = enc.Encode(runnerReply{Op: "lease", Error: err.Error()})
@@ -206,8 +214,10 @@ func (s *Server) serveRunner(ch ssh.Channel, user, accountID string) {
206 214 }
207 215 if run == nil {
208 216 // An idle runner is still connected: refresh its presence so the page can
209 // say "waiting for work" instead of showing a runner that vanished.
210 _ = cfg.Runs.TouchSession(sessionID, accountID, time.Now().UTC())
217 // say "waiting for work" instead of showing a runner that vanished. The
218 // labels ride along, because a runner waiting for the wrong kind of work
219 // is exactly what this row is for.
220 _ = cfg.Runs.TouchSession(sessionID, accountID, req.Labels, time.Now().UTC())
211 221 _ = enc.Encode(runnerReply{Op: "lease", OK: true})
212 222 continue
213 223 }
@@ -235,7 +245,7 @@ func (s *Server) serveRunner(ch ssh.Channel, user, accountID string) {
235 245 case "heartbeat":
236 246 // Liveness only: the lease is what a heartbeat buys. Presence is refreshed
237 247 // here too, so a long build does not make its runner look gone.
238 _ = cfg.Runs.TouchSession(sessionID, accountID, time.Now().UTC())
248 _ = cfg.Runs.TouchSession(sessionID, accountID, nil, time.Now().UTC())
239 249 ok, err := cfg.Runs.Heartbeat(req.Run, owner, cfg.Lease, time.Now().UTC())
240 250 if err != nil {
241 251 _ = enc.Encode(runnerReply{Op: "heartbeat", Error: err.Error()})