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

@ -3,10 +3,11 @@
# the package/source wiring; see flake.nix). One import covers
# everything; `services.hyperhive.enable = true` turns the stack on.
#
# The forge is mandatory — hive-c0re mirrors every agent's applied
# Every swarm needs the forge — hive-c0re mirrors every agent's applied
# config repo into it and it's the canonical store for the meta flake
# + `internal/*` repos, so there's no enable toggle; it deploys with
# hyperhive itself. hive-matrix is opt-in (off by default). All
# + `internal/*` repos — but only the host with `deploy.forgejo.enable`
# runs it (derived from `deploy.allSwarmServices`, see
# ./swarm-required-services.nix). hive-matrix is opt-in (off by default). All
# subsystems rely on `services.hyperhive.domain`, which is required
# (asserted in hive-network.nix) whenever hyperhive is enabled.
{
@ -26,6 +27,7 @@
./glue-bao-readers-policy-order.nix
./glue-bao-tls.nix
./glue-controller-bao-identity.nix
./glue-forge-oidc-client.nix
./glue-grafana-oidc-client.nix
./glue-matrix-bao-token.nix
./glue-matrix-ctl-bao-identity.nix

View file

@ -418,14 +418,33 @@ in
)
];
# ⚠️ `deploy.forgejo` is declared in ./hive-ci.nix, not here, and it is the
# one entry with no `enable`: the forge is not optional — it is the canonical
# store for the meta flake and every agent's config repo, so it deploys with
# hyperhive itself. Running the CI runner is the only *deployment* decision
# it has, which is exactly the `{ enable; ci; }` shape the header describes,
# minus the half that does not apply. The knobs live with the module that
# reads them; this file stays the registry of toggles.
# ⚠️ Only `deploy.forgejo.enable` is declared here. The rest of
# `deploy.forgejo` is in ./hive-forge/default.nix and ./hive-ci.nix: the
# knobs live with the module that reads them; this file stays the registry
# of toggles.
options.services.hyperhive.deploy = {
forgejo.enable = lib.mkOption {
type = lib.types.bool;
default = false;
example = true;
description = ''
Run the swarm's forge in a `hive-forge` container on this host. A
swarm has one forge, so one host turns this on. It is the
canonical store for the meta flake and every agent's config repo,
so the swarm needs it, but *this* host running it is a decision
like any other shared service's.
With it off, this hive is a *client*: it reaches the swarm's
forge at {option}`services.hyperhive.swarm.forge.domain`, which
the operator's DNS must resolve to the forge's host. No container
is created, and an existing one's state stays on disk under
`/var/lib/nixos-containers/hive-forge/`.
{option}`services.hyperhive.deploy.allSwarmServices` turns it on,
and `singleHostSwarm` through that.
'';
};
grafana.enable = lib.mkOption {
type = lib.types.bool;
default = false;

View file

@ -0,0 +1,40 @@
# Glue: register the swarm's forge as an OIDC client wherever authelia runs.
#
# ONE PAIRING PER FILE — forge ← authelia, and nothing else. Deleting this
# leaves a swarm whose forge is not a client authelia has ever heard of, so
# its "sign in with" button can never complete a login and nothing minted its
# secret either.
#
# ⚠️ Gated on authelia being HERE, and deliberately NOT on this host running
# the forge. A client is a row in THIS host's provider config, so it can only be
# declared where that config is rendered — and ./hive-forge/default.nix's whole
# `config` block hangs off `deploy.forgejo.enable`, so a swarm with the forge
# and authelia on different hosts would register the client nowhere at all.
# Same shape as ./glue-grafana-oidc-client.nix, for the same reason.
#
# Unlike Grafana's, this client is never unused: every swarm runs a forge.
{
lib,
config,
...
}:
let
hyperhiveCfg = config.services.hyperhive;
deployCfg = hyperhiveCfg.deploy;
forgeCfg = hyperhiveCfg.swarm.forge;
in
{
config = lib.mkIf (hyperhiveCfg.enable && deployCfg.authelia.enable) {
# One declaration, two readers. The forge knows its own callback URL;
# making the operator restate it in authelia's client list would be a
# second source of truth for a string whose mismatch is a silent
# rejected login.
services.hyperhive.swarm.authelia.oidc.clients = [
{
id = forgeCfg.sso.clientId;
description = "HyperHive forge";
redirectUris = [ forgeCfg.sso.redirectUri ];
}
];
};
}

View file

@ -9,9 +9,8 @@
# declared where that config is rendered — and ./swarm-grafana.nix's whole
# `config` block hangs off `deploy.grafana.enable`, so a swarm with Grafana and
# authelia on different hosts registered the client nowhere at all.
# ./hive-forge/default.nix is already on the right side of that line: its module
# is gated on `hyperhive.enable` and only the registration asks about authelia.
# This file puts Grafana there without moving the rest of its module.
# ./glue-forge-oidc-client.nix does the same for the forge. This file puts
# Grafana there without moving the rest of its module.
#
# ⚠️ Registered whether or not the swarm has a Grafana, because nothing in
# `swarm.*` records that — `deploy.grafana.enable` answers "does THIS host run

View file

@ -82,8 +82,9 @@ in
Run a Forgejo Actions runner in a `hive-ci` nixos-container.
Grouped under `services.hyperhive.deploy.forgejo` because the runner
is tightly coupled to the forge instance it registers against.
Disabled by default; the internal forge it registers against is
always present (mandatory), so enabling this is all that's needed.
Disabled by default. It registers against the forge on this same
host, so it requires
{option}`services.hyperhive.deploy.forgejo.enable` here.
On first start the container auto-registers against hive-forge using
hive-c0re's admin token — no manual token provisioning needed.
@ -170,6 +171,20 @@ in
Set behindGateway = true (it is the default).
'';
}
{
# The runner reaches the forge through THIS host's gateway, and
# hive-c0re registers it through the local forge container; neither
# exists on a host that does not run the forge. A runner on another
# host, registered by the swarm instead, is a separate design.
assertion = forgeDeployCfg.enable;
message = ''
services.hyperhive.deploy.forgejo.ci.enable requires
services.hyperhive.deploy.forgejo.enable on the same host.
The CI runner reaches the forge through this host's gateway and
is registered through the local forge container, so it can only
run where the forge does. Enable CI on the swarm's forge host.
'';
}
];
# The runner's journal, named as the unit is *inside* the container —

