swarm-controller: provision + collect its own forge account token

This commit is contained in:
damocles 2026-08-16 19:16:41 +02:00
commit c1e46b378d
2 changed files with 213 additions and 12 deletions

View file

@ -49,6 +49,22 @@ let
# working login into an intermittent one.
forgeSecretPath = "/var/lib/forgejo-oidc/${cfg.sso.clientId}.secret";
# The swarm-controller's forge account. Same name as
# `swarm-controller.nix`'s existing `queueClientId` — the account is
# provisioned to *match* that identity, not invented independently —
# "swarm controller having one identity with stuff derived from it is
# the right shape" was the swarm-level design call this account
# follows. A constant, not an option:
# nothing here makes the name configurable without also updating
# `swarm-controller.nix`'s own constant, so a shared option would
# invite the two to drift rather than prevent it.
swarmControllerForgeUser = "swarm-controller";
# Where the minted token lands inside the forge container — under
# forgejo's own state dir for the same "survives a reboot" reason as
# `forgeSecretPath` above.
swarmControllerTokenPath = "/var/lib/forgejo/swarm-controller-token";
# Forgejo's OAuth2 callback shape. Built from the SAME `ssoSourceName`
# the registration uses, so the redirect URI authelia is told to allow
# and the one forgejo will actually send cannot drift apart — a
@ -394,6 +410,28 @@ in
'';
};
};
hostSwarmControllerTokenFile = lib.mkOption {
type = lib.types.str;
default = "/var/lib/hyperhive-forge/swarm-controller.token";
description = ''
Host path where this forge deposits the freshly-minted forge
access token for the swarm's `swarm-controller` account (see
`systemd.services.forgejo-swarm-controller-account` inside the
forge container, and `hive-forge-swarm-controller-token` on the
host, which copies the token out).
Same role for this token as
`services.hyperhive.swarm.authelia.hostClientSecretDir` plays
for the OIDC secret: a **host**-local path (not inside any
container), read directly by `swarm-controller.nix`'s
`LoadCredential` when the controller runs on this same host.
On any other host the token has to get there somehow copy it
out of this path with whatever secret management this
deployment already uses, the same shape `swarm.nix`'s own
`clientSecretFile` documents for the analogous cross-host case.
'';
};
};
config = lib.mkIf config.services.hyperhive.enable {
@ -931,6 +969,79 @@ in
fi
'';
};
# Provision the swarm-controller's forge account + access token.
# Unconditional (not gated on any SSO/co-location option): forge
# is a swarm-wide singleton, so this account exists wherever
# forge does, regardless of which host (if any) actually runs
# swarm-controller — the design requirement was explicit that
# this must work even when forge and swarm-controller don't
# share a host.
systemd.services.forgejo-swarm-controller-account = {
description = "provision the swarm-controller's forge account + access token";
after = [ "forgejo.service" ];
requires = [ "forgejo.service" ];
wantedBy = [ "multi-user.target" ];
serviceConfig = {
Type = "oneshot";
RemainAfterExit = true;
User = "forgejo";
Group = "forgejo";
SyslogIdentifier = "forgejo-swarm-controller-account";
};
# `FORGEJO_CUSTOM`, same reason as `forgejo-sso-source` above:
# every `forgejo admin` invocation needs it to find the app.ini
# the upstream module wrote, not just the auth-source verb.
environment = {
FORGEJO_CUSTOM = "/var/lib/forgejo/custom";
};
path = [
cfg.package
pkgs.coreutils
pkgs.gnugrep
];
script = ''
set -euo pipefail
# Idempotent by query, same reasoning as the SSO unit: assert
# the effect, not the command's exit code.
if ! forgejo admin user list | grep -qE "[[:space:]]${swarmControllerForgeUser}[[:space:]]"; then
forgejo admin user create \
--username ${lib.escapeShellArg swarmControllerForgeUser} \
--email ${lib.escapeShellArg "${swarmControllerForgeUser}@hyperhive.local"} \
--random-password --must-change-password=false
echo "created forge account ${swarmControllerForgeUser}"
fi
if ! forgejo admin user list | grep -qE "[[:space:]]${swarmControllerForgeUser}[[:space:]]"; then
echo "forge account ${swarmControllerForgeUser} absent after creation" >&2
exit 1
fi
token_path=${lib.escapeShellArg swarmControllerTokenPath}
# Token minting is idempotent by the token FILE's existence,
# not a separate stamp — same reasoning `hive-forge-oidc-secret`
# (below) uses for its own delivery: the file is both the
# record of "already done" and the thing that has to survive,
# so a state wipe that deletes it correctly triggers a
# re-mint instead of being silently masked by a stamp that
# outlived what it claims exists.
if [ ! -s "$token_path" ]; then
out=$(forgejo admin user generate-access-token \
--username ${lib.escapeShellArg swarmControllerForgeUser} \
--token-name swarm-controller-boot \
--scopes "write:repository,write:organization,write:issue,read:user")
token=$(printf '%s' "$out" | grep -oE '[0-9a-f]{32,}' | head -n1)
if [ -z "$token" ]; then
echo "no token-shaped word in forgejo's generate-access-token output" >&2
exit 1
fi
umask 0177
printf '%s' "$token" > "$token_path"
echo "minted forge access token for ${swarmControllerForgeUser}"
fi
'';
};
};
};
@ -1006,6 +1117,49 @@ in
'';
};
# The reverse direction of the unit above: collect the
# swarm-controller's freshly-minted forge token OUT of the container
# onto a host path other units/hosts can read. Same "only the host can
# see both trees" reasoning, same ordering constraint — this can only
# depend on the container being up (`container@hive-forge.service`),
# not on the specific in-container oneshot that mints the token
# (containers run their own systemd instance, invisible to this one by
# unit name) — so it polls the same bounded way
# `hive-forge-oidc-secret` does while waiting on authelia above.
#
# Unconditional, like the in-container unit it collects from: forge is
# a swarm-wide singleton, so the token always gets minted and always
# gets collected here, regardless of whether swarm-controller runs on
# this host, another host, or nowhere in this swarm at all.
systemd.services.hive-forge-swarm-controller-token = {
description = "collect the swarm-controller's forge token onto the host";
after = [ "container@hive-forge.service" ];
wantedBy = [ "container@hive-forge.service" ];
serviceConfig = {
Type = "oneshot";
RemainAfterExit = true;
SyslogIdentifier = "hive-forge-swarm-controller-token";
};
path = [ pkgs.coreutils ];
script = ''
set -euo pipefail
src=${lib.escapeShellArg "/var/lib/nixos-containers/hive-forge${swarmControllerTokenPath}"}
dst=${lib.escapeShellArg cfg.hostSwarmControllerTokenFile}
for _ in $(seq 1 60); do
[ -s "$src" ] && break
sleep 2
done
if [ ! -s "$src" ]; then
echo "forge has not minted $src after 120s" >&2
exit 1
fi
install -D -m 0400 "$src" "$dst"
'';
};
networking.firewall = lib.mkIf cfg.openFirewall {
allowedTCPPorts = [
cfg.httpPort

View file

@ -34,6 +34,7 @@ let
};
natsCfg = config.services.hyperhive.swarm.nats;
forgeCfg = config.services.hyperhive.swarm.forge;
# The controller's own OAuth2 client. It is NOT a hive: the per-hive
# clients the roster issues belong to hives, and the responder's client
@ -66,6 +67,22 @@ let
SWARM_CONTROLLER_OIDC_CLIENT_SECRET_FILE = "%d/queue-client.secret";
};
# Independent of `queueEnv`/`queueLocal` on purpose — the queue
# coordinates' own co-location guard is narrower than it looks (it
# silently drops the controller's identity on a host that splits
# authelia/NATS from swarm-controller; a known gap tracked separately).
# Forge access has nothing to do with whether authelia+NATS happen to be
# local, only with whether `cfg.forgeTokenFile` resolves to a real file —
# which `forgeTokenFile`'s own default already handles (forge-local vs.
# operator-copied). Gating a second time here would just repeat that
# option's own logic under a different name.
forgeEnv = lib.optionalAttrs (cfg.forgeTokenFile != null) {
SWARM_CONTROLLER_FORGE_URL = "https://${forgeCfg.domain}";
# Same `%d` shape as the queue secret above — root reads the plaintext
# at unit start, the daemon's own user sees a 0400 copy.
SWARM_CONTROLLER_FORGE_TOKEN_FILE = "%d/forge-token";
};
# Wrapped rather than documented: every one of these values is derived
# from an option this deployment already set, so making the operator
# re-supply them on the command line would be asking them to repeat the
@ -211,6 +228,33 @@ in
module's own activation condition.
'';
};
forgeTokenFile = lib.mkOption {
type = lib.types.nullOr lib.types.str;
default = if forgeCfg.enable then forgeCfg.hostSwarmControllerTokenFile else null;
defaultText = lib.literalExpression ''
forge's own `hostSwarmControllerTokenFile` when this host runs
forge, else null
'';
example = "/var/lib/secrets/swarm-controller-forge.token";
description = ''
Path to this host's copy of the swarm-controller's forge access
token (see `hive-forge/default.nix`'s
`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 only when forge runs on
this same host. On any other host the token has to get here
somehow copy it out of forge's
{option}`services.hyperhive.swarm.forge.hostSwarmControllerTokenFile`
with whatever secret management this deployment already uses,
the same shape `swarm.nix`'s `clientSecretFile` documents for
the analogous cross-host case. `null` (the default when forge
isn't local and nothing else was set) means no forge access
the daemon logs that and continues without it, the same
graceful-absence shape the queue coordinates already use.
'';
};
};
config = lib.mkIf (config.services.hyperhive.enable && cfg.enable) {
@ -251,13 +295,14 @@ in
serviceConfig = {
ExecStart = "${cfg.package}/bin/swarm-controller";
# Only when the queue is actually reachable from here. An absent
# credential is not a failure: the daemon logs that no queue is
# configured and serves its HTTP surface, which is the correct
# behaviour on the hosts that do not run one.
LoadCredential = lib.mkIf queueLocal [
"queue-client.secret:${autheliaCfg.hostClientSecretDir}/${queueClientId}.secret"
];
# Only the credentials that actually resolve on this host. An
# absent credential is not a failure for either: the daemon logs
# that no queue / no forge is configured and serves its HTTP
# surface regardless, which is the correct behaviour on a host
# that doesn't run one.
LoadCredential =
lib.optional queueLocal "queue-client.secret:${autheliaCfg.hostClientSecretDir}/${queueClientId}.secret"
++ lib.optional (cfg.forgeTokenFile != null) "forge-token:${cfg.forgeTokenFile}";
User = "swarm-controller";
Group = "swarm-controller";
Restart = "on-failure";
@ -295,10 +340,11 @@ in
];
};
# Queue coordinates (`queueEnv`) merge in last and are present only
# where the queue and its IdP both run. The daemon refuses a PARTIAL
# set rather than treating it as absent, which is why they are built
# as one attrset and never assigned individually.
# Queue coordinates (`queueEnv`) and forge coordinates (`forgeEnv`)
# merge in last, each present only when its own resolution actually
# succeeded on this host. Both refuse a PARTIAL set rather than
# treating it as absent, which is why each is built as one attrset
# and never assigned individually.
environment = {
SWARM_CONTROLLER_SOCKET = cfg.socketPath;
# The swarm's hive directory, JSON-encoded — the full directory
@ -323,7 +369,8 @@ in
# off (the daemon just has nothing to apply it to).
SWARM_CONTROLLER_STALE_AFTER_SECS = toString cfg.staleAfterSeconds;
}
// queueEnv;
// queueEnv
// forgeEnv;
};
};
}