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()}) | |