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