View file

@ -105,8 +105,9 @@ let
in
{
# Private Forgejo in a `hive-forge` nixos-container, shared host
# netns. Agents reach it at `forge.<domain>` via the gateway. State
# at `/var/lib/nixos-containers/hive-forge/var/lib/forgejo/` survives
# netns, on the one host with `deploy.forgejo.enable`. Agents reach it
# at `forge.<domain>` via the gateway. State at
# `/var/lib/nixos-containers/hive-forge/var/lib/forgejo/` survives
# restart. See `docs/networking/gateway.md::hive-forge container shape`.
# External Forgejo/Gitea/Codeberg-compatible forges (beyond the mandatory
@ -138,10 +139,10 @@ in
'')
];
# The internal forge is mandatory — it's the canonical store for the
# meta flake + every agent's config repo (and the `internal/*` repos),
# so there is no enable/disable toggle. It deploys whenever hyperhive
# itself is enabled (`services.hyperhive.enable`).
# Every swarm needs this forge — it's the canonical store for the meta
# flake + every agent's config repo (and the `internal/*` repos) — but
# only one host runs it: `deploy.forgejo.enable`, in ../deploy.nix. What
# follows is what every hive needs to reach it, wherever it runs.
options.services.hyperhive.swarm.forge = {
httpPort = lib.mkOption {
type = lib.types.port;
@ -252,8 +253,8 @@ in
};
# The swarm's authelia is always registered as an OpenID Connect
# login source here — there is no toggle, for the same reason the
# forge itself has none.
# login source here — there is no toggle: a forge without it has no
# way to log a person in through the swarm's SSO.
#
# **Additive, never exclusive.** Forgejo keeps its local password
# database and gains an extra "sign in with" button; this does not
@ -270,6 +271,29 @@ in
`services.hyperhive.swarm.authelia.oidc.clients`.
'';
};
redirectUri = lib.mkOption {
type = lib.types.str;
readOnly = true;
default = ssoRedirectUri;
defaultText = lib.literalExpression ''"''${ROOT_URL}user/oauth2/authelia/callback"'';
description = ''
OAuth2 callback authelia sends the browser back to, and the URI
it matches **exactly**.
Read-only: forgejo derives it from its own `ROOT_URL` and the
login source's name, so it is a fact other modules read rather
than a knob. The glue that registers this client wherever
authelia runs reads it from here instead of restating the
format.
⚠️ With {option}`services.hyperhive.swarm.forge.rootUrl` unset,
`ROOT_URL` follows
{option}`services.hyperhive.deploy.forgejo.behindGateway` and
the gateway's `httpsPort`, which are per-host. An authelia host
that is not the forge's host renders the forge's callback only
if the two agree on them; set `rootUrl` if they do not.
'';
};
# The secret half is a path on the host that runs the forge, so it
# lives under `deploy.forgejo.sso` — see the block below.
};
@ -435,7 +459,7 @@ in
};
};
config = lib.mkIf config.services.hyperhive.enable {
config = lib.mkIf (config.services.hyperhive.enable && deployCfg.forgejo.enable) {
# Same principle as the vhost below — this service's own surface lives
# with the service. The SSO source is named alongside forgejo because
# its failure mode is a login that silently falls back, not an error.
@ -1194,20 +1218,10 @@ in
};
};
# One declaration, two readers. The forge knows its own callback URL;
# making the operator restate it in authelia's client list would be a
# second source of truth for a string whose mismatch is a silent
# rejected login.
services.hyperhive.swarm.authelia.oidc.clients = lib.mkIf ssoLocal [
{
id = cfg.sso.clientId;
description = "HyperHive forge";
redirectUris = [ ssoRedirectUri ];
}
];
# Same case, same reasoning: this host minted the secret, so it can
# say where the forge will find it.
# The client this login source authenticates as is registered by
# ../glue-forge-oidc-client.nix, wherever authelia runs. When authelia
# is here too, this host minted the secret, so it can say where the
# forge will find it.
services.hyperhive.deploy.forgejo.sso.clientSecretFile = lib.mkIf ssoLocal (
lib.mkDefault forgeSecretPath
);

