nix: run the forge on one host per swarm (deploy.forgejo.enable)

Every hive with hyperhive enabled ran its own hive-forge container, and
its gateway answered forge.<swarm> with its own bridge IP, so on a
multi-host swarm each hive talked to its own forge.

deploy.forgejo.enable defaults to false and allSwarmServices sets it with
mkDefault, like authelia and bao; singleHostSwarm gets it through that.
The forge's OIDC client moves to a glue module gated on authelia, so a
split authelia/forge swarm still registers it. CI now requires the forge
on the same host, and the controller's forgeTokenFile defaults to null
where the forge is not.

Closes #4705
Refs #3782
This commit is contained in:
atlas 2026-09-24 19:44:04 +02:00 • committed by mara
commit 978164dc53
15 changed files with 346 additions and 84 deletions

View file

@ -46,7 +46,11 @@ let
# only hive without one is a hive told to have none.
noAgentQueue = hive { deploy.hive-controller.queue.agentNatsUrl = null; };
withCi = hive { deploy.forgejo.ci.enable = true; };
# On the forge's host: the runner is refused anywhere else.
withCi = hive {
deploy.forgejo.enable = true;
deploy.forgejo.ci.enable = true;
};
# A priority collision is a property of the *option*, not
# of the merged value's interior — nix throws the moment the value is
@ -90,16 +94,16 @@ let
(hive { deploy.forgejo.behindGateway = false; }).services.hyperhive.swarm.forge.publicUrl == null;
}
{
# The controller's token path defaulted to forge's delivery path only on
# a host with the central toggle on, and to `null` otherwise. Forge
# deploys unconditionally, so the path is now unconditional too.
name = "the swarm controller's forgeTokenFile defaults to forge's delivery path regardless of the central toggle";
# The controller's token path follows where the forge runs
# (./forge-placement.nix), never the central toggle: a forge host with
# the toggle off still renders the path.
name = "the swarm controller's forgeTokenFile default does not consult the central toggle";
ok =
let
forgePath = "/var/lib/hyperhive-forge/swarm-controller.token";
in
bare.services.hyperhive.deploy.swarm-controller.forgeTokenFile == forgePath
&& centralToggleOff.services.hyperhive.deploy.swarm-controller.forgeTokenFile == forgePath;
(hive {
enable = false;
deploy.forgejo.enable = true;
}).services.hyperhive.deploy.swarm-controller.forgeTokenFile
== "/var/lib/hyperhive-forge/swarm-controller.token";
}
{
name = "a hive that does not host the swarm's shared services runs none of them";

View file

@ -0,0 +1,144 @@
# `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 used to lean on every host running a forge
# (the OIDC client, the controller's token, the CI runner) still 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";
};
ciNoForge = hive { deploy.forgejo.ci.enable = true; };
ciWithForge = hive {
deploy.forgejo.enable = true;
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";
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 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";
ok =
refusedOver ciNoForge "deploy.forgejo.enable on the same host"
&& !(refusedOver ciWithForge "deploy.forgejo.enable on the same host");
}
{
# 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;
}
];
in
runGroup "forge-placement" cases