diff --git a/nix/host-modules/hive-forge/default.nix b/nix/host-modules/hive-forge/default.nix index b137b340..9fce8702 100644 --- a/nix/host-modules/hive-forge/default.nix +++ b/nix/host-modules/hive-forge/default.nix @@ -49,22 +49,6 @@ 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 @@ -410,28 +394,6 @@ 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 { @@ -969,79 +931,6 @@ 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 - ''; - }; }; }; @@ -1117,49 +1006,6 @@ 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 c018bbfb..89a6a655 100644 --- a/nix/host-modules/swarm-controller.nix +++ b/nix/host-modules/swarm-controller.nix @@ -34,7 +34,6 @@ 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 @@ -67,22 +66,6 @@ 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 @@ -228,44 +211,6 @@ in module's own activation condition. ''; }; - - forgeTokenFile = lib.mkOption { - type = lib.types.nullOr lib.types.str; - # `services.hyperhive.swarm.forge` has no `enable` of its own to - # check — the module activates on the general - # `config.services.hyperhive.enable` instead (see - # `hive-forge/default.nix`'s own `config = lib.mkIf - # config.services.hyperhive.enable { ... }`), so that's the - # condition to match here too. Referencing a `forge.enable` that - # doesn't exist threw `attribute 'enable' missing` on every host - # that turns swarm-controller on — caught in review, not by - # `nix flake check` (nothing in its checked combinations forced - # this particular default to actually evaluate). - default = if config.services.hyperhive.enable then forgeCfg.hostSwarmControllerTokenFile else null; - defaultText = lib.literalExpression '' - forge's own `hostSwarmControllerTokenFile` when this host runs - hyperhive at all (forge has no separate enable), 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 on every host running - hyperhive (forge deploys unconditionally alongside it — 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 - {option}`services.hyperhive.swarm.forge.hostSwarmControllerTokenFile` - with whatever secret management this deployment already uses, - the same shape `swarm.nix`'s `clientSecretFile` documents for - its own cross-host case. `null` 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) { @@ -306,14 +251,13 @@ in serviceConfig = { ExecStart = "${cfg.package}/bin/swarm-controller"; - # 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}"; + # 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" + ]; User = "swarm-controller"; Group = "swarm-controller"; Restart = "on-failure"; @@ -351,11 +295,10 @@ in ]; }; - # 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. + # 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. environment = { SWARM_CONTROLLER_SOCKET = cfg.socketPath; # The swarm's hive directory, JSON-encoded — the full directory @@ -380,8 +323,7 @@ in # off (the daemon just has nothing to apply it to). SWARM_CONTROLLER_STALE_AFTER_SECS = toString cfg.staleAfterSeconds; } - // queueEnv - // forgeEnv; + // queueEnv; }; }; }