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/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)