diff --git a/nix/host-modules/hive-forge/default.nix b/nix/host-modules/hive-forge/default.nix index 9fce8702..b137b340 100644 --- a/nix/host-modules/hive-forge/default.nix +++ b/nix/host-modules/hive-forge/default.nix @@ -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 diff --git a/nix/host-modules/swarm-controller.nix b/nix/host-modules/swarm-controller.nix index 89a6a655..4a68cde0 100644 --- a/nix/host-modules/swarm-controller.nix +++ b/nix/host-modules/swarm-controller.nix @@ -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; }; }; }