swarm-controller: provision + collect its own forge account token
This commit is contained in:
parent
b03ae55786
commit
c1e46b378d
2 changed files with 213 additions and 12 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
};
|
||||
};
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue