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