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.
Ship the runner's unit and let a deployment create a runner machine
d0d4239f3f7236197c87a752d7965de238430f98 · Keenan Nemetz · 3d ago · parent dbae2e01
Ship the runner's unit and let a deployment create a runner machine The package installs one binary that is the forge, the runner, and the CLI, so it now carries a second unit. It is not enabled, and it refuses to start with a message until an operator fills in /etc/vole/runner.env: who runs builds on a machine is that machine owner's decision. The unit runs as a dedicated account, because rootless podman needs a home directory and a range of subordinate ids, and because a job should not find the forge's own account on the same disk. The arguments live in that one file rather than in the unit, split at spaces by systemd, so a single unit serves every machine. Each Terraform configuration takes runner=true and creates that machine beside the forge: its own firewall, no backups (its checkouts and caches are copies), the same key for a shell, and Cloud-init that installs the package, creates the account and its subuids, generates the runner's key, pins the instance's host key once, and enables the unit. The runner_setup output prints the three commands that let it take work, filled in with the addresses this apply created. The first-boot file cannot register the runner: the allowlist is per repository, and at first boot there are no repositories. That step stays with an instance admin, and the docs say so.
deploy/cloud-init/runner.yaml +126
| @@ -0,0 +1,126 @@ | |||
| 1 | #cloud-config | ||
| 2 | # A Vole CI runner on a fresh Ubuntu or Debian machine. | ||
| 3 | # | ||
| 4 | # A runner leases jobs over SSH, executes each one in a container, and reports the | ||
| 5 | # result. It is a separate machine from the instance on purpose: it runs | ||
| 6 | # repository code, and a runner can read every secret its jobs declare, so it | ||
| 7 | # should not share a disk with the account database and the SSH host key. | ||
| 8 | # | ||
| 9 | # Each placeholder below is a value the caller supplies. Terraform renders this | ||
| 10 | # file with templatefile(); deploy/cloud-init/render.sh renders it with | ||
| 11 | # envsubst(). Both read a placeholder as a dollar sign followed by a name in | ||
| 12 | # braces, so shell variables in the script at the bottom are written without | ||
| 13 | # braces and are left alone. | ||
| 14 | |||
| 15 | hostname: ${runner_hostname} | ||
| 16 | fqdn: ${runner_hostname} | ||
| 17 | manage_etc_hosts: true | ||
| 18 | |||
| 19 | package_update: true | ||
| 20 | packages: | ||
| 21 | - ca-certificates | ||
| 22 | - curl | ||
| 23 | - git | ||
| 24 | - openssh-client | ||
| 25 | # Rootless podman and what it needs to map the runner's user into the container | ||
| 26 | # and to give a container a network when a job asks for one. | ||
| 27 | - podman | ||
| 28 | - slirp4netns | ||
| 29 | - fuse-overlayfs | ||
| 30 | - uidmap | ||
| 31 | |||
| 32 | write_files: | ||
| 33 | # Staged, not installed directly: the package ships /etc/vole/runner.env as a | ||
| 34 | # conffile, so it has to arrive after the package does. | ||
| 35 | - path: /opt/vole-runner/runner.env | ||
| 36 | permissions: "0644" | ||
| 37 | content: | | ||
| 38 | # Read by vole-runner.service. One line, split at spaces. | ||
| 39 | VOLE_RUNNER_ARGS=--remote ssh://git@${forge_host}:2222 --container-runtime ${runner_runtime} --container-network ${runner_network} --dir ${runner_dir} --known-hosts /etc/vole/runner_known_hosts ${runner_labels} | ||
| 40 | |||
| 41 | - path: /usr/local/sbin/vole-runner-first-boot | ||
| 42 | permissions: "0755" | ||
| 43 | content: | | ||
| 44 | #!/bin/sh | ||
| 45 | # One-time setup for a Vole CI runner. cloud-init runs this once, on first | ||
| 46 | # boot, and it is safe to run again: the package install is skipped when | ||
| 47 | # vole is already there, and the account and key are only created if they | ||
| 48 | # are missing. | ||
| 49 | set -eu | ||
| 50 | |||
| 51 | FORGE_HOST='${forge_host}' | ||
| 52 | PACKAGE_URL='${vole_package_url}' | ||
| 53 | RUNNER_DIR='${runner_dir}' | ||
| 54 | RUNNER_USER=ci-runner | ||
| 55 | LOG=/var/log/vole-runner-first-boot.log | ||
| 56 | |||
| 57 | exec >>"$LOG" 2>&1 | ||
| 58 | echo "== vole runner first boot $(date -u +%Y-%m-%dT%H:%M:%SZ) ==" | ||
| 59 | |||
| 60 | # The package installs the binary, the runner unit, and /etc/vole/runner.env. | ||
| 61 | # It also prepares a server instance that this machine never starts. | ||
| 62 | if ! command -v vole >/dev/null 2>&1; then | ||
| 63 | echo "downloading $PACKAGE_URL" | ||
| 64 | curl -fsSL --retry 5 --retry-delay 2 -o /tmp/vole.deb "$PACKAGE_URL" | ||
| 65 | DEBIAN_FRONTEND=noninteractive apt-get install -y /tmp/vole.deb | ||
| 66 | rm -f /tmp/vole.deb | ||
| 67 | fi | ||
| 68 | |||
| 69 | # A dedicated account with a home directory and a range of subordinate ids, | ||
| 70 | # which is what lets the container runtime be rootless. It is not the vole | ||
| 71 | # service account: a job runs as this user, and the forge's own account does | ||
| 72 | # not exist on this machine. | ||
| 73 | if ! getent passwd "$RUNNER_USER" >/dev/null 2>&1; then | ||
| 74 | useradd --create-home --home-dir "$RUNNER_DIR" --shell /usr/sbin/nologin \ | ||
| 75 | --comment "Vole CI runner" "$RUNNER_USER" | ||
| 76 | fi | ||
| 77 | if ! grep -q "^$RUNNER_USER:" /etc/subuid; then | ||
| 78 | usermod --add-subuids 100000-165535 --add-subgids 100000-165535 "$RUNNER_USER" | ||
| 79 | fi | ||
| 80 | |||
| 81 | # The runner authenticates with this key. Its public half is what an | ||
| 82 | # instance admin registers as a bot account; nothing here talks to the | ||
| 83 | # instance as an admin. | ||
| 84 | if [ ! -f "$RUNNER_DIR/.ssh/id_ed25519" ]; then | ||
| 85 | install -d -o "$RUNNER_USER" -g "$RUNNER_USER" -m 0700 "$RUNNER_DIR/.ssh" | ||
| 86 | su -s /bin/sh "$RUNNER_USER" -c "ssh-keygen -q -t ed25519 -N '' -C 'vole-runner@${runner_hostname}' -f $RUNNER_DIR/.ssh/id_ed25519" | ||
| 87 | fi | ||
| 88 | |||
| 89 | # Pin the instance's host key once. A runner executes repository code, so a | ||
| 90 | # host that can answer in the instance's place could send it any job; this | ||
| 91 | # file is the boundary, and it is never re-scanned on later starts. | ||
| 92 | # | ||
| 93 | # The instance may still be starting, or its name may not resolve yet, so | ||
| 94 | # this waits for it instead of failing the boot. | ||
| 95 | if [ ! -s /etc/vole/runner_known_hosts ]; then | ||
| 96 | echo "waiting for $FORGE_HOST on port 2222" | ||
| 97 | i=0 | ||
| 98 | while [ "$i" -lt 60 ]; do | ||
| 99 | if ssh-keyscan -T 5 -p 2222 "$FORGE_HOST" >/tmp/known_hosts 2>/dev/null && [ -s /tmp/known_hosts ]; then | ||
| 100 | install -m 0644 /tmp/known_hosts /etc/vole/runner_known_hosts | ||
| 101 | echo "pinned the host key of $FORGE_HOST" | ||
| 102 | break | ||
| 103 | fi | ||
| 104 | i=$((i + 1)) | ||
| 105 | sleep 10 | ||
| 106 | done | ||
| 107 | rm -f /tmp/known_hosts | ||
| 108 | fi | ||
| 109 | |||
| 110 | install -m 0640 /opt/vole-runner/runner.env /etc/vole/runner.env | ||
| 111 | systemctl daemon-reload | ||
| 112 | systemctl enable vole-runner | ||
| 113 | |||
| 114 | if [ -s /etc/vole/runner_known_hosts ]; then | ||
| 115 | systemctl restart vole-runner | ||
| 116 | else | ||
| 117 | echo "$FORGE_HOST did not answer yet; once it does, run: systemctl restart vole-runner" | ||
| 118 | fi | ||
| 119 | |||
| 120 | runcmd: | ||
| 121 | - /usr/local/sbin/vole-runner-first-boot | ||
| 122 | |||
| 123 | final_message: >- | ||
| 124 | The runner is configured. Register its key as a bot account and allow it on a | ||
| 125 | repository; the public key is at ${runner_dir}/.ssh/id_ed25519.pub, and setup | ||
| 126 | output is in /var/log/vole-runner-first-boot.log. | ||