No description
  • Rust 60.9%
  • Shell 37.8%
  • Go Template 0.7%
  • Dockerfile 0.6%
Find a file
Enno Boland a2b11dfb42
Some checks failed
Container disks / lint (push) Successful in 16s
CI / chart (push) Successful in 6s
Container disks / alpine (3.24) (push) Successful in 1m7s
Container disks / openbsd (7.9) (push) Failing after 2m32s
Container disks / freebsd (15.1) (push) Successful in 2m32s
CI / rust (push) Successful in 3m40s
Container disks / debian (trixie) (push) Successful in 2m59s
CI / build-chart (push) Successful in 6s
CI / build-image (push) Failing after 1m6s
Note the iptables podman needs on the alpine runner
2026-08-20 22:53:13 +02:00
.forgejo/workflows Note the iptables podman needs on the alpine runner 2026-08-20 22:53:13 +02:00
charts/forgejo-runner-controller Write the status only when it changed and record what was registered 2026-08-20 16:48:55 +02:00
controller Keep the test suite out of examples/ 2026-08-20 16:48:55 +02:00
examples Split the VM example into one per platform 2026-08-20 16:49:00 +02:00
vms Install dbus so crun can manage cgroups through systemd 2026-08-20 22:41:17 +02:00
.dockerignore Initial commit 2026-08-20 14:03:04 +02:00
.editorconfig Initial commit 2026-08-20 14:03:04 +02:00
.gitignore Ignore what a disk build leaves behind 2026-08-20 16:48:33 +02:00
Dockerfile Initial commit 2026-08-20 14:03:04 +02:00
LICENSE Initial commit 2026-08-20 14:03:04 +02:00
README.md Build disk images in stages and publish them from CI 2026-08-20 14:03:13 +02:00
renovate.json Point every renovate manager at something that exists 2026-08-20 16:49:10 +02:00
rustfmt.toml Initial commit 2026-08-20 14:03:04 +02:00

forgejo-ephemeral-runner

A Kubernetes operator that runs Forgejo Actions jobs on throwaway runners. Every queued job gets its own runner, which registers, executes exactly that one job, and disappears. Nothing is reused between jobs.

Two kinds of runner are supported, chosen per Runner resource:

  • pod -- a batch/v1 Job. The job image is independent of the runner: an init container copies the forgejo-runner binary out of spec.runnerImage -- or the operator's own default, set chart-wide -- into an emptyDir that the job container runs it from, so jobs can use any image at all.
  • vm -- a KubeVirt VirtualMachine booting a prepared guest image. This is how non-Linux jobs (OpenBSD, FreeBSD) get real hardware-level isolation; vms/ holds the image builders.

How it works

The controller polls GET /api/v1/admin/actions/runners/jobs for jobs whose runs-on labels a Runner serves. Forgejo has no webhook for "job queued" and no queue-depth metric, so polling is the only option.

Every child is the same thing -- an ephemeral runner with a random name that registers, takes one job and exits -- so the decision is arithmetic. Count the children that can still take a job, and spawn enough to cover the queue plus minRunners spare, without passing maxRunners:

spawn = ((waiting + minRunners) - available).min(maxRunners - live)

Children that have not connected yet count as available, which is what keeps the loop honest: a job polled twice before its runner comes up does not get a second one. minRunners sits on top of demand rather than being consumed by it, so a spare stays ready even while every other child is busy.

Nothing reclaims a child early. One left behind by a cancelled job holds its slot until jobTimeoutSeconds, which is also the backstop for anything else wedged.

Credentials are minted per spawn: the controller registers an ephemeral runner and puts the result in a Secret owned by the child, so deleting the child takes the token with it.

How the runner is then told what to do differs by kind. A pod gets an explicit one-job --url ... --uuid ... --label ... argv on the binary in the emptyDir, and the Secret holds only the token file it reads. A VM has no such channel -- it just boots -- so the Secret is attached as a CDROM and carries the whole invocation as files:

file contents
url Forgejo base URL
uuid runner uuid from registration
token runner token from registration
label one <name>:host label per line

Reading those and running forgejo-runner one-job is the guest image's job, not the controller's. vms/bin/<os>_image.sh bakes it in: -b installs a script that runs on every boot, -F adds the fstab entry that mounts the CDROM, and -f copies in any extra files.

Usage

kubectl create namespace forgejo-runner
kubectl -n forgejo-runner create secret generic forgejo-pat --from-literal=token=<admin-pat>
helm install forgejo-runner-controller charts/forgejo-runner-controller
kubectl apply -f examples/runner-pod.yaml
kubectl -n forgejo-runner get runners

The token must be a Forgejo admin token: the controller registers runners instance-wide and lists queued jobs across all repositories.

See examples/ for a pod-mode and a VM-mode Runner, and the CRD in charts/forgejo-runner-controller/crds/ for the full field reference.

Development

cd controller
cargo test
cargo clippy --all-targets -- -D warnings
cargo run --bin crdgen -- --update   # regenerate the CRD in the chart
cargo run --bin forgejo-runner-controller

crdgen writes the CRD into the chart; CI fails if it is out of date.