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.
Add packaging, a CI workflow, and containerised local runs
3b39a84c913cabe15bfa6a60dd176ff2dd28d241 · Keenan Nemetz · 14d ago · parent 30a72490
Add packaging, a CI workflow, and containerised local runs A v* tag now builds a .deb and a .rpm for amd64 and arm64 with nfpm and attaches them, with checksums, to the release. The package ships the systemd unit, a config at /etc/vole/vole.toml, and a postinstall that creates the service account and prepares the instance that config names; `vole admin init --config` is what makes that possible, and the binary's version is stamped from the tag instead of the source tree. scripts/ci.sh becomes .vole/workflows/ci.yaml, so `vole workflow run ci` executes exactly what a push runs, and the Markdown link check moves to scripts/check-links.py. Local runs now always execute in the job image and share ci/container with the runner; --no-container is gone, because a run in a different environment proves nothing about CI. The runner mounts a per-repository namespace at /cache and sets no variable for it: a job opts in through its image's env or the new job-level env, which is pinned with the queue and delivered with the lease. Whole namespaces are evicted least-recently-written first so a module cache is never half-deleted, and contributor branches get their own namespace so an untrusted tip cannot seed the cache a protected branch builds into. Running the suite in that container found four bugs worth naming: /tmp was mounted noexec, which broke `go test` and `go run`; the read-only root is gone, because images make toolchain directories world-writable deliberately and the mount set is what protects the host; OpenSSH needs a passwd entry for the job's uid; and a lost metadata push reported as "cannot lock ref" was not classified as a race, so concurrent CLI writes gave up on the first collision. That retry now backs off exponentially with jitter.
ci/container/container.go +309
| @@ -0,0 +1,309 @@ | |||
| 1 | // Package container runs a job's steps in the OCI container its image names. | ||
| 2 | // | ||
| 3 | // Two callers share it. The runner uses it as the boundary between untrusted | ||
| 4 | // repository code and the machine that executes it: one bind mount, no | ||
| 5 | // capabilities, no new privileges, and no network unless the operator asked for | ||
| 6 | // it. `vole workflow run` uses it so a local run is the run CI performs — same | ||
| 7 | // image, same environment, same paths — rather than whatever happens to be | ||
| 8 | // installed on the developer's laptop. | ||
| 9 | // | ||
| 10 | // What protects the host is the mount set and the credential boundary, not the | ||
| 11 | // writability of the container's own filesystem: the task usually runs as an | ||
| 12 | // unprivileged uid, and everything outside the image and the job tree simply is | ||
| 13 | // not reachable. The container's filesystem is left writable because images set up | ||
| 14 | // world-writable toolchain directories (Go's /go is one) for exactly this case, and | ||
| 15 | // because the whole layer is discarded with the container. It is immutable in the | ||
| 16 | // one configuration where that matters — a non-root job cannot write the parts of | ||
| 17 | // the image that DAC protects. RunsAsRoot reports the exception. | ||
| 18 | // | ||
| 19 | // The flag list below is that boundary in one place. Keeping it here is what stops | ||
| 20 | // a local run and a pushed run from quietly disagreeing about how a step executes. | ||
| 21 | package container | ||
| 22 | |||
| 23 | import ( | ||
| 24 | "context" | ||
| 25 | "fmt" | ||
| 26 | "io" | ||
| 27 | "os" | ||
| 28 | "os/exec" | ||
| 29 | "path/filepath" | ||
| 30 | "sort" | ||
| 31 | "strconv" | ||
| 32 | "strings" | ||
| 33 | "time" | ||
| 34 | |||
| 35 | "github.com/rodent-software/vole/ci" | ||
| 36 | ) | ||
| 37 | |||
| 38 | // KeepAlive is PID 1's command. Steps run with exec, so the container has to stay | ||
| 39 | // up between them. `/bin/sh` is also what the steps need, which keeps the | ||
| 40 | // requirement to one the image must satisfy anyway. | ||
| 41 | const KeepAlive = `trap 'exit 0' TERM INT; while :; do sleep 3600; done` | ||
| 42 | |||
| 43 | // Config is the runtime and isolation settings for one container. It is validated | ||
| 44 | // once, before any work starts, so a missing runtime or an invalid option is an | ||
| 45 | // error at startup rather than a job that fails mysteriously later. | ||
| 46 | type Config struct { | ||
| 47 | Runtime string | ||
| 48 | Network string | ||
| 49 | Pull string | ||
| 50 | User string | ||
| 51 | Userns string | ||
| 52 | Memory string | ||
| 53 | CPUs string | ||
| 54 | PIDs int | ||
| 55 | } | ||
| 56 | |||
| 57 | // Validate fills in the defaults and refuses settings the runtime would reject. | ||
| 58 | func Validate(cfg Config) (Config, error) { | ||
| 59 | if cfg.Runtime == "" { | ||
| 60 | cfg.Runtime = "docker" | ||
| 61 | } | ||
| 62 | switch cfg.Runtime { | ||
| 63 | case "docker", "podman", "nerdctl": | ||
| 64 | default: | ||
| 65 | return Config{}, fmt.Errorf("--container-runtime %q is not supported; use docker, podman, or nerdctl", cfg.Runtime) | ||
| 66 | } | ||
| 67 | if _, err := exec.LookPath(cfg.Runtime); err != nil { | ||
| 68 | return Config{}, fmt.Errorf("--container-runtime %s is not on PATH: %w", cfg.Runtime, err) | ||
| 69 | } | ||
| 70 | if cfg.Network == "" { | ||
| 71 | cfg.Network = "none" | ||
| 72 | } | ||
| 73 | switch cfg.Network { | ||
| 74 | case "none", "bridge": | ||
| 75 | default: | ||
| 76 | return Config{}, fmt.Errorf("--container-network %q is not supported; use none or bridge", cfg.Network) | ||
| 77 | } | ||
| 78 | if cfg.Pull == "" { | ||
| 79 | cfg.Pull = "missing" | ||
| 80 | } | ||
| 81 | switch cfg.Pull { | ||
| 82 | case "missing", "always", "never": | ||
| 83 | default: | ||
| 84 | return Config{}, fmt.Errorf("--container-pull %q is not supported; use missing, always, or never", cfg.Pull) | ||
| 85 | } | ||
| 86 | defaultUser := fmt.Sprintf("%d:%d", os.Getuid(), os.Getgid()) | ||
| 87 | if cfg.User == "" { | ||
| 88 | cfg.User = defaultUser | ||
| 89 | } | ||
| 90 | if strings.ContainsAny(cfg.User, " \t\r\n") { | ||
| 91 | return Config{}, fmt.Errorf("--container-user %q is not a user", cfg.User) | ||
| 92 | } | ||
| 93 | if strings.ContainsAny(cfg.Userns, " \t\r\n") { | ||
| 94 | return Config{}, fmt.Errorf("--container-userns %q is not a user namespace mode", cfg.Userns) | ||
| 95 | } | ||
| 96 | if cfg.Userns == "" && cfg.Runtime == "podman" && os.Geteuid() != 0 && cfg.User == defaultUser { | ||
| 97 | // Rootless Podman maps container root to the caller but maps other UIDs | ||
| 98 | // through subuids. keep-id makes the numeric --container-user line up with | ||
| 99 | // the host user, so the bind-mounted job tree is writable. A caller that | ||
| 100 | // intentionally overrides --container-user may need a different mode. | ||
| 101 | cfg.Userns = "keep-id" | ||
| 102 | } | ||
| 103 | if cfg.PIDs < 0 { | ||
| 104 | return Config{}, fmt.Errorf("--container-pids must not be negative") | ||
| 105 | } | ||
| 106 | return cfg, nil | ||
| 107 | } | ||
| 108 | |||
| 109 | // RunsAsRoot reports whether the task will run as uid 0 in its container. That is | ||
| 110 | // the one case where the image's own files are fully writable for the length of the | ||
| 111 | // job, so a caller should say so: an ordinary user is protected by the image's | ||
| 112 | // permissions, root is not. | ||
| 113 | func RunsAsRoot(cfg Config) bool { | ||
| 114 | user := cfg.User | ||
| 115 | if i := strings.IndexByte(user, ':'); i >= 0 { | ||
| 116 | user = user[:i] | ||
| 117 | } | ||
| 118 | return user == "0" || user == "root" | ||
| 119 | } | ||
| 120 | |||
| 121 | // Mount is one extra host directory made visible in the container. It exists for | ||
| 122 | // the cache, which is a property of the runner rather than of the job: the job sees | ||
| 123 | // exactly one directory and cannot reach the namespaces around it. | ||
| 124 | type Mount struct { | ||
| 125 | Host string | ||
| 126 | Path string | ||
| 127 | } | ||
| 128 | |||
| 129 | // Spec is everything about one container that is not a runtime setting. | ||
| 130 | type Spec struct { | ||
| 131 | Image string | ||
| 132 | Name string | ||
| 133 | Work string // the job tree, bind-mounted read-write at /workspace | ||
| 134 | Labels map[string]string | ||
| 135 | // Mounts are extra directories to make visible. They are read-write and owned | ||
| 136 | // by the runner's user, so a job can fill one; the caller that creates them is | ||
| 137 | // the caller that has to bound them. | ||
| 138 | Mounts []Mount | ||
| 139 | // Env is the job's environment on top of the fixed set below. Names here cannot | ||
| 140 | // collide with the reserves: the workflow validator refuses those. | ||
| 141 | Env map[string]string | ||
| 142 | } | ||
| 143 | |||
| 144 | // RunArgs is the argument list for `docker run`. It is pure so it can be checked | ||
| 145 | // without a daemon. | ||
| 146 | func RunArgs(cfg Config, s Spec) []string { | ||
| 147 | args := []string{ | ||
| 148 | "run", "-d", "--rm", | ||
| 149 | "--name", s.Name, | ||
| 150 | } | ||
| 151 | keys := make([]string, 0, len(s.Labels)) | ||
| 152 | for k := range s.Labels { | ||
| 153 | keys = append(keys, k) | ||
| 154 | } | ||
| 155 | sort.Strings(keys) | ||
| 156 | for _, k := range keys { | ||
| 157 | args = append(args, "--label", k+"="+s.Labels[k]) | ||
| 158 | } | ||
| 159 | args = append(args, | ||
| 160 | "--workdir", "/workspace", | ||
| 161 | "--user", cfg.User, | ||
| 162 | ) | ||
| 163 | if cfg.Userns != "" { | ||
| 164 | args = append(args, "--userns", cfg.Userns) | ||
| 165 | } | ||
| 166 | for _, kv := range []string{ | ||
| 167 | "CI=true", | ||
| 168 | "VOLE_CI=1", | ||
| 169 | "GIT_TERMINAL_PROMPT=0", | ||
| 170 | "HOME=/tmp", | ||
| 171 | "TMPDIR=/tmp", | ||
| 172 | } { | ||
| 173 | args = append(args, "--env", kv) | ||
| 174 | } | ||
| 175 | envNames := make([]string, 0, len(s.Env)) | ||
| 176 | for name := range s.Env { | ||
| 177 | envNames = append(envNames, name) | ||
| 178 | } | ||
| 179 | sort.Strings(envNames) | ||
| 180 | for _, name := range envNames { | ||
| 181 | args = append(args, "--env", name+"="+s.Env[name]) | ||
| 182 | } | ||
| 183 | args = append(args, "--volume", s.Work+":/workspace:rw") | ||
| 184 | for _, m := range s.Mounts { | ||
| 185 | args = append(args, "--volume", m.Host+":"+m.Path+":rw") | ||
| 186 | } | ||
| 187 | args = append(args, | ||
| 188 | // exec is explicit because the runtime mounts a tmpfs noexec by default. | ||
| 189 | // Compilers write their output here and then run it: `go test` and | ||
| 190 | // `go run` both exec from GOCACHE under /tmp, so noexec breaks every Go | ||
| 191 | // workflow. It costs no isolation a job does not already have, since the | ||
| 192 | // workspace mount is writable and executable too. | ||
| 193 | "--tmpfs", "/tmp:rw,nosuid,nodev,exec,mode=1777", | ||
| 194 | "--cap-drop", "ALL", | ||
| 195 | "--security-opt", "no-new-privileges", | ||
| 196 | "--init", | ||
| 197 | "--stop-timeout", "5", | ||
| 198 | "--network", cfg.Network, | ||
| 199 | "--pull", cfg.Pull, | ||
| 200 | ) | ||
| 201 | if cfg.PIDs > 0 { | ||
| 202 | args = append(args, "--pids-limit", strconv.Itoa(cfg.PIDs)) | ||
| 203 | } | ||
| 204 | if cfg.Memory != "" { | ||
| 205 | args = append(args, "--memory", cfg.Memory) | ||
| 206 | } | ||
| 207 | if cfg.CPUs != "" { | ||
| 208 | args = append(args, "--cpus", cfg.CPUs) | ||
| 209 | } | ||
| 210 | return append(args, | ||
| 211 | "--entrypoint", "/bin/sh", | ||
| 212 | s.Image, | ||
| 213 | "-c", KeepAlive, | ||
| 214 | ) | ||
| 215 | } | ||
| 216 | |||
| 217 | // Container is one running container. | ||
| 218 | type Container struct { | ||
| 219 | cfg Config | ||
| 220 | name string | ||
| 221 | work string | ||
| 222 | } | ||
| 223 | |||
| 224 | // Start creates the container and waits for it to be running. A runtime that | ||
| 225 | // cannot start it is reported with the runtime's own message: a pull failure and a | ||
| 226 | // bad flag look the same from here, and the message is what tells them apart. | ||
| 227 | func Start(cfg Config, s Spec) (*Container, error) { | ||
| 228 | c := &Container{cfg: cfg, name: s.Name, work: s.Work} | ||
| 229 | cmd := exec.Command(cfg.Runtime, RunArgs(cfg, s)...) | ||
| 230 | var stderr strings.Builder | ||
| 231 | cmd.Stderr = &stderr | ||
| 232 | if err := cmd.Run(); err != nil { | ||
| 233 | _ = exec.Command(cfg.Runtime, "rm", "-f", s.Name).Run() | ||
| 234 | return nil, fmt.Errorf("%s could not start %s: %w: %s", | ||
| 235 | cfg.Runtime, s.Image, err, strings.TrimSpace(stderr.String())) | ||
| 236 | } | ||
| 237 | return c, nil | ||
| 238 | } | ||
| 239 | |||
| 240 | // Exec runs one step in the existing container. Each step is a separate exec, | ||
| 241 | // which is what gives it its own log, timeout and exit status while keeping the | ||
| 242 | // filesystem and background processes shared with the steps around it. | ||
| 243 | func (c *Container) Exec(ctx context.Context, script string) *exec.Cmd { | ||
| 244 | cmd := exec.CommandContext(ctx, c.cfg.Runtime, "exec", | ||
| 245 | "-w", "/workspace", c.name, "/bin/sh", "-c", script) | ||
| 246 | // The runtime ignores the client's working directory, but keeping it at the job | ||
| 247 | // tree lets a test runtime execute the step in the same place without pretending | ||
| 248 | // to be a container. | ||
| 249 | cmd.Dir = c.work | ||
| 250 | return cmd | ||
| 251 | } | ||
| 252 | |||
| 253 | // Stop kills and removes the container. `rm -f` is one command that also handles a | ||
| 254 | // container that has already exited. | ||
| 255 | func (c *Container) Stop() { | ||
| 256 | ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second) | ||
| 257 | defer cancel() | ||
| 258 | _ = exec.CommandContext(ctx, c.cfg.Runtime, "rm", "-f", c.name).Run() | ||
| 259 | } | ||
| 260 | |||
| 261 | // ImageTag names the image built from a definition. The digest is what the image | ||
| 262 | // means, so a changed definition builds a different tag and an unchanged one is | ||
| 263 | // reused across jobs and across runs. | ||
| 264 | func ImageTag(imageFile, digest string) string { | ||
| 265 | sha := digest | ||
| 266 | if len(sha) > 16 { | ||
| 267 | sha = sha[:16] | ||
| 268 | } | ||
| 269 | return "vole/image-file:" + imageFile + "-" + sha | ||
| 270 | } | ||
| 271 | |||
| 272 | // Exists reports whether an image is present locally. | ||
| 273 | func Exists(runtime, tag string) bool { | ||
| 274 | return exec.Command(runtime, "image", "inspect", tag).Run() == nil | ||
| 275 | } | ||
| 276 | |||
| 277 | // Build renders an image definition and builds it. It is for callers with nothing | ||
| 278 | // else to keep alive; a runner builds through its own command loop so a long build | ||
| 279 | // keeps its lease. | ||
| 280 | func Build(ctx context.Context, cfg Config, definition, tag string, out io.Writer) error { | ||
| 281 | dockerfile, err := ci.RenderImage(definition) | ||
| 282 | if err != nil { | ||
| 283 | return err | ||
| 284 | } | ||
| 285 | dir, err := os.MkdirTemp("", "vole-image-*") | ||
| 286 | if err != nil { | ||
| 287 | return err | ||
| 288 | } | ||
| 289 | defer os.RemoveAll(dir) | ||
| 290 | if err := os.WriteFile(filepath.Join(dir, "Dockerfile"), []byte(dockerfile), 0o600); err != nil { | ||
| 291 | return err | ||
| 292 | } | ||
| 293 | args := []string{"build", "-t", tag, "-f", filepath.Join(dir, "Dockerfile"), dir} | ||
| 294 | switch { | ||
| 295 | case cfg.Pull == "always" && cfg.Runtime == "podman": | ||
| 296 | args = append(args, "--pull=always") | ||
| 297 | case cfg.Pull == "always": | ||
| 298 | args = append(args, "--pull") | ||
| 299 | } | ||
| 300 | cmd := exec.CommandContext(ctx, cfg.Runtime, args...) | ||
| 301 | cmd.Stdout, cmd.Stderr = out, out | ||
| 302 | if err := cmd.Run(); err != nil { | ||
| 303 | return fmt.Errorf("building image %s: %w", tag, err) | ||
| 304 | } | ||
| 305 | if !Exists(cfg.Runtime, tag) { | ||
| 306 | return fmt.Errorf("building image %s produced no image", tag) | ||
| 307 | } | ||
| 308 | return nil | ||
| 309 | } | ||