Skip to content

Commit 91c1b0c

Browse files
louij2claude
andcommitted
ci: self-hosted burst runners for the kvm label
The VM end-to-end test cannot run on GitHub's hosted runners - no nested virtualisation, not enough disk - so it has been dispatch-only with nowhere to dispatch to. This adds the runner side, modelled on the ADO burst agents: spawn per job, one job per runner, nothing running when the queue is empty. - Dockerfile.runner / runner-entrypoint.sh: an ephemeral runner that takes a single job, deregisters and exits. It drives the host's libvirt over the mounted socket rather than running qemu itself. - autoscaler.py: stdlib-only loop that counts queued jobs asking for the label, tops up runner containers to a ceiling, and serves gha_ci_* metrics on :9826 for Prometheus. - deploy-unraid.sh: builds both images and starts the autoscaler, refusing to run without a credential and saying how to create one. Security, because this repo is public: vm-test.yml stays workflow_dispatch only, so a fork PR can never reach the runner, and nothing else uses the kvm label. Runners are ephemeral, and the credential is bind-mounted read-only and read at point of use, so it is not in the image, the environment, or `docker inspect`. Also makes the VM script work from inside a container: OVMF paths are overridable and the emulator is now asked of libvirt itself, since firmware and qemu live on the host rather than on the machine running the script. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
1 parent f9c85a1 commit 91c1b0c

8 files changed

Lines changed: 585 additions & 6 deletions

File tree

.github/workflows/vm-test.yml

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -35,12 +35,19 @@ jobs:
3535
- uses: actions/checkout@v4
3636

3737
- name: Run the end-to-end VM test
38-
run: sudo tools/vm-test/run-vm-test.sh ${{ inputs.build && format('--build {0}', inputs.build) || '' }}
38+
# VM_TEST_WORKDIR is set by the runner: it must be a path that exists
39+
# identically on the libvirt host, since libvirt resolves the disk
40+
# images there. It must not be RAM-backed - see the script's guard.
41+
run: |
42+
sudo --preserve-env=VM_TEST_WORKDIR,OVMF_CODE,OVMF_VARS \
43+
tools/vm-test/run-vm-test.sh \
44+
--workdir "${VM_TEST_WORKDIR:-/var/tmp/steamos-vm-test}" \
45+
${{ inputs.build && format('--build {0}', inputs.build) || '' }}
3946
4047
- name: Publish the guest console log
4148
if: always()
4249
uses: actions/upload-artifact@v4
4350
with:
4451
name: vm-console-log
45-
path: /var/tmp/steamos-vm-test/console.log
52+
path: ${{ env.VM_TEST_WORKDIR || '/var/tmp/steamos-vm-test' }}/console.log
4653
if-no-files-found: warn
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
# Watches the repo's job queue and spawns ephemeral runner containers.
2+
#
3+
# Needs the host's docker socket, because it starts sibling containers rather
4+
# than running jobs itself. It never runs job code.
5+
FROM python:3.12-alpine
6+
7+
RUN apk add --no-cache docker-cli
8+
9+
COPY autoscaler.py /usr/local/bin/autoscaler.py
10+
RUN chmod +x /usr/local/bin/autoscaler.py
11+
12+
EXPOSE 9826
13+
ENTRYPOINT ["python3", "-u", "/usr/local/bin/autoscaler.py"]

