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
228 lines
8.9 KiB
Nix
228 lines
8.9 KiB
Nix
# `checks.module-eval-forge-placement` — see ./lib.nix for the shared
|
|
# rationale (why this suite exists, naming convention, "evaluates
|
|
# not executes").
|
|
#
|
|
# Where the forge runs. A swarm has one, on the host with
|
|
# `deploy.forgejo.enable`; every other hive is a client of it, and the parts
|
|
# of a split deployment that must not assume every host runs a forge
|
|
# (the OIDC client, the controller's token, the CI runner) have to hold.
|
|
{
|
|
pkgs,
|
|
lib,
|
|
self,
|
|
nixosSystem,
|
|
}:
|
|
let
|
|
inherit
|
|
(import ./lib.nix {
|
|
inherit
|
|
pkgs
|
|
lib
|
|
self
|
|
nixosSystem
|
|
;
|
|
})
|
|
hive
|
|
runGroup
|
|
;
|
|
|
|
bare = hive { };
|
|
allLocal = hive { deploy.singleHostSwarm = true; };
|
|
servicesHere = hive { deploy.allSwarmServices = true; };
|
|
forgeHere = hive { deploy.forgejo.enable = true; };
|
|
|
|
# The shared services here, the forge somewhere else. Every derivation in
|
|
# ../host-modules/swarm-required-services.nix is `mkDefault`, so this stays
|
|
# expressible — and it is also the authelia-without-forge host the OIDC
|
|
# client case needs.
|
|
servicesForgeElsewhere = hive {
|
|
deploy.allSwarmServices = true;
|
|
deploy.forgejo.enable = false;
|
|
};
|
|
|
|
# The forge without authelia: the other half of the split.
|
|
forgeNoAuthelia = hive {
|
|
deploy.forgejo.enable = true;
|
|
deploy.forgejo.sso.clientSecretFile = "/var/lib/forgejo-oidc/by-hand.secret";
|
|
};
|
|
|
|
# The runner alone: no hive, no forge, no store, its store identity placed
|
|
# by hand.
|
|
ciAlone = hive {
|
|
deploy.hive-controller.enable = false;
|
|
deploy.ci-runner.enable = true;
|
|
deploy.bao.ciRunnerClientCertFile = "/etc/pki/bao-ci-runner.pem";
|
|
deploy.bao.ciRunnerClientKeyFile = "/etc/pki/bao-ci-runner-key.pem";
|
|
};
|
|
ciNoIdentity = hive {
|
|
deploy.hive-controller.enable = false;
|
|
deploy.ci-runner.enable = true;
|
|
};
|
|
|
|
# A host config that still names the runner under the forge.
|
|
ciUnderForge = hive { deploy.forgejo.ci.enable = true; };
|
|
|
|
runsForge = m: m.services.hyperhive.deploy.forgejo.enable && m.containers ? hive-forge;
|
|
|
|
forgeClientIds =
|
|
m:
|
|
map (c: c.id) (
|
|
lib.filter (
|
|
c: c.id == m.services.hyperhive.swarm.forge.sso.clientId
|
|
) m.services.hyperhive.swarm.authelia.oidc.clients
|
|
);
|
|
|
|
# Matched on the option the message names, same reasoning as
|
|
# ./grafana.nix's `grafanaRefusedFor`.
|
|
refusedOver = m: needle: lib.any (a: !a.assertion && lib.hasInfix needle a.message) m.assertions;
|
|
|
|
tokenFile = m: m.services.hyperhive.deploy.swarm-controller.forgeTokenFile;
|
|
forgePath = "/var/lib/hyperhive-forge/swarm-controller.token";
|
|
|
|
forgeSettings = forgeHere.containers.hive-forge.config.services.forgejo.settings;
|
|
|
|
runnerService = ciAlone.containers.swarm-ci-runner.config.systemd.services.gitea-runner-swarm;
|
|
registerStep = lib.findFirst lib.isDerivation null runnerService.serviceConfig.ExecStartPre;
|
|
|
|
cases = [
|
|
{
|
|
# The absence the whole option exists for: a second forge in a swarm
|
|
# is a split brain nobody notices, so a hive that has not been told it
|
|
# is the forge's host runs none of its surface.
|
|
name = "a hive that is not the forge's host runs no forge";
|
|
ok =
|
|
!bare.services.hyperhive.deploy.forgejo.enable
|
|
&& !(bare.containers ? hive-forge)
|
|
&& !(bare.systemd.services ? hive-forge-swarm-controller-token)
|
|
&& !(lib.elem "forge.t.local" bare.services.hyperhive.gateway.localNames)
|
|
&& !(refusedOver bare "deploy.forgejo.sso.clientSecretFile");
|
|
}
|
|
{
|
|
name = "hosting the swarm's shared services runs the forge";
|
|
ok = runsForge servicesHere;
|
|
}
|
|
{
|
|
name = "the all-local mode runs the forge";
|
|
ok = runsForge allLocal && lib.elem "forge.t.local" allLocal.services.hyperhive.gateway.localNames;
|
|
}
|
|
{
|
|
name = "an explicit deploy.forgejo.enable runs the forge on its own";
|
|
ok = runsForge forgeHere && !forgeHere.services.hyperhive.deploy.allSwarmServices;
|
|
}
|
|
{
|
|
# `mkDefault`, not a plain assignment: the forge stays placeable on a
|
|
# host of its own. `nats` is the control, so the case cannot pass on a
|
|
# fixture where nothing came on.
|
|
name = "placing the forge elsewhere survives the switch that would enable it";
|
|
ok =
|
|
!(servicesForgeElsewhere.containers ? hive-forge)
|
|
&& servicesForgeElsewhere.services.hyperhive.deploy.nats.enable;
|
|
}
|
|
{
|
|
# A client is a row in authelia's config, so it is declared where
|
|
# authelia runs. With the forge's module gated, registering it from
|
|
# there would leave a split swarm's forge unknown to its IdP.
|
|
name = "the forge's OIDC client is registered wherever authelia runs, and only there";
|
|
ok =
|
|
forgeClientIds servicesForgeElsewhere == [ "forgejo" ]
|
|
&& forgeClientIds allLocal == [ "forgejo" ]
|
|
&& forgeClientIds forgeNoAuthelia == [ ];
|
|
}
|
|
{
|
|
name = "the forge's OIDC callback is the one forgejo sends";
|
|
ok =
|
|
servicesForgeElsewhere.services.hyperhive.swarm.forge.sso.redirectUri
|
|
== "https://forge.t.local/user/oauth2/authelia/callback";
|
|
}
|
|
{
|
|
# The runner reaches the forge by name and reads its token with its own
|
|
# certificate, so nothing about the host it runs on is asked.
|
|
name = "the CI runner runs on a host with neither the forge nor a hive";
|
|
ok =
|
|
ciAlone.containers ? swarm-ci-runner
|
|
&& !(ciAlone.containers ? hive-forge)
|
|
&& !ciAlone.services.hyperhive.deploy.hive-controller.enable
|
|
&& !(refusedOver ciAlone "ci-runner")
|
|
&&
|
|
ciAlone.systemd.services.swarm-bao-ci-runner.environment.BAO_CLIENT_CERT
|
|
== "/etc/pki/bao-ci-runner.pem";
|
|
}
|
|
{
|
|
# Without the identity the token never arrives; the control is the
|
|
# placed pair above.
|
|
name = "the CI runner is refused without its store identity";
|
|
ok = refusedOver ciNoIdentity "deploy.bao.ciRunnerClientCertFile";
|
|
}
|
|
{
|
|
# A host job inherits the daemon's whole environment, and a token there
|
|
# lets any workflow register runners against the forge.
|
|
name = "the CI runner daemon carries no registration token in its environment";
|
|
ok =
|
|
let
|
|
sc = runnerService.serviceConfig;
|
|
in
|
|
!(runnerService.environment ? TOKEN)
|
|
&& lib.toList (sc.EnvironmentFile or [ ]) == [ ]
|
|
&& !(lib.any (lib.hasPrefix "TOKEN=") (lib.toList (sc.Environment or [ ])))
|
|
&& sc.LoadCredential == [ "registration-token:/var/lib/swarm-ci-runner/registration-token" ];
|
|
}
|
|
{
|
|
# The only reader of the credential, and the only registration path.
|
|
name = "the CI runner registers from its credential, and only without a .runner";
|
|
ok =
|
|
let
|
|
text = registerStep.text;
|
|
beforeRegister = lib.head (lib.splitString "register --no-interactive" text);
|
|
in
|
|
registerStep != null
|
|
&& !(lib.any (
|
|
s: lib.hasInfix "gitea-register-runner" (toString s)
|
|
) runnerService.serviceConfig.ExecStartPre)
|
|
&& lib.hasInfix "if [ -e .runner ]; then\n exit 0\nfi" beforeRegister
|
|
&& lib.hasInfix ''token="$(< "$CREDENTIALS_DIRECTORY/registration-token")"'' beforeRegister
|
|
&& lib.hasInfix ''--token "$token"'' text
|
|
&& !(lib.hasInfix "export" text)
|
|
&& !(lib.hasInfix "TOKEN" text);
|
|
}
|
|
{
|
|
# A removed option evaluates to an assertion naming the replacement.
|
|
name = "a CI option under deploy.forgejo names its replacement under deploy.ci-runner";
|
|
ok = refusedOver ciUnderForge "services.hyperhive.deploy.ci-runner.enable instead";
|
|
}
|
|
{
|
|
# Nothing writes the delivery path away from the forge, and a
|
|
# `LoadCredential=` naming a missing path is fatal to the unit.
|
|
name = "the controller's forge token defaults to the delivery path only where the forge runs";
|
|
ok =
|
|
tokenFile bare == null
|
|
&& tokenFile servicesForgeElsewhere == null
|
|
&& tokenFile forgeHere == forgePath
|
|
&& tokenFile allLocal == forgePath;
|
|
}
|
|
{
|
|
# The only thing that makes a human's forge account: nothing else
|
|
# creates one.
|
|
name = "a first authelia login creates the forge account, named by preferred_username";
|
|
ok =
|
|
let
|
|
o = forgeSettings.oauth2_client;
|
|
in
|
|
o.ENABLE_AUTO_REGISTRATION == true && o.USERNAME == "preferred_username";
|
|
}
|
|
{
|
|
# `auto` would give an SSO user whatever local account carries their
|
|
# name — an agent's, or `core`'s.
|
|
name = "an SSO login adopts an existing forge account only with that account's password";
|
|
ok = forgeSettings.oauth2_client.ACCOUNT_LINKING == "login";
|
|
}
|
|
{
|
|
# Both halves: local sign-up stays off, and nothing turns on the one
|
|
# setting forgejo's auto-registration does check.
|
|
name = "local sign-up stays off without blocking the SSO registration";
|
|
ok =
|
|
forgeSettings.service.DISABLE_REGISTRATION == true
|
|
&& !(forgeSettings.service.ALLOW_ONLY_INTERNAL_REGISTRATION or false);
|
|
}
|
|
];
|
|
in
|
|
runGroup "forge-placement" cases
|