Watch
0
0
Fork
You've already forked hyperhive
0

swarm-ci-runner: give the registration token only to the register step

The runner got its registration token through an `EnvironmentFile`
(`TOKEN=…`), so the daemon carried it for its whole life, and for a
`host` label act_runner copies its environment into every job step:
any workflow could read a token that registers runners on the forge.

The host unit now writes the bare token, and the runner unit loads it
with `LoadCredential=`. The upstream pre-start step, which registered
from `$TOKEN`, is replaced by one register step that reads the token
from `$CREDENTIALS_DIRECTORY` and runs `act_runner register` only when
`.runner` is missing, with the instance URL, name, labels and config
file upstream used. The precond now also removes `.runner` when its
labels differ from the configured ones, which upstream's step covered.
A changed token alone no longer re-registers.

Host jobs can still read `.runner` itself (#4979).

Closes #4974
This commit is contained in:
atlas 2026-10-05 16:06:19 +02:00
commit 0302b29ca4
4 changed files with 158 additions and 71 deletions

View file

@ -57,11 +57,13 @@ let
# it, which is the spelling a step run as root (`+`) can follow.
runnerFile = "/var/lib/gitea-runner/${instance}/.runner";
# Inside the container, root-only. The token file is the runner's
# `EnvironmentFile` (`TOKEN=<value>`); the host unit below writes it through
# the container's root, at `hostTokenFile`.
# Inside the container, root-only. The token file holds the bare token and
# reaches the runner unit only as a `LoadCredential=`, which only the
# register step reads; the host unit below writes it through the
# container's root, at `hostTokenFile`.
stateDir = "/var/lib/swarm-ci-runner";
tokenFile = "${stateDir}/registration-token.env";
tokenFile = "${stateDir}/registration-token";
tokenCredential = "registration-token";
hostTokenFile = "/var/lib/nixos-containers/${machine}${tokenFile}";
# The epoch second the daemon last started. The precond reads the daemon's
# journal from there.
@ -246,15 +248,15 @@ in
exit 1
fi
# Root-owned: systemd reads an `EnvironmentFile` as root, before the
# runner drops to its dynamic user.
# Root-owned: systemd reads a `LoadCredential=` source as root, before
# the runner drops to its dynamic user.
install -d -m 0700 ${lib.escapeShellArg (builtins.dirOf hostTokenFile)}
if secret_differs ${lib.escapeShellArg hostTokenFile} "TOKEN=$token"; then
atomic_write_secret 0600 "" ${lib.escapeShellArg hostTokenFile} "TOKEN=$token"
if secret_differs ${lib.escapeShellArg hostTokenFile} "$token"; then
atomic_write_secret 0600 "" ${lib.escapeShellArg hostTokenFile} "$token"
fi
# `EnvironmentFile=` is read once, at start. A changed token makes the
# upstream pre-start step register again.
# `LoadCredential=` copies the file at start, so a runner still waiting
# to register sees a new token only once it restarts.
refresh_consumer ${lib.escapeShellArg machine} ${runnerUnit}.service ${lib.escapeShellArg hostTokenFile}
'';
};
@ -282,7 +284,20 @@ in
// caTrust.bindMount;
config =
{ pkgs, lib, ... }:
{
pkgs,
lib,
config,
...
}:
let
runnerCfg = config.services.gitea-actions-runner;
instanceCfg = runnerCfg.instances.${instance};
# The same file upstream generates and hands the daemon.
runnerConfigFile = (pkgs.formats.yaml { }).generate "config.yaml" instanceCfg.settings;
# In the form the precond reads back from `.runner`.
registeredLabels = lib.concatStringsSep "," (lib.sort lib.lessThan instanceCfg.labels);
in
{
imports = [
../container-modules/swarm-container.nix
@ -321,8 +336,9 @@ in
systemd.tmpfiles.rules = [
"d ${stateDir} 0700 root root -"
# `EnvironmentFile=` fails the unit when the file is missing; an
# empty one leaves `TOKEN` unset for the precond to report.
# `LoadCredential=` fails the unit when the file is missing; an
# empty one lets a registered runner start before any token has
# arrived, and the register step report the missing token.
"f ${tokenFile} 0600 root root -"
];
@ -332,6 +348,8 @@ in
enable = true;
inherit (cfg) name labels;
url = forgeUrl;
# Upstream refuses an instance without a token source, and turns
# this one into the `EnvironmentFile=` the unit below drops.
inherit tokenFile;
settings = {
runner.capacity = cfg.concurrency;
@ -360,54 +378,80 @@ in
environment = lib.mkIf caTrust.useSelfSigned {
NODE_EXTRA_CA_CERTS = caTrust.caContainerPath;
};
# Runs before upstream's pre-start step, which registers whenever
# `.runner` is absent or the token or labels changed. This one
# removes `.runner` in the two cases upstream cannot see, and holds
# the unit down until there is a token.
#
# `+`: root, because `.runner`, the token and the system journal
# are all out of the dynamic user's reach.
serviceConfig.ExecStartPre = lib.mkMerge [
(lib.mkBefore [
"+${pkgs.writeShellScript "swarm-ci-runner-precond" ''
set -eu
runner=${runnerFile}
started=${daemonStartedFile}
# The registration token reaches the unit only as a credential,
# which the register step reads. A host job inherits the daemon's
# whole environment, so neither carries it.
serviceConfig.EnvironmentFile = lib.mkForce [ ];
serviceConfig.LoadCredential = [ "${tokenCredential}:${tokenFile}" ];
# One registration path: upstream's pre-start step registers from
# `$TOKEN`, so this list replaces it.
serviceConfig.ExecStartPre = lib.mkForce [
# Removes `.runner` in the cases where reusing it would fail, so
# the register step after it registers again.
#
# `+`: root, because the system journal is out of the dynamic
# user's reach.
"+${pkgs.writeShellScript "swarm-ci-runner-precond" ''
set -eu
runner=${runnerFile}
started=${daemonStartedFile}
# The forge refused the registration the daemon last started
# with. Read on the next start rather than when the daemon
# exits, so journald has its last lines by then.
if [ -f "$started" ]; then
since="$(< "$started")"
${pkgs.coreutils}/bin/rm -f "$started"
if ${pkgs.systemd}/bin/journalctl -u ${runnerUnit}.service --since "@$since" -o cat --no-pager \
| ${pkgs.gnugrep}/bin/grep -qF ${lib.escapeShellArg rejectedMessage}; then
echo "the forge has no record of this runner's registration; registering again" >&2
${pkgs.coreutils}/bin/rm -f "$runner"
fi
fi
# `.runner` records the address it registered at, and the
# daemon dials that one, not the configured one.
if [ -f "$runner" ] \
&& [ "$(${pkgs.jq}/bin/jq -r .address "$runner")" != ${lib.escapeShellArg forgeUrl} ]; then
echo "$runner names another forge address; registering again at ${forgeUrl}" >&2
# The forge refused the registration the daemon last started
# with. Read on the next start rather than when the daemon
# exits, so journald has its last lines by then.
if [ -f "$started" ]; then
since="$(< "$started")"
${pkgs.coreutils}/bin/rm -f "$started"
if ${pkgs.systemd}/bin/journalctl -u ${runnerUnit}.service --since "@$since" -o cat --no-pager \
| ${pkgs.gnugrep}/bin/grep -qF ${lib.escapeShellArg rejectedMessage}; then
echo "the forge has no record of this runner's registration; registering again" >&2
${pkgs.coreutils}/bin/rm -f "$runner"
fi
fi
# Upstream registers on a changed token hash, an empty token
# included, and a registration with no token fails.
if [ -z "''${TOKEN:-}" ]; then
echo "no registration token yet; swarm-bao-ci-runner on the host delivers it" >&2
exit 1
fi
''}"
])
(lib.mkAfter [
"+${pkgs.writeShellScript "swarm-ci-runner-mark-start" ''
${pkgs.coreutils}/bin/date +%s > ${daemonStartedFile}
''}"
])
# `.runner` records the address it registered at, and the
# daemon dials that one, not the configured one.
if [ -f "$runner" ] \
&& [ "$(${pkgs.jq}/bin/jq -r .address "$runner")" != ${lib.escapeShellArg forgeUrl} ]; then
echo "$runner names another forge address; registering again at ${forgeUrl}" >&2
${pkgs.coreutils}/bin/rm -f "$runner"
fi
# The daemon declares the labels `.runner` records, not the
# configured ones.
if [ -f "$runner" ] \
&& [ "$(${pkgs.jq}/bin/jq -r '.labels | sort | join(",")' "$runner")" != ${lib.escapeShellArg registeredLabels} ]; then
echo "$runner names other labels; registering again with ${registeredLabels}" >&2
${pkgs.coreutils}/bin/rm -f "$runner"
fi
''}"
# Registers only when `.runner` is absent. As the dynamic user,
# so `.runner` is the daemon's own. The derivation itself rather
# than its path, so ../module-eval reads its text without building.
(pkgs.writeShellScript "swarm-ci-runner-register" ''
set -eu
dir="$STATE_DIRECTORY/${instance}"
${pkgs.coreutils}/bin/mkdir -p "$dir"
cd "$dir"
if [ -e .runner ]; then
exit 0
fi
token="$(< "$CREDENTIALS_DIRECTORY/${tokenCredential}")"
if [ -z "$token" ]; then
echo "no registration token yet; swarm-bao-ci-runner on the host delivers it" >&2
exit 1
fi
exec ${lib.getExe runnerCfg.package} register --no-interactive \
--instance ${lib.escapeShellArg instanceCfg.url} \
--token "$token" \
--name ${lib.escapeShellArg instanceCfg.name} \
--labels ${lib.escapeShellArg (lib.concatStringsSep "," instanceCfg.labels)} \
--config ${runnerConfigFile}
'')
"+${pkgs.writeShellScript "swarm-ci-runner-mark-start" ''
${pkgs.coreutils}/bin/date +%s > ${daemonStartedFile}
''}"
];
# Restarted until the token arrives, however long the store and
# the controller take. `mkForce`: upstream sets both.