tools/ci-runner/Dockerfile.runner

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
# Ephemeral GitHub Actions runner able to drive a libvirt host.
2+
#
3+
# It does NOT run qemu itself: it talks to the host's libvirt over the mounted
4+
# socket, so the VM runs on the host while this container only orchestrates.
5+
# What it does need locally is the disk plumbing - losetup, mount, btrfs - which
6+
# is why it runs privileged with the host's /dev.
7+
FROM ubuntu:24.04
8+
9+
ARG RUNNER_VERSION=2.337.0
10+
ARG RUNNER_ARCH=x64
11+
12+
ENV DEBIAN_FRONTEND=noninteractive \
13+
RUNNER_ALLOW_RUNASROOT=1 \
14+
RUNNER_MANUALLY_TRAP_SIG=1
15+
16+
RUN apt-get update && apt-get install -y --no-install-recommends \
17+
ca-certificates curl jq git sudo unzip tar gzip bzip2 xz-utils \
18+
libvirt-clients qemu-utils \
19+
util-linux mount udev btrfs-progs e2fsprogs dosfstools \
20+
coreutils gawk sed grep procps iproute2 \
21+
libicu74 \
22+
&& rm -rf /var/lib/apt/lists/*
23+
24+
WORKDIR /actions-runner
25+
RUN curl -fsSL -o runner.tar.gz \
26+
"https://github.com/actions/runner/releases/download/v${RUNNER_VERSION}/actions-runner-linux-${RUNNER_ARCH}-${RUNNER_VERSION}.tar.gz" \
27+
&& tar xzf runner.tar.gz && rm runner.tar.gz
28+
29+
COPY runner-entrypoint.sh /usr/local/bin/runner-entrypoint.sh
30+
RUN chmod +x /usr/local/bin/runner-entrypoint.sh
31+
32+
ENTRYPOINT ["/usr/local/bin/runner-entrypoint.sh"]

tools/ci-runner/README.md

Lines changed: 129 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,129 @@
1+
# `tools/ci-runner/` — self-hosted burst runners
2+
3+
Spawn-per-job GitHub Actions runners for the `kvm` label, so the
4+
[VM end-to-end test](../vm-test/README.md) can run in CI. GitHub's hosted
5+
runners cannot: they have no nested virtualisation and not enough disk.
6+
7+
Nothing runs while the queue is empty. One small autoscaler container watches
8+
for queued jobs and starts an **ephemeral** runner per job; each runner takes
9+
exactly one job, deregisters itself and exits.
10+
11+
```
12+
autoscaler (always on, ~20 MB)
13+
│ polls the repo for queued jobs asking for 'kvm'
14+
15+
gha-burst-<ts>-<n> ephemeral runner container, one job then gone
16+
│ drives libvirt over the host socket
17+
18+
steamos-install-test VM on the host, torn down by the test itself
19+
```
20+
21+
## Security: read this before pointing it at a public repo
22+
23+
A self-hosted runner executes whatever the workflow says. On a **public**
24+
repository that is a real risk, because anyone can open a pull request.
25+
26+
The protections here:
27+
28+
- **`vm-test.yml` is `workflow_dispatch` only.** It never triggers on
29+
`pull_request`, so a fork cannot cause it to run. Nothing else uses the
30+
`kvm` label — every other job stays on GitHub's hosted runners.
31+
- **Ephemeral runners.** One job per container, then it is discarded. Nothing
32+
persists between jobs.
33+
- **The credential is never in the image or the environment.** It is
34+
bind-mounted read-only as a file and read at the moment it is used, so
35+
`docker inspect` does not reveal it.
36+
37+
Worth also setting, once:
38+
39+
```bash
40+
gh api -X PUT repos/OWNER/REPO/actions/permissions/workflow -f default_workflow_permissions=read
41+
```
42+
43+
and in **Settings → Actions → General → Fork pull request workflows**, choose
44+
*Require approval for all outside collaborators*.
45+
46+
Do not add the `kvm` label to a workflow that runs on `pull_request` unless you
47+
have thought hard about it. The runner is privileged and sits on your LAN.
48+
49+
## Setting it up
50+
51+
On the libvirt host (not your laptop):
52+
53+
```bash
54+
git clone https://github.com/louij2/steamos_custom_install.git
55+
cd steamos_custom_install
56+
sudo ./tools/ci-runner/deploy-unraid.sh
57+
```
58+
59+
It refuses to start without a credential and tells you how to create one. The
60+
token needs **`Administration: read and write`** on the one repository — that
61+
is what mints runner registration tokens. Create a fine-grained PAT scoped to
62+
that single repo at <https://github.com/settings/personal-access-tokens/new>,
63+
then put it in place yourself:
64+
65+
```bash
66+
install -d -m 700 /mnt/user/appdata/gh-autoscaler
67+
printf '%s' 'YOUR_TOKEN' > /mnt/user/appdata/gh-autoscaler/gh_pat
68+
chmod 600 /mnt/user/appdata/gh-autoscaler/gh_pat
69+
```
70+
71+
## Configuration
72+
73+
Every knob is an environment variable on `deploy-unraid.sh`:
74+
75+
| Variable | Default | Meaning |
76+
|---|---|---|
77+
| `GITHUB_REPO` | `louij2/steamos_custom_install` | Repository to serve |
78+
| `RUNNER_LABEL` | `kvm` | Label a job must request |
79+
| `MAX_RUNNERS` | `3` | Ceiling on concurrent burst runners |
80+
| `POLL_SECONDS` | `30` | Seconds between queue checks |
81+
| `METRICS_PORT` | `9826` | Prometheus endpoint |
82+
| `VM_WORKDIR` | `/mnt/user/isos/steamos-vm-test` | Image cache, must be real storage |
83+
| `MAX_RUNNERS=1` | | Effectively serialises, if the host is busy |
84+
85+
Raise `MAX_RUNNERS` only as far as the host can take: each concurrent VM test
86+
wants ~8 GB RAM, 4 vCPU and ~21 GB of disk.
87+
88+
Unlike the Azure DevOps equivalent there is **no billing ceiling** — self-hosted
89+
runners on a public repository have unlimited concurrency, so the only limit is
90+
the hardware.
91+
92+
## Metrics
93+
94+
Prometheus endpoint on `:9826/metrics`:
95+
96+
| Metric | Meaning |
97+
|---|---|
98+
| `gha_ci_queued_jobs` | Queued jobs asking for the label |
99+
| `gha_ci_burst_running` | Burst containers alive now |
100+
| `gha_ci_max_runners` | Configured ceiling |
101+
| `gha_ci_spawned_total` | Runners started since the autoscaler booted |
102+
| `gha_ci_errors_total` | Poll or spawn failures |
103+
| `gha_ci_last_tick_timestamp_seconds` | Staleness check for the loop |
104+
105+
Alert on `time() - gha_ci_last_tick_timestamp_seconds > 300` (the loop is dead)
106+
and on `gha_ci_queued_jobs > 0 and gha_ci_burst_running == 0` sustained (jobs
107+
are queuing but nothing is starting — usually an expired credential).
108+
109+
## Why the runner container is privileged
110+
111+
The VM test needs `losetup`, `mount` and `btrfs property` against real devices
112+
to inject the harness into the recovery image. It does **not** run qemu: it
113+
talks to the host's libvirt over the mounted socket, so the VM belongs to the
114+
host. That is also why the work directory is bind-mounted at the *same path*
115+
inside and out — libvirt resolves the disk image paths on the host, not in the
116+
container.
117+
118+
## Operating it
119+
120+
```bash
121+
docker logs -f gh-autoscaler # what it is deciding
122+
docker ps --filter label=gha-burst=1 # runners alive right now
123+
curl -s localhost:9826/metrics # current state
124+
docker rm -f gh-autoscaler # stop scaling; running jobs finish
125+
```
126+
127+
Runners that vanish mid-job leave a registration behind; they are ephemeral so
128+
GitHub clears them, but `gh api repos/OWNER/REPO/actions/runners` shows what is
129+
registered if you suspect otherwise.

0 commit comments

Comments
 (0)