Watch
0
0
Fork
You've already forked hyperhive
0

swarm-ci-runner: run the forge CI runner as a swarm service

The CI runner was a hive deploy module: hive-ci.nix ran it beside the
forge, and hive-c0re registered it through the forge's admin API with
the `core` token, writing the registration token through hive-priv. It
could only run on the forge's host, and it kept an admin-scoped forge
token on a hive.

Now the runner is a swarm service:

- swarm-ci-runner.nix runs it in a `swarm-ci-runner` container on the
  host that sets deploy.ci-runner.enable, reaching the forge by name.
  It needs neither the forge nor a hive there.
- swarm-controller reads the forge's runner registration token with its
  own admin token and writes it to swarm/services/ci-runner/
  registration-token whenever it differs from what it last wrote. Its
  grant on that path is create/update only.
- The runner's own leaf (CN swarm-ci-runner, role and policy of the same
  name) reads that one path. swarm-bao-ci-runner.service fetches it into
  the runner's EnvironmentFile and restarts the runner on a change.
- A pre-start step removes .runner when the daemon's journal since its
  last start holds Forgejo's "unregistered runner" refusal (the daemon
  exits 1 when its startup Declare gets it), or when .runner names
  another forge address; upstream's pre-start step then registers again.

Removed from the hive side: hive-ci.nix and deploy.forgejo.ci.*,
forge/ci_runner.rs, PrivRequest::RegisterCiRunner and its handler and
ReadWritePaths entry, InfraContainer::Ci with hivectl's --ci and the
dashboard's hive-ci journal entry, HYPERHIVE_FORGE_CI_ENABLED, and
read:admin,write:admin from CORE_TOKEN_SCOPES: nothing else in hive-c0re
calls /api/v1/admin/*. The old ci options, under both swarm.forge.ci and
deploy.forgejo.ci, now fail evaluation naming deploy.ci-runner.

The forge's half of the old ci.enable, the actions/checkout mirror and
DEFAULT_ACTIONS_URL = self, is deploy.forgejo.actionsFromSelf.

Closes #4954
This commit is contained in:
atlas 2026-10-05 02:25:05 +02:00 • committed by mara
commit e2da5509ae
60 changed files with 1088 additions and 1201 deletions

View file

@ -170,7 +170,7 @@ let
_: u: (u.environment.BAO_CLIENT_CERT or null) == granterCertFile
) baoGrantWithConsumers.systemd.services;
# The thirteen units that write a `swarm-*` grant, by name, for the discovery
# The fourteen units that write a `swarm-*` grant, by name, for the discovery
# control below.
grantingUnitNames = [
"swarm-bao-controller-policy"
@ -186,6 +186,7 @@ let
"swarm-bao-nats-auth-policy"
"swarm-bao-operator-viewer-policy"
"swarm-bao-hive-leaf-policy"
"swarm-bao-ci-runner-policy"
];
# Comment lines dropped first: both the HCL and the scripts explain
@ -794,24 +795,24 @@ let
}
{
# A store host without the granter's pair writes its grants some other
# way, so none of the thirteen units may exist. Without this arm
# way, so none of the fourteen units may exist. Without this arm
# `lib.mkIf haveGranter` could be dropped from any of them and every other
# case here would still pass.
name = "without the granter's pair none of the thirteen granting units render";
name = "without the granter's pair none of the fourteen granting units render";
ok =
let
s = baoGranterOptOut.systemd.services;
in
lib.all (unit: !(s ? ${unit})) (grantingUnitNames ++ [ "swarm-bao-granter-role" ])
# The control: the same store with the pair renders all thirteen.
# The control: the same store with the pair renders all fourteen.
&& lib.all (unit: baoGrantHere.systemd.services ? ${unit}) grantingUnitNames;
}
{
# 🩸 What replaced the silent skip. With no bootstrap token the thirteen still
# 🩸 What replaced the silent skip. With no bootstrap token the fourteen still
# render, and a refused granter fails them with the step that fixes it.
# A store host that never named a token is told to name one, since the
# unit that sets the granter up renders only where it has.
name = "a store host without a bootstrap token renders the thirteen, each failing loudly with the one-time step";
name = "a store host without a bootstrap token renders the fourteen, each failing loudly with the one-time step";
ok =
let
s = baoGranterNoToken.systemd.services;
@ -1254,6 +1255,62 @@ let
&& !(lib.hasInfix "secret/data/swarm/hives/*" s)
&& !(lib.hasInfix "secret/metadata/swarm/hives" s);
}
{
# `forge::runner_token` writes the runner's registration token and keeps
# the last value in memory, so its stanza has no `read`. Pinned as the
# whole stanza, so an added capability fails, and spelled to the leaf:
# `services/*` would reach every swarm service's OIDC client secret.
name = "the controller writes the CI runner's registration token and cannot read it back";
ok =
let
s = baoGrantHere.systemd.services.swarm-bao-controller-policy.script;
in
lib.hasInfix "path \"secret/data/swarm/services/ci-runner/registration-token\" {\n capabilities = [\"create\", \"update\"]\n}" s
&& !(lib.hasInfix "secret/data/swarm/services/*" s);
}
{
# The runner's role is the token's only reader. Its policy is the one
# leaf, read-only, under the runner's own subject.
name = "the CI runner's grant is its registration token and nothing else";
ok =
let
s = baoGrantHere.systemd.services.swarm-bao-ci-runner-policy.script;
in
lib.hasInfix "path \"secret/data/swarm/services/ci-runner/registration-token\" {" s
&& lib.hasInfix "capabilities = [\"read\"]" s
&& lib.length (lib.filter lib.isList (builtins.split "path \"" s)) == 1
&& !(lib.hasInfix "secret/data/swarm/services/*" s)
&& !(lib.hasInfix "secret/data/swarm/agents" s)
&& !(lib.hasInfix "secret/data/swarm/hives" s)
&& lib.hasInfix "auth/cert/certs/swarm-ci-runner" s
&& lib.hasInfix "allowed_common_names=swarm-ci-runner" s
&& lib.hasInfix "token_policies=swarm-ci-runner" s;
}
{
# Read access to the token is a grant on its path, so the policy units
# that name it are the whole list of who reaches it: the controller,
# which writes it, and the runner. The publisher's `services/*` write
# also covers it and holds no read.
name = "no granting unit but the controller's and the CI runner's names the runner's token";
ok =
let
naming = lib.filter (
u: lib.hasInfix "ci-runner/registration-token" baoGrantHere.systemd.services.${u}.script
) grantingUnitNames;
in
lib.sort lib.lessThan naming == [
"swarm-bao-ci-runner-policy"
"swarm-bao-controller-policy"
];
}
{
name = "the PKI unit signs the CI runner's leaf under its own subject";
ok =
let
s = baoGrantHere.systemd.services.swarm-bao-pki.script;
in
lib.hasInfix "/ci-runner.pem ]" s && lib.hasInfix "swarm-ci-runner \"\" clientAuth" s;
}
{
# Revocation, and the reason it is a stanza of its own: `delete` on the
# `data/` path soft-deletes the newest version and leaves earlier ones
@ -1386,8 +1443,8 @@ let
}
{
# What makes the case above mean something: discovery by the granter's
# certificate reaches all thirteen units, and each yields calls.
name = "the granter-policy check sees all thirteen granting units, and parses calls from each";
# certificate reaches all fourteen units, and each yields calls.
name = "the granter-policy check sees all fourteen granting units, and parses calls from each";
ok =
lib.sort lib.lessThan (lib.attrNames granterUnits) == lib.sort lib.lessThan grantingUnitNames
&& lib.all (u: baoCalls u.script != [ ]) (lib.attrValues granterUnits)

View file

@ -47,10 +47,10 @@ let
# only hive without one is a hive told to have none.
noAgentQueue = hiveWithController { deploy.hive-controller.queue.agentNatsUrl = null; };
# On the forge's host: the runner is refused anywhere else.
# On the store's host, which mints the runner's store identity.
withCi = hive {
deploy.forgejo.enable = true;
deploy.forgejo.ci.enable = true;
deploy.bao.enable = true;
deploy.ci-runner.enable = true;
};
# A priority collision is a property of the *option*, not
@ -63,7 +63,7 @@ let
# stack (measured — this is not a hypothetical).
forceCiServiceConfigs =
let
svcs = withCi.containers.hive-ci.config.systemd.services;
svcs = withCi.containers.swarm-ci-runner.config.systemd.services;
vals = lib.concatMap (s: builtins.attrValues (s.serviceConfig or { })) (builtins.attrValues svcs);
in
builtins.foldl' (acc: v: builtins.seq v acc) true vals;
@ -245,7 +245,7 @@ let
# `gitea-actions-runner.nix` adds a plain `serviceConfig.*`
# definition that collides with one of ours, and nix refuses to
# merge two plain definitions at *host* eval. No other check
# instantiates a host with `containers.hive-ci` actually enabled, so
# instantiates a host with `containers.swarm-ci-runner` actually enabled, so
# the collision only surfaces on operator deploy, not in CI.
name = "the CI container's unit definitions merge without a priority collision";
ok = forceCiServiceConfigs;

View file

@ -46,11 +46,21 @@ let
deploy.forgejo.sso.clientSecretFile = "/var/lib/forgejo-oidc/by-hand.secret";
};
ciNoForge = hive { deploy.forgejo.ci.enable = true; };
ciWithForge = hive {
deploy.forgejo.enable = true;
deploy.forgejo.ci.enable = true;
# 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;
@ -122,13 +132,28 @@ let
== "https://forge.t.local/user/oauth2/authelia/callback";
}
{
# The runner reaches the forge through this host's gateway and is
# registered through the local container. The second arm is the
# control: a refusal that fires everywhere is not a check.
name = "CI is refused on a host that does not run the forge";
# 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 =
refusedOver ciNoForge "deploy.forgejo.enable on the same host"
&& !(refusedOver ciWithForge "deploy.forgejo.enable on the same host");
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 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

View file

@ -116,6 +116,12 @@ let
deploy.forgejo.enable = true;
deploy.forgejo.mirrors = [ aMirror ];
};
controllerWithSelfActions = hive {
deploy.swarm-controller.enable = true;
deploy.forgejo.enable = true;
deploy.forgejo.mirrors = [ aMirror ];
deploy.forgejo.actionsFromSelf = true;
};
# The controller with every shared service on another host, and the same
# controller hosting them all: the swarm UI's links must not tell the two
@ -150,7 +156,7 @@ let
serviceContainersWithCi = hive {
deploy.hive-controller.enable = false;
deploy.allSwarmServices = true;
deploy.forgejo.ci.enable = true;
deploy.ci-runner.enable = true;
};
serviceContainers = serviceContainersWithCi.containers;
cases = [
@ -412,7 +418,7 @@ let
name = "a service container on a netns of its own keeps its firewall and resolvconf";
ok =
let
c = serviceContainers.hive-ci.config;
c = serviceContainers.swarm-ci-runner.config;
in
c.networking.firewall.enable && c.networking.resolvconf.enable;
}
@ -446,6 +452,22 @@ let
in
warns mirrorsNoController && !(warns controllerWithMirrors);
}
{
# The mirror is what `self` resolves `actions/checkout` to, so the two
# come together. The control is the same forge without the option.
name = "actionsFromSelf adds the checkout mirror and resolves actions from the forge";
ok =
let
mirrorsOf =
m: builtins.fromJSON m.systemd.services.swarm-controller.environment.SWARM_CONTROLLER_FORGE_MIRRORS;
actionsUrlOf =
m: m.containers.hive-forge.config.services.forgejo.settings.actions.DEFAULT_ACTIONS_URL or null;
in
lib.elem "actions/checkout" (map (m: m.dest) (mirrorsOf controllerWithSelfActions))
&& actionsUrlOf controllerWithSelfActions == "self"
&& mirrorsOf controllerWithMirrors == [ aMirror ]
&& actionsUrlOf controllerWithMirrors == null;
}
{
name = "the old c0re org-avatar option lands on the controller's unit";
ok =