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