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.
|
# working login into an intermittent one.
|
||||||
forgeSecretPath = "/var/lib/forgejo-oidc/${cfg.sso.clientId}.secret";
|
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`
|
# Forgejo's OAuth2 callback shape. Built from the SAME `ssoSourceName`
|
||||||
# the registration uses, so the redirect URI authelia is told to allow
|
# the registration uses, so the redirect URI authelia is told to allow
|
||||||
# and the one forgejo will actually send cannot drift apart — a
|
# 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 {
|
config = lib.mkIf config.services.hyperhive.enable {
|
||||||
|
|
@ -931,6 +969,79 @@ in
|
||||||
fi
|
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 {
|
networking.firewall = lib.mkIf cfg.openFirewall {
|
||||||
allowedTCPPorts = [
|
allowedTCPPorts = [
|
||||||
cfg.httpPort
|
cfg.httpPort
|
||||||
|
|
|
||||||
|
|
@ -34,6 +34,7 @@ let
|
||||||
};
|
};
|
||||||
|
|
||||||
natsCfg = config.services.hyperhive.swarm.nats;
|
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
|
# 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
|
# 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";
|
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
|
# Wrapped rather than documented: every one of these values is derived
|
||||||
# from an option this deployment already set, so making the operator
|
# 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
|
# re-supply them on the command line would be asking them to repeat the
|
||||||
|
|
@ -211,6 +228,33 @@ in
|
||||||
module's own activation condition.
|
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) {
|
config = lib.mkIf (config.services.hyperhive.enable && cfg.enable) {
|
||||||
|
|
@ -251,13 +295,14 @@ in
|
||||||
serviceConfig = {
|
serviceConfig = {
|
||||||
ExecStart = "${cfg.package}/bin/swarm-controller";
|
ExecStart = "${cfg.package}/bin/swarm-controller";
|
||||||
|
|
||||||
# Only when the queue is actually reachable from here. An absent
|
# Only the credentials that actually resolve on this host. An
|
||||||
# credential is not a failure: the daemon logs that no queue is
|
# absent credential is not a failure for either: the daemon logs
|
||||||
# configured and serves its HTTP surface, which is the correct
|
# that no queue / no forge is configured and serves its HTTP
|
||||||
# behaviour on the hosts that do not run one.
|
# surface regardless, which is the correct behaviour on a host
|
||||||
LoadCredential = lib.mkIf queueLocal [
|
# that doesn't run one.
|
||||||
"queue-client.secret:${autheliaCfg.hostClientSecretDir}/${queueClientId}.secret"
|
LoadCredential =
|
||||||
];
|
lib.optional queueLocal "queue-client.secret:${autheliaCfg.hostClientSecretDir}/${queueClientId}.secret"
|
||||||
|
++ lib.optional (cfg.forgeTokenFile != null) "forge-token:${cfg.forgeTokenFile}";
|
||||||
User = "swarm-controller";
|
User = "swarm-controller";
|
||||||
Group = "swarm-controller";
|
Group = "swarm-controller";
|
||||||
Restart = "on-failure";
|
Restart = "on-failure";
|
||||||
|
|
@ -295,10 +340,11 @@ in
|
||||||
];
|
];
|
||||||
};
|
};
|
||||||
|
|
||||||
# Queue coordinates (`queueEnv`) merge in last and are present only
|
# Queue coordinates (`queueEnv`) and forge coordinates (`forgeEnv`)
|
||||||
# where the queue and its IdP both run. The daemon refuses a PARTIAL
|
# merge in last, each present only when its own resolution actually
|
||||||
# set rather than treating it as absent, which is why they are built
|
# succeeded on this host. Both refuse a PARTIAL set rather than
|
||||||
# as one attrset and never assigned individually.
|
# treating it as absent, which is why each is built as one attrset
|
||||||
|
# and never assigned individually.
|
||||||
environment = {
|
environment = {
|
||||||
SWARM_CONTROLLER_SOCKET = cfg.socketPath;
|
SWARM_CONTROLLER_SOCKET = cfg.socketPath;
|
||||||
# The swarm's hive directory, JSON-encoded — the full directory
|
# 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).
|
# off (the daemon just has nothing to apply it to).
|
||||||
SWARM_CONTROLLER_STALE_AFTER_SECS = toString cfg.staleAfterSeconds;
|
SWARM_CONTROLLER_STALE_AFTER_SECS = toString cfg.staleAfterSeconds;
|
||||||
}
|
}
|
||||||
// queueEnv;
|
// queueEnv
|
||||||
|
// forgeEnv;
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue