Let one host carry runners for more than one registration token
tests / runner-env (confined) (pull_request) Failing after 1m49s
tests / tests (pull_request) Successful in 1m51s
tests / compose-schema (pull_request) Successful in 1m35s
tests / runner-env (privileged) (pull_request) Successful in 1m24s

A registration token is minted at a scope and serves only repositories inside
it, so serving both an organization and a user account from one machine takes
two registrations. There was no way to express that: one invocation owned
~/runners, and a second would have overwritten the first.

The obstacle was that the label did too much. It named the runs-on label AND
the compose project, the container names and the volumes - and two groups on
one host usually want the SAME label, since a job from either scope should
reach a runner. Sharing the label was fine; sharing an identity meant the
second group replacing the first.

So identity is now --name, separate from --label. It scopes the directory, the
compose project, the containers, the volumes and the runner names in the forge;
the label scopes nothing and is free to be shared. --name defaults to the label,
so a host with one group needs no new argument and gets what it got before.

A name containing a slash or a space is refused rather than producing a
directory or a compose project that does not mean what it says.

Three tests: two groups sharing a label collide nowhere and appear in nothing
of each other's; the default keeps a single group working without --name; and
unusable names are refused. Also rendered two groups end to end and confirmed
separate projects, separate container names and two 0600 token files.

79 ok, 0 failed.
This commit is contained in:
Brad Anderson
2026-08-08 18:29:10 -07:00
parent 239651d5a6
commit d88b172da1
5 changed files with 117 additions and 13 deletions
+33 -3
View File
@@ -40,6 +40,7 @@ fi
ROLE=""
DIR=""
FORGE=""
OPT_NAME=""
OPT_LABEL=""
OPT_COUNT=""
OPT_VOLUMES=""
@@ -63,6 +64,11 @@ Usage: sudo $PROG --role confined|privileged [--dir PATH] [--forge URL]
--forge URL the forge, e.g. https://gitea.example.com. Read from
$CONFIG when install.sh has run; required on a privileged
host, where it has not
--name NAME identifies this group of runners on this host: its
directory, compose project, containers and volumes. Give
a second group its own name to register a second token -
another organization, or a user account - alongside the
first. Defaults to the label
--label NAME the label a workflow names in runs-on:
--count N how many runners (confined only; privileged is always 1)
--volumes PATHS host paths jobs may bind-mount, space-separated, or
@@ -85,6 +91,7 @@ while [ $# -gt 0 ]; do
--role) ROLE="${2:?}"; shift ;;
--dir) DIR="${2:?}"; shift ;;
--forge) FORGE="${2:?}"; shift ;;
--name) OPT_NAME="${2:?}"; shift ;;
--label) OPT_LABEL="${2:?}"; shift ;;
--count) OPT_COUNT="${2:?}"; shift ;;
--volumes) OPT_VOLUMES="${2:?}"; shift ;;
@@ -161,7 +168,6 @@ cfg_get() {
target_user="${SUDO_USER:-$(id -un)}"
target_home=$(getent passwd "$target_user" | cut -d: -f6)
[ -n "$target_home" ] || target_home="$HOME"
: "${DIR:=$target_home/runners}"
say "=============================================================="
say " Runner setup - $ROLE tier"
@@ -219,6 +225,23 @@ say "a job naming it can land there instead, on a host nothing here hardened."
say
ask label "Runner label" "${OPT_LABEL:-$default_label}"
[ -n "$label" ] || die "a label is required"
# The group's identity, kept separate from its label. Two groups on one host
# may share a label - that is the point of registering one at organization
# scope and another for a user account, so either scope's workflows reach the
# same pool - but they cannot share a compose project, container names or
# volumes without colliding.
say
say "A name for this group of runners on this host. It names the directory,"
say "the compose project, the containers and the volumes - not anything a"
say "workflow sees. Give a second group its own name to register a second"
say "token alongside this one."
ask name "Group name" "${OPT_NAME:-$label}"
[ -n "$name" ] || die "a group name is required"
case "$name" in
*/*|*' '*) die "a group name cannot contain a slash or a space: $name" ;;
esac
: "${DIR:=$target_home/runners/$name}"
case "$label" in
ubuntu-*|ubuntu|latest|self-hosted)
say
@@ -270,7 +293,7 @@ if [ "$ROLE" = privileged ]; then
fi
# --- render -----------------------------------------------------------------
project="${label}-runners"
project="${name}-runners"
install -d -m 0755 "$DIR" || die "cannot create $DIR"
@@ -320,6 +343,7 @@ subst() {
# would break the YAML anyway.
sed -e "s|@@PROJECT@@|$project|g" \
-e "s|@@LABEL@@|$label|g" \
-e "s|@@NAME@@|$name|g" \
-e "s|@@IMAGE@@|$act_image|g" \
-e "s|@@JOB_IMAGE@@|$job_image|g" \
-e "s|@@INSTANCE_URL@@|$instance|g" \
@@ -557,4 +581,10 @@ say
say " project $DIR"
say " token $ENV_FILE (0600 - empty it once they are Online)"
say " restart cd $DIR && docker compose restart"
say " change re-run $PROG --role $ROLE"
say " change re-run $PROG --role $ROLE --name $name"
say
say "To register a second token on this host - another organization, or a user"
say "account - run this again with a different --name. The label may be the"
say "same; everything else is kept apart:"
say
say " $PROG --role $ROLE --name <other>"
+25
View File
@@ -337,6 +337,31 @@ Give it one label that nothing else carries, and grant it to as few
repositories as you can stand. Then take the clean snapshot, which §7 below
covers for both tiers.
### More than one registration token on the same host
A registration token is minted at a scope — an organization, or a user
account — and serves only repositories inside it. To serve two scopes from one
host, register two groups:
```sh
sudo dejima-ci-runners --role confined --name dejima --label ci-shared
sudo dejima-ci-runners --role confined --name branderson --label ci-shared
```
Each gets its own directory, compose project, containers, volumes and `.env`,
so the tokens never meet and either group can be changed or removed without
touching the other. Run the command again per group; it is idempotent, so the
first is undisturbed by the second.
**The label may be the same for both, and usually should be.** It is what a
workflow names in `runs-on:`, and a job from either scope should be able to
reach a runner. That is why the group name is a separate thing: sharing an
identity as well would collide on the compose project and the container names,
and the second group would replace the first rather than join it.
What the group name must not be shared with is another group. Everything the
host uses to tell them apart is derived from it.
---
## 7. Snapshot it clean, once the runner is registered
+12 -5
View File
@@ -52,13 +52,20 @@ services:
# label and one job network. That is what makes them a pool: a job naming
# @@LABEL@@ lands on whichever is free. Only the name and the data volume
# differ.
#
# Those names come from @@NAME@@, which identifies this GROUP of runners on
# this host, rather than from the label. The two are separate on purpose: a
# host can carry a second group registered with a different token - another
# organization, or a user account - and both may legitimately answer the
# same label. Sharing an identity as well would collide on the compose
# project, the container names and the volumes.
# @@SERVICE_BEGIN@@
"@@LABEL@@-@@N@@":
"@@NAME@@-@@N@@":
<<: *ci-runner
container_name: "@@LABEL@@-@@N@@"
container_name: "@@NAME@@-@@N@@"
environment:
<<: *ci-env
GITEA_RUNNER_NAME: "@@LABEL@@-@@N@@"
GITEA_RUNNER_NAME: "@@NAME@@-@@N@@"
volumes:
# The runner's OWN access to the daemon, so it can create job
# containers. NOT the same setting as container.docker_host, which
@@ -69,7 +76,7 @@ services:
- ./config.yaml:/config.yaml:ro
# Its registration identity. One per runner, never shared - two runners
# cannot share a .runner.
- "@@LABEL@@-@@N@@-data:/data"
- "@@NAME@@-@@N@@-data:/data"
# @@SERVICE_END@@
networks:
@@ -89,5 +96,5 @@ networks:
volumes:
# @@VOLUME_BEGIN@@
"@@LABEL@@-@@N@@-data":
"@@NAME@@-@@N@@-data":
# @@VOLUME_END@@
+5 -5
View File
@@ -27,13 +27,13 @@
name: "@@PROJECT@@"
services:
"@@LABEL@@":
"@@NAME@@":
# Pin the tag. This process executes untrusted code on every pull request,
# and :latest means the thing doing that can change without you deciding
# it should.
image: "@@IMAGE@@"
restart: unless-stopped
container_name: "@@LABEL@@"
container_name: "@@NAME@@"
networks: [runners]
environment:
@@ -43,7 +43,7 @@ services:
# invisible. Check it first when an edit appears to do nothing.
CONFIG_FILE: /config.yaml
GITEA_INSTANCE_URL: "@@INSTANCE_URL@@"
GITEA_RUNNER_NAME: "@@LABEL@@"
GITEA_RUNNER_NAME: "@@NAME@@"
# Needed only until registration completes; the identity then lives in
# the volume below. Comes from a .env file beside this one (chmod 600)
# so it is not in the file you commit, and is worth removing once
@@ -60,7 +60,7 @@ services:
- /var/run/docker.sock:/var/run/docker.sock
- ./config.yaml:/config.yaml:ro
# Its own registration identity. Never shared with another runner.
- "@@LABEL@@-data:/data"
- "@@NAME@@-data:/data"
# No bind for /tmp/dejima-ci. The job container gets that path because
# the workflow asks for it and container.valid_volumes permits it, and
# the host daemon resolves the source host-side. This container never
@@ -74,4 +74,4 @@ networks:
runners:
volumes:
"@@LABEL@@-data":
"@@NAME@@-data":
+42
View File
@@ -226,6 +226,48 @@ privileged() {
done
}
@test "two groups on one host can share a label without colliding" {
# The reason to register a second token at all: one group at organization
# scope and another for a user account, both answering the same label so
# either scope's workflows reach a runner. Sharing the label is the point;
# sharing a compose project, container names or volumes would mean the
# second group quietly replacing the first.
confined --name org --label shared --count 2
[ "$status" -eq 0 ]
OUT="$TMP/second" confined --name user --label shared --count 2
[ "$status" -eq 0 ]
grep -q '^name: "org-runners"$' "$TMP/runners/docker-compose.yml"
grep -q '^name: "user-runners"$' "$TMP/second/docker-compose.yml"
grep -q 'container_name: "org-1"' "$TMP/runners/docker-compose.yml"
grep -q 'container_name: "user-1"' "$TMP/second/docker-compose.yml"
grep -q '"org-1-data":' "$TMP/runners/docker-compose.yml"
grep -q '"user-1-data":' "$TMP/second/docker-compose.yml"
# Nothing identifying one group appears in the other.
! grep -q 'user-' "$TMP/runners/docker-compose.yml"
! grep -q '\borg-' "$TMP/second/docker-compose.yml"
# ...while both answer the same label, which is what a workflow names.
grep -q '"shared:docker://' "$TMP/runners/config.yaml"
grep -q '"shared:docker://' "$TMP/second/config.yaml"
}
@test "the group name defaults to the label, so one group needs no --name" {
confined --label solo
[ "$status" -eq 0 ]
grep -q '^name: "solo-runners"$' "$OUT/docker-compose.yml"
grep -q 'container_name: "solo-1"' "$OUT/docker-compose.yml"
}
@test "a group name that would break a path or a compose project is refused" {
confined --name 'a/b'
[ "$status" -ne 0 ]
confined --name 'a b'
[ "$status" -ne 0 ]
}
@test "both rendered files are parseable YAML" {
if ! command -v js-yaml >/dev/null 2>&1 && ! python3 -c 'import yaml' 2>/dev/null; then
skip "no YAML parser available"