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.

Code

395 commit(s) on main

update readme Keenan Nemetz · 56m ago 32290114
history blame raw 11.6 KiB
1package main
2
3import (
4 "context"
5 "fmt"
6 "os"
7 "os/signal"
8 "strconv"
9 "strings"
10 "syscall"
11
12 "rodent.software/vole/ci/agent"
13 jobcache "rodent.software/vole/ci/cache"
14 "rodent.software/vole/client"
15)
16
17func runnerUsage() string {
18 return `usage: vole runner --remote ssh://git@host[:port] [--once] [--jobs n] [--dir path]
19 [--repos name]... [--label name]...
20 [--container-runtime docker|podman|nerdctl]
21 [--container-network none|bridge] [--container-pull missing|always|never]
22 [--container-user uid:gid] [--container-userns mode]
23 [--container-memory size] [--container-cpus n] [--container-pids n]
24 [--cache-size size] [--git-cache-size size] [--no-cache]
25 [--known-hosts path | --insecure-host-key]
26
27Leases CI jobs from a Vole instance and runs them. Authentication is the SSH key
28you would push with, so there is no token to configure. A runner may only run jobs
29for repositories whose runner allowlist its account is on (admins are implicit):
30running a job executes that repository's code, which is a bigger power than
31writing to it.
32
33Every finished attempt commits a record to the repository's metadata branch,
34together with the artifacts the build published, in one push: the runner is the
35party that did the work, so the runner is the party that states the result. The
36push authenticates with the same key, and pre-receive checks the allowlist.
37
38 --once run one job, then exit (useful for a container that is scheduled per job)
39 --jobs how many jobs to run at once (default 1). Each job gets a session of its
40 own, and the container limits below apply to each one, so a machine
41 running n jobs needs room for n of them. With --once, every job started
42 this way is finished before the runner exits.
43 --dir where to keep checkouts (default: os.UserCacheDir()/vole-runner)
44 --repos only take jobs for these repositories (repeatable). It narrows what the
45 account's runner allowlist grants; it can never add to it. Without it a
46 runner takes any repository it is allowed to run.
47 --label what this machine is, e.g. linux or arm64 (repeatable). A workflow asks
48 for a machine with runs-on, and a job is only given to a runner carrying
49 every label it asks for. Without labels a runner only takes jobs that ask
50 for nothing, which is what every job did before runs-on existed.
51
52Host key:
53 A runner executes repository code, so the instance it trusts is a security
54 boundary. The SSH host key is verified against your known_hosts, and an unknown
55 or changed host is refused. Populate the file with:
56 ssh-keyscan -p <port> <host> >> ~/.ssh/known_hosts
57
58 --known-hosts a known_hosts file to check against instead of the default
59 --insecure-host-key turn verification off. For tests and throwaway instances
60 only: it accepts any host that answers.
61
62Container execution:
63 A runner always needs an OCI runtime. Each job names its image in the workflow,
64 and the runner starts one container per job and removes it afterward. The runner's
65 key and trusted checkout stay outside the container. The image must have /bin/sh
66 and a POSIX sleep, because Vole runs shell steps and keeps the container alive
67 between them. A workflow image-file definition is built locally before the job
68 starts and cached by definition digest; the image build uses the runtime's own
69 network, not --container-network.
70
71 --container-runtime docker (default), podman, or nerdctl.
72 The runtime is checked at startup rather than on the first job: the runner runs
73 "<runtime> info" once and refuses to serve when that fails, because a binary on
74 PATH is not a runtime that can create a container. The error is the runtime's own
75 last line, so an unwritable run root or a missing OCI runtime reads as what it is
76 instead of as a repository whose build failed. vole workflow run checks the same
77 way before a local run.
78 --container-network none (default) or bridge: what this machine can give a job.
79 A job's own network: decides what it gets, so a bridge runner can still
80 run a job that asks for none, and a job that asks for bridge waits for a
81 runner that can provide one. The image is expected to contain its
82 dependencies.
83 --container-pull missing (default), always, or never.
84 --container-user uid:gid the steps run as (default: this user).
85 --container-userns user namespace mode passed to the runtime. Defaults to
86 keep-id for rootless Podman so the job tree is writable by this user.
87 --container-memory, --container-cpus, --container-pids
88 resource limits passed to the runtime. --container-pids defaults to 2048
89 and cannot be removed: a job that can fork without a bound is a fork bomb
90 away from taking the machine.
91 --container-tmpfs size of the job's /tmp (default 1G). It is a tmpfs, so it is
92 memory: an unsized one lets a build fill the machine's RAM without writing
93 to the workspace. Raise it for a build that writes large temporary files.
94
95Job cache:
96 Each job gets a writable directory at /cache, kept under --dir/cache and reused
97 by later jobs of the same repository and image. The runner mounts it and sets no
98 variables: a job opts in by pointing its own at /cache, in the image's env: (for
99 example GOCACHE=/cache/go-build) or in the step. Namespaces are per repository,
100 and contributor branches get their own, so one branch cannot seed another's
101 cache.
102
103 --cache-size cap for the cache (default 5G). Whole namespaces are evicted,
104 least recently written first, so a cache is never left half-deleted.
105 --no-cache mount the same directory but empty it after every job.
106
107Saved checkouts:
108 The first job for a repository clones it once into --dir and keeps that clone, so
109 a later job clones from disk and only commits it does not have cross the network.
110 The clone is never mounted into a job: the workflow's checkout is a copy with its
111 own .git, so a hook or a changed config dies with the job.
112
113 --git-cache-size cap for the saved checkouts (default 10G). A checkout that is
114 most of the cap is repacked first, which drops history no branch or tag
115 can reach any more and costs nothing to re-download; when the cap is
116 still reached, the least recently used repository's checkout is deleted
117 and its next job downloads it again. 0 keeps every saved checkout.
118 --disk-reserve free space to keep under --dir (default 2G, 0 disables). A job
119 that takes the filesystem below it is stopped while it runs. A job tree is
120 a host directory the runtime cannot quota, and a build that fills the disk
121 takes the runner out of service for every repository it serves.`
122}
123
124func cmdRunner(args []string) error {
125 f, err := parseFlags(args, flagSpec{long: "remote", short: "r"}, flagSpec{long: "dir"},
126 flagSpec{long: "repos"}, flagSpec{long: "label"},
127 flagSpec{long: "container-runtime"},
128 flagSpec{long: "container-network"}, flagSpec{long: "container-pull"},
129 flagSpec{long: "container-user"}, flagSpec{long: "container-userns"},
130 flagSpec{long: "container-memory"},
131 flagSpec{long: "container-cpus"}, flagSpec{long: "container-pids"},
132 flagSpec{long: "container-tmpfs"}, flagSpec{long: "disk-reserve"},
133 flagSpec{long: "cache-size"}, flagSpec{long: "git-cache-size"},
134 flagSpec{long: "no-cache", boolean: true},
135 flagSpec{long: "once", boolean: true},
136 flagSpec{long: "jobs"},
137 flagSpec{long: "known-hosts"}, flagSpec{long: "insecure-host-key", boolean: true})
138 if err != nil {
139 return err
140 }
141 if f.help {
142 fmt.Fprintln(os.Stdout, runnerUsage())
143 return nil
144 }
145 if len(f.rest) > 0 {
146 return usagef("unexpected argument %q", f.rest[0])
147 }
148 pids := 0
149 if raw := f.one("container-pids"); raw != "" {
150 pids, err = strconv.Atoi(raw)
151 if err != nil || pids < 0 {
152 return usagef("--container-pids %q is not a non-negative number", raw)
153 }
154 }
155 // One slot is the default, and a slot is a job at a time: --jobs 0 would mean a
156 // runner that never works, which is never what somebody meant to type.
157 jobs := 1
158 if raw := f.one("jobs"); raw != "" {
159 jobs, err = strconv.Atoi(raw)
160 if err != nil || jobs < 1 {
161 return usagef("--jobs %q is not a number of jobs (1 or more)", raw)
162 }
163 }
164 cacheMax := int64(0)
165 if raw := f.one("cache-size"); raw != "" {
166 cacheMax, err = jobcache.ParseSize(raw)
167 if err != nil {
168 return usagef("--cache-size %v", err)
169 }
170 }
171 // A saved checkout is worth more than a job cache and costs less to make: it is
172 // kept by default, and only a size the operator names takes it away. -1 means the
173 // agent's default, 0 means keep every checkout.
174 gitCacheMax := int64(-1)
175 if raw := f.one("git-cache-size"); raw != "" {
176 if strings.TrimSpace(raw) == "0" {
177 gitCacheMax = 0
178 } else if gitCacheMax, err = jobcache.ParseSize(raw); err != nil {
179 return usagef("--git-cache-size %v", err)
180 }
181 }
182 // The disk reserve is on by default: a runner that fills its own filesystem is out
183 // of service for every repository it serves, so the bound is the runner's problem
184 // rather than the operator's memory. 0 turns it off for a --dir on its own sized
185 // filesystem.
186 diskReserve := int64(2 << 30)
187 if raw := f.one("disk-reserve"); raw != "" {
188 if strings.TrimSpace(raw) == "0" {
189 diskReserve = 0
190 } else if diskReserve, err = jobcache.ParseSize(raw); err != nil {
191 return usagef("--disk-reserve %v", err)
192 }
193 }
194 // Host-key policy for this runner and every git child it starts. The explicit
195 // flag replaces what the environment asked for, so a test can be explicit and an
196 // operator can point at a file.
197 keys := client.HostKeysFromEnv()
198 if p := strings.TrimSpace(f.one("known-hosts")); p != "" {
199 keys.KnownHosts = p
200 keys.Insecure = false
201 }
202 if f.has("insecure-host-key") {
203 keys.Insecure = true
204 fmt.Fprintln(os.Stderr, "vole runner: WARNING: --insecure-host-key accepts any SSH host key; use it only for tests")
205 }
206 client.SetHostKeys(keys)
207 os.Setenv("GIT_SSH_COMMAND", client.GitSSHCommand())
208
209 // A terminated runner stops leasing and lets the jobs it already has finish and
210 // report: killing a build that is nearly done wastes the work and leaves the run
211 // unclaimed until its lease expires. A second signal ends the process at once,
212 // which is what signal.Stop restores — somebody watching a drain that is not
213 // ending should not have to escalate to SIGKILL.
214 sig := make(chan os.Signal, 1)
215 signal.Notify(sig, os.Interrupt, syscall.SIGTERM)
216 defer signal.Stop(sig)
217 ctx, cancel := context.WithCancel(context.Background())
218 defer cancel()
219 finished := make(chan struct{})
220 go func() {
221 select {
222 case <-sig:
223 fmt.Fprintln(os.Stderr, "vole runner: stopping after the job(s) in flight; signal again to stop now")
224 cancel()
225 signal.Stop(sig)
226 case <-finished:
227 }
228 }()
229
230 err = agent.Run(ctx, agent.Options{
231 Remote: f.one("remote"),
232 Dir: f.one("dir"),
233 Repos: f.all("repos"),
234 Labels: f.all("label"),
235 Once: f.has("once"),
236 Jobs: jobs,
237 ContainerRuntime: f.one("container-runtime"),
238 ContainerNetwork: f.one("container-network"),
239 ContainerPull: f.one("container-pull"),
240 ContainerUser: f.one("container-user"),
241 ContainerUserns: f.one("container-userns"),
242 ContainerMemory: f.one("container-memory"),
243 ContainerCPUs: f.one("container-cpus"),
244 ContainerPIDs: pids,
245 ContainerTmpfs: f.one("container-tmpfs"),
246 DiskReserve: diskReserve,
247 NoCache: f.has("no-cache"),
248 CacheMaxBytes: cacheMax,
249 GitCacheMaxBytes: gitCacheMax,
250 })
251 close(finished)
252 return err
253}
254