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/sandbox.go +22−41

@@ -4,13 +4,13 @@ package agent
4 4 //
5 5 // The runner process stays on the host: it holds the SSH key and the trusted Git
6 6 // cache, leases work, publishes the record, and uploads artifacts. The workflow
7 // steps run in a container started from the image the operator chose with --image.
8 // One container per job is the isolation boundary: a job cannot see the runner's
9 // key, and the next job starts with a clean container and a fresh job tree.
7 // steps run in a container built from the image the job names. One container per
8 // job is the isolation boundary: a job cannot see the runner's key, and the next
9 // job starts with a clean container and a fresh job tree.
10 10 //
11 // The image is deliberately a runner option rather than a workflow field. The
12 // workflow asks for a machine with runs-on; the operator decides what that machine
13 // runs, and the workflow schema does not grow a package manager.
11 // The image is part of the workflow, so a job carries the environment it needs.
12 // A custom .vole/images definition is built by the runner before the container
13 // starts; a registry reference is used as written.
14 14
15 15 import (
16 16 "context"
@@ -32,7 +32,6 @@ const keepAlive = `trap 'exit 0' TERM INT; while :; do sleep 3600; done`
32 32
33 33 type containerConfig struct {
34 34 runtime string
35 image string
36 35 network string
37 36 pull string
38 37 user string
@@ -48,29 +47,13 @@ type containerConfig struct {
48 47 pid int
49 48 }
50 49
51 func (c containerConfig) enabled() bool { return c.image != "" }
52
53 50 // containerOptions validates the runner's container settings and fills in the
54 // defaults. It runs before the runner connects, so a bad image reference or a
55 // missing runtime is a startup error rather than a job that fails mysteriously
56 // after it has been leased.
51 // defaults. It runs before the runner connects, so a missing runtime or an invalid
52 // option is a startup error rather than a job that fails mysteriously after it has
53 // been leased.
57 54 func containerOptions(opts Options, cache string) (containerConfig, error) {
58 if strings.TrimSpace(opts.Image) == "" {
59 if strings.TrimSpace(opts.ContainerRuntime) != "" ||
60 strings.TrimSpace(opts.ContainerNetwork) != "" ||
61 strings.TrimSpace(opts.ContainerPull) != "" ||
62 strings.TrimSpace(opts.ContainerUser) != "" ||
63 strings.TrimSpace(opts.ContainerUserns) != "" ||
64 strings.TrimSpace(opts.ContainerMemory) != "" ||
65 strings.TrimSpace(opts.ContainerCPUs) != "" ||
66 opts.ContainerPIDs != 0 {
67 return containerConfig{}, fmt.Errorf("container options need --image")
68 }
69 return containerConfig{}, nil
70 }
71 55 cfg := containerConfig{
72 56 runtime: strings.TrimSpace(opts.ContainerRuntime),
73 image: strings.TrimSpace(opts.Image),
74 57 network: strings.TrimSpace(opts.ContainerNetwork),
75 58 pull: strings.TrimSpace(opts.ContainerPull),
76 59 user: strings.TrimSpace(opts.ContainerUser),
@@ -90,11 +73,6 @@ func containerOptions(opts Options, cache string) (containerConfig, error) {
90 73 if _, err := exec.LookPath(cfg.runtime); err != nil {
91 74 return containerConfig{}, fmt.Errorf("--container-runtime %s is not on PATH: %w", cfg.runtime, err)
92 75 }
93 // The image is passed as its own argument, never through a shell, but a leading
94 // dash would still be read as an option by the container CLI.
95 if strings.HasPrefix(cfg.image, "-") || strings.ContainsAny(cfg.image, " \t\r\n") {
96 return containerConfig{}, fmt.Errorf("--image %q is not an image reference", cfg.image)
97 }
98 76 if cfg.network == "" {
99 77 cfg.network = "none"
100 78 }
@@ -143,9 +121,6 @@ func containerOptions(opts Options, cache string) (containerConfig, error) {
143 121 // runner's cache. Normally the executor removes its own container, but a killed
144 122 // runner leaves the keepalive process behind.
145 123 func cleanupStaleContainers(cfg containerConfig) {
146 if !cfg.enabled() {
147 return
148 }
149 124 out, err := exec.Command(cfg.runtime, "ps", "-a",
150 125 "--filter", "label=vole.runner.id="+cfg.id,
151 126 "--format", `{{.ID}} {{.Label "vole.runner.pid"}}`).Output()
@@ -174,7 +149,7 @@ func cleanupStaleContainers(cfg containerConfig) {
174 149 // containerRunArgs is pure so the argument list can be checked without a daemon.
175 150 // The defaults are deliberately closed: no host mounts beyond the job tree, no
176 151 // capabilities, no writable root, and no network unless the operator asked for it.
177 func containerRunArgs(cfg containerConfig, name, work, runID string) []string {
152 func containerRunArgs(cfg containerConfig, image, name, work, runID string) []string {
178 153 args := []string{
179 154 "run", "-d", "--rm",
180 155 "--name", name,
@@ -215,7 +190,7 @@ func containerRunArgs(cfg containerConfig, name, work, runID string) []string {
215 190 }
216 191 return append(args,
217 192 "--entrypoint", "/bin/sh",
218 cfg.image,
193 image,
219 194 "-c", keepAlive,
220 195 )
221 196 }
@@ -228,18 +203,19 @@ func processAlive(pid int) bool {
228 203 type container struct {
229 204 cfg containerConfig
230 205 name string
206 work string
231 207 }
232 208
233 func startContainer(cfg containerConfig, job *runnerJob, work string) (*container, error) {
209 func startContainer(cfg containerConfig, image string, job *runnerJob, work string) (*container, error) {
234 210 name := fmt.Sprintf("vole-%s-%s-%d", cfg.id, strings.ToLower(job.ID), job.Attempt)
235 c := &container{cfg: cfg, name: name}
236 cmd := exec.Command(cfg.runtime, containerRunArgs(cfg, name, work, job.ID)...)
211 c := &container{cfg: cfg, name: name, work: work}
212 cmd := exec.Command(cfg.runtime, containerRunArgs(cfg, image, name, work, job.ID)...)
237 213 var stderr strings.Builder
238 214 cmd.Stderr = &stderr
239 215 if err := cmd.Run(); err != nil {
240 216 _ = exec.Command(cfg.runtime, "rm", "-f", name).Run()
241 217 return nil, fmt.Errorf("%s could not start %s: %w: %s",
242 cfg.runtime, cfg.image, err, strings.TrimSpace(stderr.String()))
218 cfg.runtime, image, err, strings.TrimSpace(stderr.String()))
243 219 }
244 220 return c, nil
245 221 }
@@ -248,8 +224,13 @@ func startContainer(cfg containerConfig, job *runnerJob, work string) (*containe
248 224 // exec, which is what gives it its own log, timeout and exit status while keeping
249 225 // the filesystem and background processes shared with the steps around it.
250 226 func (c *container) execCommand(ctx context.Context, step runnerStep) *exec.Cmd {
251 return exec.CommandContext(ctx, c.cfg.runtime, "exec",
227 cmd := exec.CommandContext(ctx, c.cfg.runtime, "exec",
252 228 "-w", "/workspace", c.name, "/bin/sh", "-c", step.Run)
229 // The runtime ignores the client's working directory, but keeping it at the job
230 // tree lets a test runtime execute the step in the same place without pretending
231 // to be a container.
232 cmd.Dir = c.work
233 return cmd
253 234 }
254 235
255 236 // stop kills and removes the container. `rm -f` is one command that also handles a