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/vole.git

Write over SSH with a registered key:

git clone ssh://git@rodent.software:2222/vole.git

A free and open-source forge that lives in your Git repo.

Require an image for every CI job

70b702649b875d841e294f5a085ca784d43e5bc4 · Keenan Nemetz · 16d ago · parent 7da9111e

Require an image for every CI job

Runners now require an OCI runtime, and each workflow job must name either a registry image or an image-file definition under .vole/images. Definitions are validated at push time, pinned with the run, built locally by the runner, and cached by definition digest. Records and the run page carry the image and definition digest.

Split the CI guide into workflows and runners, and add reference pages for workflow and image definition fields.

ci/agent/agent.go +69−67

@@ -2,10 +2,11 @@
2 2 //
3 3 // A runner connects to the instance over SSH with the key its account pushes
4 4 // with, leases one job at a time, and gives the job a disposable checkout. Steps
5 // run either on the host or in a container from the operator's image; the runner's
6 // key and trusted Git cache stay outside both. When the job ends the runner uploads
7 // the log and artifacts and publishes the attempt as a signed commit. This package
8 // is the agent; argument parsing and the CLI's messages stay in cmd/vole.
5 // run in a container built from the image the workflow names, or from a pinned
6 // .vole/images definition; the runner's key and trusted Git cache stay outside.
7 // When the job ends the runner uploads the log and artifacts and publishes the
8 // attempt as a signed commit. This package is the agent; argument parsing and the
9 // CLI's messages stay in cmd/vole.
9 10 package agent
10 11
11 12 import (
@@ -39,6 +40,10 @@ import (
39 40 // invisible in the queue's load.
40 41 const leaseWait = 25 * time.Second
41 42
43 // errLeaseLost means the queue took the job away while the runner was still
44 // preparing its environment. There is nothing to report in that case.
45 var errLeaseLost = errors.New("lease lost")
46
42 47 type runnerStep struct {
43 48 Name string `json:"name"`
44 49 Run string `json:"run"`
@@ -62,6 +67,12 @@ type runnerJob struct {
62 67 Workflow string `json:"workflow"`
63 68 Job string `json:"job"`
64 69 Steps []runnerStep `json:"steps"`
70 // Image is a registry reference or "file:<name>". For the latter, ImageDefinition
71 // is the pinned recipe and ImageSHA its digest; the runner builds it locally.
72 Image string `json:"image"`
73 ImageFile string `json:"image_file,omitempty"`
74 ImageDefinition string `json:"image_definition,omitempty"`
75 ImageSHA string `json:"image_sha,omitempty"`
65 76 // Artifacts are path patterns, expanded in the checkout after the steps pass.
66 77 Artifacts []string `json:"artifacts,omitempty"`
67 78 // Tag is set for a tag-triggered run; TagAnnotated says whether it carries
@@ -134,12 +145,8 @@ type Options struct {
134 145 Labels []string
135 146 // Once runs one job and exits, which is what a scheduled container wants.
136 147 Once bool
137 // Image runs each job's steps in a container built from this image. Empty runs
138 // them directly on the runner host. The image belongs to the machine, not to the
139 // workflow: a workflow asks for labels, and the operator decides what those
140 // labels mean.
141 Image string
142 148 // ContainerRuntime is the CLI used to run containers: docker, podman, or nerdctl.
149 // A runner always requires one; the image itself comes from each job's workflow.
143 150 ContainerRuntime string
144 151 // ContainerNetwork is the network mode: none (the default) or bridge.
145 152 ContainerNetwork string
@@ -221,11 +228,7 @@ func Run(ctx context.Context, opts Options) error {
221 228 sess.account, sess.name = who.Account, who.Name
222 229 sess.repos, sess.labels = opts.Repos, opts.Labels
223 230 fmt.Fprintf(os.Stderr, "vole runner: %s\n", sess.describe(who.Name))
224 if box.enabled() {
225 fmt.Fprintf(os.Stderr, "vole runner: jobs run in %s via %s\n", box.image, box.runtime)
226 } else {
227 fmt.Fprintln(os.Stderr, "vole runner: no --image given; jobs run directly on this host")
228 }
231 fmt.Fprintf(os.Stderr, "vole runner: jobs run in the workflow's image via %s\n", box.runtime)
229 232 for {
230 233 // A resident runner parks on the server's lease: the reply arrives when work is
231 234 // queued, not on a fixed beat, so an idle pool costs one open channel per
@@ -411,38 +414,45 @@ func (s *runnerSession) run(remote, cache string, job *runnerJob, lease time.Dur
411 414 return
412 415 }
413 416 defer co.cleanup()
414 // recordBox is set only after the container actually starts. A failed start is
415 // not evidence that the job ran in that image, so its record stays on the host.
416 recordBox := containerConfig{}
417 if job.Image == "" {
418 // A run queued before images were required has no environment to start.
419 // Fail it with a record rather than guessing at the host.
420 fmt.Fprintf(log, "\nthis run has no image; re-queue it from a workflow that declares image or image-file\n")
421 s.finish(remote, co, job, log, 1, nil, nil, box)
422 return
423 }
417 424 fail := func(format string, args ...any) {
418 425 msg := fmt.Sprintf(format, args...)
419 426 fmt.Fprintf(os.Stderr, "vole runner: %s\n", msg)
420 427 fmt.Fprintf(log, "\n%s\n", msg)
421 s.finish(remote, co, job, log, 1, nil, nil, recordBox)
428 s.finish(remote, co, job, log, 1, nil, nil, box)
422 429 }
423 430 fmt.Fprintf(log, "=== %s · %s · %s @ %s\n", job.Repo, job.Job, job.Branch, format.SHA(job.SHA))
424 if box.enabled() {
425 fmt.Fprintf(log, "environment: %s via %s\n", box.image, box.runtime)
426 }
431 fmt.Fprintf(log, "environment: %s via %s\n", job.Image, box.runtime)
427 432
428 var sandbox *container
429 if box.enabled() {
430 sandbox, err = startContainer(box, job, co.Work)
431 if err != nil {
432 fail("%s", err)
433 image, err := s.resolveImage(job, box, log, lease)
434 if err != nil {
435 if errors.Is(err, errLeaseLost) {
436 fmt.Fprintf(os.Stderr, "vole runner: lease lost while building %s; stopping\n", job.Image)
433 437 return
434 438 }
435 recordBox = box
436 defer func() {
437 if sandbox != nil {
438 sandbox.stop()
439 }
440 }()
439 fail("%s", err)
440 return
441 441 }
442 sandbox, err := startContainer(box, image, job, co.Work)
443 if err != nil {
444 fail("%s", err)
445 return
446 }
447 defer func() {
448 if sandbox != nil {
449 sandbox.stop()
450 }
451 }()
442 452
443 453 for _, step := range job.Steps {
444 454 fmt.Fprintf(log, "\n--- %s\n", step.Name)
445 code, chunk, lost := s.execStep(job.ID, co, sandbox, step, lease)
455 code, chunk, lost := s.execStep(job.ID, sandbox, step, lease)
446 456 log.WriteString(chunk)
447 457 if lost {
448 458 fmt.Fprintf(os.Stderr, "vole runner: lease lost while running %q; stopping\n", step.Name)
@@ -450,21 +460,17 @@ func (s *runnerSession) run(remote, cache string, job *runnerJob, lease time.Dur
450 460 }
451 461 if code != 0 {
452 462 fmt.Fprintf(log, "\nstep %q exited %d\n", step.Name, code)
453 if sandbox != nil {
454 sandbox.stop()
455 sandbox = nil
456 }
457 s.finish(remote, co, job, log, code, nil, nil, recordBox)
463 sandbox.stop()
464 sandbox = nil
465 s.finish(remote, co, job, log, code, nil, nil, box)
458 466 return
459 467 }
460 468 }
461 469 // The steps are done. Stop the container before reading artifacts or deploying a
462 470 // site, so a process the workflow left in the background cannot change the files
463 471 // while the runner is publishing them.
464 if sandbox != nil {
465 sandbox.stop()
466 sandbox = nil
467 }
472 sandbox.stop()
473 sandbox = nil
468 474 arts, releaseArts, err := publishArtifacts(remote, co.Work, job, log)
469 475 if err != nil {
470 476 fail("%s", err)
@@ -482,7 +488,7 @@ func (s *runnerSession) run(remote, cache string, job *runnerJob, lease time.Dur
482 488 fail("%s", err)
483 489 return
484 490 }
485 s.finish(remote, co, job, log, 0, arts, releaseArts, recordBox)
491 s.finish(remote, co, job, log, 0, arts, releaseArts, box)
486 492 }
487 493
488 494 // finish uploads this attempt's log, commits its record and pushes it, and only then
@@ -517,9 +523,8 @@ func (s *runnerSession) finish(remote string, co checkout, job *runnerJob, log *
517 523 // and a platform quirk for a fact the binary already knows.
518 524 OS: runtime.GOOS, Arch: runtime.GOARCH,
519 525 }
520 if box.enabled() {
521 rec.Image, rec.Runtime = box.image, box.runtime
522 }
526 rec.Image, rec.ImageFile, rec.ImageSHA = job.Image, job.ImageFile, job.ImageSHA
527 rec.Runtime = box.runtime
523 528 if oid != "" {
524 529 rec.Log = &lfs.Pointer{OID: oid, Size: size}
525 530 }
@@ -825,7 +830,7 @@ func (w *logBuffer) String() string {
825 830
826 831 // execStep runs one shell step, heartbeating while it works. It returns the exit
827 832 // code, the output produced, and whether the lease was lost.
828 func (s *runnerSession) execStep(runID string, co checkout, sandbox *container, step runnerStep, lease time.Duration) (int, string, bool) {
833 func (s *runnerSession) execStep(runID string, sandbox *container, step runnerStep, lease time.Duration) (int, string, bool) {
829 834 timeout := 10 * time.Minute
830 835 if d, err := time.ParseDuration(step.Timeout); err == nil && d > 0 {
831 836 timeout = d
@@ -833,16 +838,21 @@ func (s *runnerSession) execStep(runID string, co checkout, sandbox *container,
833 838 ctx, cancel := context.WithTimeout(context.Background(), timeout)
834 839 defer cancel()
835 840
836 var cmd *exec.Cmd
837 if sandbox != nil {
838 cmd = sandbox.execCommand(ctx, step)
839 cmd.Dir = co.Work
840 } else {
841 cmd = exec.CommandContext(ctx, "sh", "-c", step.Run)
842 cmd.Dir = co.Work
843 cmd.Env = append(os.Environ(), "CI=true", "VOLE_CI=1")
841 cmd := sandbox.execCommand(ctx, step)
842 code, chunk, lost := s.runCommand(ctx, cmd, runID, lease, sandbox.stop)
843 if ctx.Err() == context.DeadlineExceeded {
844 // The heartbeat will have extended the lease this far; report the timeout as
845 // a failure rather than leaving the job hanging.
846 return 1, chunk + fmt.Sprintf("\nstep timed out after %s\n", timeout), lost
844 847 }
845 // Own process group: a step that spawns children must not outlive its timeout.
848 return code, chunk, lost
849 }
850
851 // runCommand starts one host-side command and heartbeats while it runs. It is used
852 // for docker exec and for building a custom image; onTimeout is what closes the
853 // sandbox when the command runs out of time.
854 func (s *runnerSession) runCommand(ctx context.Context, cmd *exec.Cmd, runID string, lease time.Duration, onTimeout func()) (int, string, bool) {
855 // Own process group: a command that spawns children must not outlive its timeout.
846 856 cmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true}
847 857 w := &logBuffer{}
848 858 cmd.Stdout, cmd.Stderr = w, w
@@ -864,11 +874,6 @@ func (s *runnerSession) execStep(runID string, co checkout, sandbox *container,
864 874 select {
865 875 case err := <-done:
866 876 chunk := w.String()
867 if ctx.Err() == context.DeadlineExceeded {
868 // The heartbeat will have extended the lease this far; report the
869 // timeout as a failure rather than leaving the job hanging.
870 return 1, chunk + fmt.Sprintf("\nstep timed out after %s\n", timeout), false
871 }
872 877 if err == nil {
873 878 return 0, chunk, false
874 879 }
@@ -878,18 +883,15 @@ func (s *runnerSession) execStep(runID string, co checkout, sandbox *container,
878 883 }
879 884 return 1, chunk + fmt.Sprintf("\n%v\n", err), false
880 885 case <-ctx.Done():
881 // Killing the docker exec client is not enough: the process inside the
882 // container would keep running. Remove the whole container, which also
883 // makes the timeout the end of this job rather than one hung step.
884 if sandbox != nil {
885 sandbox.stop()
886 if onTimeout != nil {
887 onTimeout()
886 888 }
887 889 if cmd.Process != nil {
888 890 _ = syscall.Kill(-cmd.Process.Pid, syscall.SIGKILL)
889 891 }
890 892 // Wait for the reader goroutine before returning the output.
891 893 <-done
892 return 1, w.String() + fmt.Sprintf("\nstep timed out after %s\n", timeout), false
894 return 1, w.String(), false
893 895 case <-tick.C:
894 896 // The lease is the only thing a heartbeat carries.
895 897 rep, err := s.call(runnerRequest{Op: "heartbeat", Run: runID})