View file

@ -502,16 +502,17 @@ in
forgeTokenFile = lib.mkOption {
type = lib.types.nullOr lib.types.str;
# The forge has no `enable` of its own to condition this on —
# neither half of its split namespace carries one — and it deploys
# unconditionally wherever the rest of the stack does, so its own
# delivery path is simply the right default. The controller's units
# are the thing that decides whether the file is ever read: they only
# exist under `deploy.swarm-controller.enable`, and a host whose forge
# lives elsewhere overrides this (or sets `null`) explicitly.
default = deployCfg.forgejo.hostSwarmControllerTokenFile;
# Forge's own delivery path when the forge runs here, since nothing
# writes that path anywhere else. `null` otherwise, so a controller
# away from the forge logs that it has no forge access instead of
# waiting on a file that never appears. The controller's units are
# the thing that decides whether the file is ever read: they only
# exist under `deploy.swarm-controller.enable`.
default = if deployCfg.forgejo.enable then deployCfg.forgejo.hostSwarmControllerTokenFile else null;
defaultText = lib.literalExpression ''
config.services.hyperhive.deploy.forgejo.hostSwarmControllerTokenFile
if config.services.hyperhive.deploy.forgejo.enable
then config.services.hyperhive.deploy.forgejo.hostSwarmControllerTokenFile
else null
'';
example = "/var/lib/secrets/swarm-controller-forge.token";
description = ''
@ -520,11 +521,10 @@ in
`forgejo-swarm-controller-account` + `hive-forge-swarm-controller-token`
units, which mint and collect it onto forge's own host).
Defaults to forge's own delivery path (forge deploys
unconditionally alongside the rest of the stack — see
`hive-forge/default.nix`, it has no `enable` of its own).
Override explicitly if forge's actual token file ends up
somewhere else — copy it out of forge's
Defaults to forge's own delivery path when
{option}`services.hyperhive.deploy.forgejo.enable` is set on this
host, and to `null` otherwise. Set it explicitly when the forge
runs on another host — copy the token out of forge's
{option}`services.hyperhive.deploy.forgejo.hostSwarmControllerTokenFile`
with whatever secret management this deployment already uses,
the same shape `swarm.nix`'s `clientSecretFile` documents for

View file

@ -4,10 +4,8 @@
# where they live, and asserts the per-service `enable`s that follow —
# the same mode-not-default shape as ./local-defaults.nix, one tier down.
#
# Only the *optional* services derive. The forge has no `enable` to
# assert, because it is not optional — it is the canonical store for the
# meta flake and every agent's config repo, so it deploys with hyperhive
# itself.
# The forge derives from here like the rest: every swarm needs one, but
# only one host in it runs it.
{
lib,
config,
@ -23,20 +21,16 @@ in
example = true;
description = ''
Host the swarm's shared services on this hive. The services that
exist once per swarm rather than once per hive and are *optional*
— the matrix homeserver, the SSO provider, the queue, the metrics
and log stores — have their toggle asserted from this, so a
swarm's service host is declared in one place.
exist once per swarm rather than once per hive — the forge, the
matrix homeserver, the SSO provider, the queue, the metrics and log
stores — have their toggle asserted from this, so a swarm's service
host is declared in one place.
Every toggle it asserts is a {option}`services.hyperhive.deploy.*`
one, because "does THIS host run it" is a per-host decision — which
is the same reason this option is a `deploy.*` one itself. See
./deploy.nix.
The forge is swarm-wide too but has nothing to assert: it is the
canonical store for the meta flake and every agent's config repo,
so it deploys with hyperhive itself and is not optional.
`services.hyperhive.deploy.singleHostSwarm` turns this on as part
of the all-on-one-box mode. Set it directly to run the swarm's
services on a host that is not otherwise all-local — a dedicated
@ -105,4 +99,10 @@ in
# placeable on a host of its own — it can be set directly here and
# turned off wherever this switch happens to be on.
config.services.hyperhive.deploy.bao.enable = lib.mkDefault deployCfg.allSwarmServices;
# The forge. Every swarm needs it, but one host runs it: a hive that does
# not is a *client*, reaching it at `swarm.forge.domain`. Without this a
# `singleHostSwarm` box, which gets here through `allSwarmServices`, would
# have no forge at all.
config.services.hyperhive.deploy.forgejo.enable = lib.mkDefault deployCfg.allSwarmServices;
}