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