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/workflow/workflow.go +74
| @@ -31,6 +31,9 @@ const ( | |||
| 31 | 31 | MaxJobs = 50 | |
| 32 | 32 | MaxSteps = 100 | |
| 33 | 33 | MaxArtifacts = 32 | |
| 34 | // MaxLabels bounds runs-on. A job naming more than a handful of labels is | ||
| 35 | // describing a specific machine, which is what --repos and the allowlist are for. | ||
| 36 | MaxLabels = 8 | ||
| 34 | 37 | // MaxArtifactPathBytes bounds one pattern. A declaration is a path, not a | |
| 35 | 38 | // manifest: the files it matches are discovered when the job runs. | |
| 36 | 39 | MaxArtifactPathBytes = 256 | |
| @@ -45,8 +48,23 @@ const ( | |||
| 45 | 48 | var ( | |
| 46 | 49 | jobIDRE = regexp.MustCompile(`^[a-z][a-z0-9_-]{0,31}$`) | |
| 47 | 50 | branchGlob = regexp.MustCompile(`^[A-Za-z0-9*?\[\]!._/-]+$`) | |
| 51 | // labelRE is a runner label: what a workflow asks for with runs-on and what a | ||
| 52 | // runner says it is with --label. Lowercase because a label is an identity, and | ||
| 53 | // `Linux` and `linux` being different machines is a support question nobody wants. | ||
| 54 | labelRE = regexp.MustCompile(`^[a-z0-9][a-z0-9._-]{0,31}$`) | ||
| 48 | 55 | ) | |
| 49 | 56 | ||
| 57 | // ValidLabel reports whether s can be a runner label. It is exported so the runner | ||
| 58 | // refuses a bad --label at startup with the same rule the workflow validator applies: | ||
| 59 | // a label that can never be stored would otherwise be a machine that silently never | ||
| 60 | // matches a job. | ||
| 61 | func ValidLabel(s string) error { | ||
| 62 | if !labelRE.MatchString(s) { | ||
| 63 | return fmt.Errorf("%q is not a runner label (1-32 characters: lowercase letters, digits, '.', '_' and '-', starting with a letter or digit)", s) | ||
| 64 | } | ||
| 65 | return nil | ||
| 66 | } | ||
| 67 | |||
| 50 | 68 | type Step struct { | |
| 51 | 69 | Name string | |
| 52 | 70 | Run string | |
| @@ -68,6 +86,11 @@ type Artifact struct { | |||
| 68 | 86 | type Job struct { | |
| 69 | 87 | ID string | |
| 70 | 88 | Steps []Step | |
| 89 | // Labels are the runner labels this job requires (`runs-on`). Empty means any | ||
| 90 | // runner: a workflow that says nothing about where it runs keeps the behaviour it | ||
| 91 | // had before targeting existed. A job is only leased to a runner carrying every | ||
| 92 | // label here, so this is a filter on machines, not a preference among them. | ||
| 93 | Labels []string | ||
| 71 | 94 | // Artifacts are the files this job publishes as the run's outputs. | |
| 72 | 95 | Artifacts []Artifact | |
| 73 | 96 | // Pages, when set, publishes a directory as the repository's site. It is on the | |
| @@ -132,10 +155,38 @@ type rawPages struct { | |||
| 132 | 155 | ||
| 133 | 156 | type rawJob struct { | |
| 134 | 157 | Steps []rawStep `yaml:"steps"` | |
| 158 | RunsOn rawLabels `yaml:"runs-on"` | ||
| 135 | 159 | Artifacts []rawArtifact `yaml:"artifacts"` | |
| 136 | 160 | Pages *rawPages `yaml:"pages"` | |
| 137 | 161 | } | |
| 138 | 162 | ||
| 163 | // rawLabels accepts both spellings of runs-on, because both are the obvious way to | ||
| 164 | // write it: `runs-on: linux` when there is one label and `runs-on: [linux, arm64]` | ||
| 165 | // when there are several. Making the scalar a decode error would be a dialect | ||
| 166 | // difference with no purpose — the dialect is separate from GitHub's already, so it | ||
| 167 | // should not also fail on the spelling a person guessed. | ||
| 168 | type rawLabels []string | ||
| 169 | |||
| 170 | func (l *rawLabels) UnmarshalYAML(node *yaml.Node) error { | ||
| 171 | switch node.Kind { | ||
| 172 | case yaml.ScalarNode: | ||
| 173 | var s string | ||
| 174 | if err := node.Decode(&s); err != nil { | ||
| 175 | return err | ||
| 176 | } | ||
| 177 | *l = []string{s} | ||
| 178 | return nil | ||
| 179 | case yaml.SequenceNode: | ||
| 180 | var v []string | ||
| 181 | if err := node.Decode(&v); err != nil { | ||
| 182 | return err | ||
| 183 | } | ||
| 184 | *l = v | ||
| 185 | return nil | ||
| 186 | } | ||
| 187 | return fmt.Errorf("runs-on takes a label or a list of labels (runs-on: linux, or runs-on: [linux, arm64])") | ||
| 188 | } | ||
| 189 | |||
| 139 | 190 | type rawWorkflow struct { | |
| 140 | 191 | Version int `yaml:"version"` | |
| 141 | 192 | On struct { | |
| @@ -233,6 +284,29 @@ func Parse(file string, content string) (*Workflow, []string) { | |||
| 233 | 284 | bad("jobs.%s: %d steps, limit %d", id, len(jr.Steps), MaxSteps) | |
| 234 | 285 | } | |
| 235 | 286 | job := Job{ID: id} | |
| 287 | // runs-on is validated here rather than at lease time: a label a job asks for and | ||
| 288 | // no runner can ever carry leaves the job queued forever, which looks like a busy | ||
| 289 | // pool rather than a mistake. A typo fails the push that introduced it. | ||
| 290 | if len(jr.RunsOn) > MaxLabels { | ||
| 291 | bad("jobs.%s: %d runs-on labels, limit %d", id, len(jr.RunsOn), MaxLabels) | ||
| 292 | } | ||
| 293 | seenLabels := map[string]bool{} | ||
| 294 | for _, raw := range jr.RunsOn { | ||
| 295 | label := strings.TrimSpace(raw) | ||
| 296 | if label == "" { | ||
| 297 | bad("jobs.%s.runs-on: an empty label names no runner", id) | ||
| 298 | continue | ||
| 299 | } | ||
| 300 | if err := ValidLabel(label); err != nil { | ||
| 301 | bad("jobs.%s.runs-on: %v", id, err) | ||
| 302 | continue | ||
| 303 | } | ||
| 304 | if seenLabels[label] { | ||
| 305 | continue // a repeated label asks for nothing more than one does | ||
| 306 | } | ||
| 307 | seenLabels[label] = true | ||
| 308 | job.Labels = append(job.Labels, label) | ||
| 309 | } | ||
| 236 | 310 | for i, sr := range jr.Steps { | |
| 237 | 311 | where := fmt.Sprintf("jobs.%s.steps[%d]", id, i) | |
| 238 | 312 | run := strings.TrimSpace(sr.Run) | |