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.
| 1 | package main |
| 2 | |
| 3 | import ( |
| 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 | |
| 17 | func 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 | |
| 27 | Leases CI jobs from a Vole instance and runs them. Authentication is the SSH key |
| 28 | you would push with, so there is no token to configure. A runner may only run jobs |
| 29 | for repositories whose runner allowlist its account is on (admins are implicit): |
| 30 | running a job executes that repository's code, which is a bigger power than |
| 31 | writing to it. |
| 32 | |
| 33 | Every finished attempt commits a record to the repository's metadata branch, |
| 34 | together with the artifacts the build published, in one push: the runner is the |
| 35 | party that did the work, so the runner is the party that states the result. The |
| 36 | push 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 | |
| 52 | Host 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 | |
| 62 | Container 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 | |
| 95 | Job 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 | |
| 107 | Saved 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 | |
| 124 | func 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 |