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/integration_test.go +73

@@ -3349,3 +3349,76 @@ func TestHealthzReportsUsers(t *testing.T) {
3349 3349 }
3350 3350
3351 3351 var _ = acl.Read
3352
3353 // A job that names a machine with runs-on is only built by a runner that carries the
3354 // label. The end-to-end shape matters here: the whole point is that nothing runs, which
3355 // has to be distinguishable from "a runner started and failed".
3356 func TestRunsOnWaitsForARunnerThatCarriesTheLabel(t *testing.T) {
3357 f := newFixtureWith(t, func(c *config.Config) { c.LeaseSeconds = 5 })
3358 f.publish(f.repo)
3359
3360 work := workRepo(t, workflowFile(`version: 1
3361 jobs:
3362 build:
3363 runs-on: [linux, fast]
3364 steps:
3365 - run: echo "on the right machine"
3366 `))
3367 if _, errs, err := f.git(work, "push", f.sshURL(f.repo), "main:refs/heads/main"); err != nil {
3368 t.Fatalf("push: %s", errs)
3369 }
3370 queued := f.waitForRun(t, 5*time.Second, func(r *runs.Run) bool { return r.Status == runs.StatusQueued })
3371 if queued.Job != "build" {
3372 t.Fatalf("queued run = %+v", queued)
3373 }
3374
3375 // A one-shot runner carrying only one of the two labels must not take it.
3376 cmd := exec.Command(binPath, "runner", "--remote", f.sshBase(), "--once",
3377 "--dir", t.TempDir(), "--label", "linux")
3378 cmd.Env = f.ciEnv()
3379 if out, err := cmd.CombinedOutput(); err != nil {
3380 t.Fatalf("runner: %v\n%s", err, out)
3381 }
3382 still, err := f.srv.Runs.Get(queued.ID)
3383 if err != nil {
3384 t.Fatal(err)
3385 }
3386 if still.Status != runs.StatusQueued {
3387 t.Fatalf("a runner carrying only linux took a job that asks for linux and fast: run is %s", still.Status)
3388 }
3389
3390 // The matching runner builds it.
3391 cmd = exec.Command(binPath, "runner", "--remote", f.sshBase(), "--once",
3392 "--dir", t.TempDir(), "--label", "linux", "--label", "fast")
3393 cmd.Env = f.ciEnv()
3394 if out, err := cmd.CombinedOutput(); err != nil {
3395 t.Fatalf("runner: %v\n%s", err, out)
3396 }
3397 done := f.waitForRun(t, 10*time.Second, func(r *runs.Run) bool {
3398 return r.Status != runs.StatusQueued && r.Status != runs.StatusRunning
3399 })
3400 if done.Status != runs.StatusSuccess {
3401 t.Fatalf("run status = %s", done.Status)
3402 }
3403 if code, body := f.get("/r/" + f.repo + "/runs/" + done.ID + "/log"); code != 200 ||
3404 !strings.Contains(body, "on the right machine") {
3405 t.Fatalf("run log = %d:\n%s", code, body)
3406 }
3407 }
3408
3409 // A label the workflow validator would reject is a command-line mistake, so a runner
3410 // refuses it before it dials. A machine advertising a label no workflow can ask for is
3411 // a typo with no symptom.
3412 func TestABadRunnerLabelIsRefusedBeforeConnecting(t *testing.T) {
3413 f := newFixture(t)
3414 cmd := exec.Command(binPath, "runner", "--remote", f.sshBase(), "--once",
3415 "--dir", t.TempDir(), "--label", "Linux")
3416 cmd.Env = f.ciEnv()
3417 out, err := cmd.CombinedOutput()
3418 if err == nil {
3419 t.Fatalf("a runner accepted --label Linux:\n%s", out)
3420 }
3421 if !strings.Contains(string(out), "not a runner label") {
3422 t.Fatalf("the refusal does not explain the rule:\n%s", out)
3423 }
3424 }