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.
Let a job declare the network it needs
d79580247b8f33f7ffdf2f05847733cad12c187a · Keenan Nemetz · 6d ago · parent 4ac42680
Let a job declare the network it needs A runner's --container-network decided the network for every job it took, so a machine that could reach the network gave it to builds that never asked, and a job that did need one could be handed to a machine that had none. The docs said "add bridge only when a job needs it", which no workflow could express. A job now says `network: none` or `network: bridge`. The request is pinned with the run, and the lease only hands a bridge job to a runner that advertises the capability, so a job waits for a capable machine instead of failing on one that never could. The runner applies the job's request to its container, which means a bridge runner can still withhold the network from a job that asked for none. A job that declares nothing keeps the runner's own setting, which is what every workflow meant before this field existed. The local run applies the same rule and refuses a bridge job when it was started without one, so `vole workflow run` either matches CI or says what to change on the command line. The workflows page, the run page, and the runner's log line now say what a job asked for and what it ran with. verify.yaml and release.yaml are deliberately not in this change: the instance validates a pushed workflow with the binary it is running, so they can declare the field once that binary knows it. Closes #159.
ci/agent/agent.go +26−3
| @@ -76,6 +76,10 @@ type runnerJob struct { | |||
| 76 | 76 | Trigger string `json:"trigger,omitempty"` | |
| 77 | 77 | TriggerAction string `json:"trigger_action,omitempty"` | |
| 78 | 78 | EventPayload string `json:"event_payload,omitempty"` | |
| 79 | // Network is the isolation the job asked for with `network:`, or empty when the | ||
| 80 | // workflow did not say. The runner applies it to the container it starts, so the | ||
| 81 | // job's request decides and the machine's default is what an unspecified job gets. | ||
| 82 | Network string `json:"network,omitempty"` | ||
| 79 | 83 | // Image is a registry reference or "file:<name>". For the latter, ImageDefinition | |
| 80 | 84 | // is the pinned recipe and ImageSHA its digest; the runner builds it locally. | |
| 81 | 85 | Image string `json:"image"` | |
| @@ -152,6 +156,10 @@ type runnerRequest struct { | |||
| 152 | 156 | // Labels are what this machine is, sent with every lease so the server can match | |
| 153 | 157 | // them against the runs-on a job asked for. | |
| 154 | 158 | Labels []string `json:"labels,omitempty"` | |
| 159 | // Network is the network this machine can give, sent with every lease so the | ||
| 160 | // server can match it against a job's `network:`. A runner started without | ||
| 161 | // --container-network bridge says none, which is the truth: it cannot provide one. | ||
| 162 | Network string `json:"network,omitempty"` | ||
| 155 | 163 | // Wait is how many seconds an idle lease may park before answering "nothing to | |
| 156 | 164 | // do"; the server caps it. It is what makes an idle runner cost a parked channel | |
| 157 | 165 | // rather than a query every couple of seconds. | |
| @@ -299,6 +307,7 @@ func Run(ctx context.Context, opts Options) error { | |||
| 299 | 307 | } | |
| 300 | 308 | sess.name = who.Name | |
| 301 | 309 | sess.repos, sess.labels = opts.Repos, opts.Labels | |
| 310 | sess.network = box.Network | ||
| 302 | 311 | sess.cacheRoot, sess.cacheMax, sess.cacheReuse = cacheRoot, cacheMax, !opts.NoCache | |
| 303 | 312 | if opts.NoCache { | |
| 304 | 313 | fmt.Fprintf(os.Stderr, "vole runner: job cache in %s, emptied after every job\n", cacheRoot) | |
| @@ -318,7 +327,7 @@ func Run(ctx context.Context, opts Options) error { | |||
| 318 | 327 | // queued, not on a fixed beat, so an idle pool costs one open channel per | |
| 319 | 328 | // runner instead of a query every couple of seconds. A one-shot runner asks | |
| 320 | 329 | // once and leaves. | |
| 321 | req := runnerRequest{Op: "lease", Repos: sess.repos, Labels: sess.labels} | ||
| 330 | req := runnerRequest{Op: "lease", Repos: sess.repos, Labels: sess.labels, Network: sess.network} | ||
| 322 | 331 | if !opts.Once { | |
| 323 | 332 | req.Wait = int(leaseWait.Seconds()) | |
| 324 | 333 | } | |
| @@ -392,6 +401,9 @@ type runnerSession struct { | |||
| 392 | 401 | // carries every label the job asked for, so an unlabelled runner only sees jobs | |
| 393 | 402 | // that ask for nothing. | |
| 394 | 403 | labels []string | |
| 404 | // network is the network this runner can give, sent with every lease so the server | ||
| 405 | // only hands it a job whose `network:` it can satisfy. | ||
| 406 | network string | ||
| 395 | 407 | } | |
| 396 | 408 | ||
| 397 | 409 | // describe is the one line a runner prints when it starts work, because "why is | |
| @@ -406,6 +418,9 @@ func (s *runnerSession) describe(name string) string { | |||
| 406 | 418 | if len(s.repos) > 0 { | |
| 407 | 419 | out += ", only in " + strings.Join(s.repos, ", ") | |
| 408 | 420 | } | |
| 421 | if s.network == ci.NetworkBridge { | ||
| 422 | out += ", able to give a job the network" | ||
| 423 | } | ||
| 409 | 424 | return out + "; waiting for work" | |
| 410 | 425 | } | |
| 411 | 426 | ||
| @@ -538,7 +553,15 @@ func (s *runnerSession) run(remote, cache string, job *runnerJob, lease time.Dur | |||
| 538 | 553 | s.finish(remote, co, job, log, 1, nil, nil, nil, box) | |
| 539 | 554 | } | |
| 540 | 555 | fmt.Fprintf(log, "=== %s · %s · %s @ %s\n", job.Repo, job.Job, job.Branch, format.SHA(job.SHA)) | |
| 541 | fmt.Fprintf(log, "environment: %s via %s\n", job.Image, box.Runtime) | ||
| 556 | // The job's network request is resolved before anything runs, so a runner that was | ||
| 557 | // handed a job it cannot isolate refuses it instead of leaking the network into a | ||
| 558 | // job that asked for none, or starving a job that asked for one. | ||
| 559 | boxConfig, err := box.networkFor(job) | ||
| 560 | if err != nil { | ||
| 561 | fail("%s", err) | ||
| 562 | return | ||
| 563 | } | ||
| 564 | fmt.Fprintf(log, "environment: %s via %s, network %s\n", job.Image, box.Runtime, boxConfig.Network) | ||
| 542 | 565 | // A declared secret the repository does not have is not a failure: the variable | |
| 543 | 566 | // is left unset and the log says which one, because a workflow is allowed an | |
| 544 | 567 | // optional secret and a required one is ${NAME:?} in the step. | |
| @@ -618,7 +641,7 @@ func (s *runnerSession) run(remote, cache string, job *runnerJob, lease time.Dur | |||
| 618 | 641 | } | |
| 619 | 642 | }() | |
| 620 | 643 | } | |
| 621 | sandbox, err := container.Start(box.Config, container.Spec{ | ||
| 644 | sandbox, err := container.Start(boxConfig, container.Spec{ | ||
| 622 | 645 | Image: image, | |
| 623 | 646 | Name: containerName(box, job), | |
| 624 | 647 | Work: co.Work, | |