From 4e8225c058ce6d531fa28e89351dcfea0abd73a6 Mon Sep 17 00:00:00 2001 From: atlas Date: Thu, 1 Oct 2026 10:37:27 +0200 Subject: [PATCH] nix: split hive-forge into service and deploy-mode files `swarm.forge` (what the forge is to every hive: ports, domain, public and root URLs, OIDC client id and callback) moves to nix/host-modules/hive-forge/service.nix, together with the rename of the old `services.hyperhive.forge` tree and the removed `swarm.forge.sso.enable`, both of which name `swarm.forge` paths. Everything else -- the `deploy.forgejo` options, the whole `config` block including `containers.hive-forge`, and the helpers only they read -- stays in nix/host-modules/hive-forge/default.nix, which now imports ./service.nix. Importing it from the directory's own default.nix, as hive-c0re/ and hive-gateway/ do with their option files, keeps the flake's standalone `nixosModules.hive-forge` export whole. Both halves read `cfg`, `gatewayCfg`, `swarmDomain` and `deployCfg`. They are option reads, so each file binds them from `config`. `ssoSourceName`, `defaultRootUrl` and `effectiveRootUrl` are not options and both halves need them (the service half builds `sso.redirectUri` from them, the deploy half registers the login source and sets ROOT_URL), so they are duplicated, with a note at each copy. `ssoRedirectUri` is read only by the service half and moves. `swarm.forge.publicUrl` and `swarm.forge.sso.redirectUri` default from `deploy.forgejo.behindGateway`; both move as they are. A pure move: option paths, option definitions and config are unchanged apart from comments: the two on either side of the cut, the `ssoRedirectUri` comment and the duplication notes, and the rename precedent in ./deploy.nix, which now names ./hive-forge/service.nix. Refs #3742 --- nix/host-modules/deploy.nix | 2 +- nix/host-modules/hive-forge/default.nix | 200 +--------------------- nix/host-modules/hive-forge/service.nix | 216 ++++++++++++++++++++++++ 3 files changed, 226 insertions(+), 192 deletions(-) create mode 100644 nix/host-modules/hive-forge/service.nix diff --git a/nix/host-modules/deploy.nix b/nix/host-modules/deploy.nix index d5b22ea1..acc2f4a1 100644 --- a/nix/host-modules/deploy.nix +++ b/nix/host-modules/deploy.nix @@ -36,7 +36,7 @@ in imports = [ # Same type, same meaning, new path — so a rename carries it exactly # and existing configs keep evaluating with one warning naming both - # paths. Precedent: ./hive-forge/default.nix, ./hive-matrix.nix. + # paths. Precedent: ./hive-forge/service.nix, ./hive-matrix.nix. (lib.mkRenamedOptionModule [ "services" "hyperhive" "swarm" "grafana" "enable" ] [ "services" "hyperhive" "deploy" "grafana" "enable" ] diff --git a/nix/host-modules/hive-forge/default.nix b/nix/host-modules/hive-forge/default.nix index e0a4e8b2..b7dec05d 100644 --- a/nix/host-modules/hive-forge/default.nix +++ b/nix/host-modules/hive-forge/default.nix @@ -15,7 +15,8 @@ let # Forgejo's name for the login source. A constant, not an option: it # is the key this module's own idempotency check looks up, so making # it configurable would buy nothing and add a way for the lookup and - # the row to disagree. + # the row to disagree. Duplicated in ./service.nix, which builds + # `sso.redirectUri` from it; keep the two equal. ssoSourceName = "authelia"; # `url` is the half of the authelia module that exists on EVERY hive — @@ -55,12 +56,6 @@ let # `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 - # mismatch there is a rejected login with no error text worth reading. - ssoRedirectUri = "${effectiveRootUrl}user/oauth2/${ssoSourceName}/callback"; - caTrust = import ../lib/hive-ca-trust.nix { inherit lib tlsCfg gatewayCfg; }; # ROOT_URL forgejo advertises in clone links + outbound URLs. When @@ -73,7 +68,8 @@ let # the port suffix. When direct (`behindGateway = false`), keep the # host:httpPort shape so direct browser access still produces correct # links. Operators can still override via `cfg.rootUrl` for bespoke - # shapes. + # shapes. Both bindings are duplicated in ./service.nix, which builds + # `sso.redirectUri` from them; keep the two equal. defaultRootUrl = if deployCfg.forgejo.behindGateway then let @@ -125,190 +121,12 @@ in # (base URL), the same shape as the GitHub PAT / matrix extra-account # flows. See `hive-c0re/src/dashboard/extra_forges.rs`. - # Forge moved under `swarm` when the swarm-global services were - # consolidated. One rename for the namespace: the subtree comes with it, - # so existing hives keep evaluating and get one warning naming both paths. - imports = [ - (lib.mkRenamedOptionModule - [ "services" "hyperhive" "forge" ] - [ "services" "hyperhive" "swarm" "forge" ] - ) - (lib.mkRemovedOptionModule [ "services" "hyperhive" "swarm" "forge" "sso" "enable" ] '' - SSO is no longer optional: the forge always registers the swarm's - authelia as a login source. + imports = [ ./service.nix ]; - Removed rather than defaulted to true so a config that turned it - OFF fails here, where the line is, instead of silently gaining a - login provider on the next rebuild. Drop the line; if it was - false, set services.hyperhive.deploy.forgejo.sso.clientSecretFile and - services.hyperhive.swarm.authelia.url as the assertions describe. - '') - ]; - - # Every swarm needs this forge — it's the canonical store for the meta - # flake + every agent's config repo (and the `internal/*` repos) — but - # only one host runs it: `deploy.forgejo.enable`, in ../deploy.nix. What - # follows is what every hive needs to reach it, wherever it runs. - options.services.hyperhive.swarm.forge = { - httpPort = lib.mkOption { - type = lib.types.port; - default = 3000; - description = '' - TCP port the forge serves HTTP on. Default 3000 sits outside - hyperhive's claimed ranges (dashboard 7000, every agent in - 8100..8999 via FNV-1a hash). Change this if you already have - another forgejo bound to 3000. - ''; - }; - - sshPort = lib.mkOption { - type = lib.types.port; - default = 2222; - description = '' - TCP port the forge's built-in SSH server listens on. Kept off - 22 so it doesn't clash with the host's openssh. Agents push - with `ssh -p git@:/.git`. - ''; - }; - - domain = lib.mkOption { - type = lib.types.str; - # Under the SWARM domain, not this hive's: a swarm runs one forge - # and every hive in it reaches the same host, so the name belongs - # to the swarm rather than to whichever hive happens to run it. - # - # Total on a null swarm domain so the required-domain assertion in - # hive-network.nix is the thing that fires; see the comment there. - default = if swarmDomain == null then "forge.invalid" else "forge.${swarmDomain}"; - defaultText = lib.literalExpression ''"forge.''${services.hyperhive.swarm.domain}"''; - example = "git.example.com"; - description = '' - Public hostname for the forge. Doubles as both the forgejo - `DOMAIN` setting (clone URLs forgejo advertises) AND the - gateway vhost server-name when `deploy.forgejo.behindGateway = true` - (sub-domain routing — see `docs/networking/gateway.md`). - - Defaults to `forge.''${services.hyperhive.swarm.domain}` — the - swarm's domain, not this hive's, because a swarm runs **one** - forge that every hive in it talks to. - - ⚠️ A deployment that was running before this moved keeps its - current name by pinning it here: - `forge.''${services.hyperhive.domain}`, which is exactly what - the old default rendered. Certificates follow either way: the - swarm-services sub-CA is name-constrained to the configured - names (see `./swarm-ca.nix`), not to a fixed tree. - - Set to a full hostname (`git.example.com`, - `forge.internal.lan`, etc.) for a bespoke vhost shape — the - full domain goes here, no separate sub-domain-label option. - ''; - }; - - publicUrl = lib.mkOption { - type = lib.types.nullOr lib.types.str; - default = if deployCfg.forgejo.behindGateway then "https://${cfg.domain}" else null; - defaultText = lib.literalExpression '' - if behindGateway then "https://''${domain}" else null - ''; - example = "https://forge.example.com"; - description = '' - Browser-facing forge URL the dashboard uses to build clickable - forge links (the H0M3 Forge tile, per-agent-row forge links, - the approval-queue's "review PR on forge" link) — sourced into - every agent container + hive-c0re as `HIVE_FORGE_PUBLIC_URL`. - - Defaults to `https://''${cfg.domain}` when `deploy.forgejo.behindGateway = - true` (the gateway vhost is genuinely reachable at that URL) - and `null` otherwise. When `null`, the dashboard **hides** - forge links rather than guessing one — see - `docs/web-ui/dashboard.md::H0M3 page` for the rationale (a - link built from the operator's own browser hostname + a - container port is only an accident away from wrong on any - deployment that isn't plain localhost). - - **Set this explicitly if `deploy.forgejo.behindGateway = false`** and the - forge is still reachable at a stable URL you want linked from - the dashboard (e.g. `http://:''${toString cfg.httpPort}` - for an all-LAN deployment) — leaving it unset there means the - dashboard's forge links are simply absent, not broken. - ''; - }; - - rootUrl = lib.mkOption { - type = lib.types.nullOr lib.types.str; - default = null; - example = "https://forge.example.com/"; - description = '' - Override the auto-derived forgejo `ROOT_URL`. When `null` - (default), `ROOT_URL` is derived from `cfg.domain` + gateway - state, including the scheme: - - - `deploy.forgejo.behindGateway = true` → `https://''${cfg.domain}/`. The gateway - always terminates TLS (self-signed is the implicit floor when no - `gateway.tls.certDir` / ACME is set), so the forge is always - advertised over https. A non-canonical `gateway.httpsPort` is - appended as `:`. - - `deploy.forgejo.behindGateway = false` → `http://''${cfg.domain}:''${cfg.httpPort}/` - - The TLS scheme is derived automatically now, so you only need to - set this for a genuinely bespoke shape (e.g. an external reverse - proxy on a different host/path). Must end with `/` per forgejo's - `ROOT_URL` contract. - ''; - }; - - # The swarm's authelia is always registered as an OpenID Connect - # login source here — there is no toggle: a forge without it has no - # way to log a person in through the swarm's SSO. - # - # **Additive, never exclusive.** Forgejo keeps its local password - # database and gains an extra "sign in with" button; this does not - # disable local login. Deliberate: an identity provider that can take - # the forge offline when it hiccups is a worse forge than one with - # two ways in — which is also what makes always-on safe. - sso = { - clientId = lib.mkOption { - type = lib.types.str; - default = "forgejo"; - description = '' - OAuth2 client id this forge identifies itself with. Must match - the `id` of the corresponding entry in - `services.hyperhive.swarm.authelia.oidc.clients`. - ''; - }; - redirectUri = lib.mkOption { - type = lib.types.str; - readOnly = true; - default = ssoRedirectUri; - defaultText = lib.literalExpression ''"''${ROOT_URL}user/oauth2/authelia/callback"''; - description = '' - OAuth2 callback authelia sends the browser back to, and the URI - it matches **exactly**. - - Read-only: forgejo derives it from its own `ROOT_URL` and the - login source's name, so it is a fact other modules read rather - than a knob. The glue that registers this client wherever - authelia runs reads it from here instead of restating the - format. - - ⚠️ With {option}`services.hyperhive.swarm.forge.rootUrl` unset, - `ROOT_URL` follows - {option}`services.hyperhive.deploy.forgejo.behindGateway` and - the gateway's `httpsPort`, which are per-host. An authelia host - that is not the forge's host renders the forge's callback only - if the two agree on them; set `rootUrl` if they do not. - ''; - }; - # The secret half is a path on the host that runs the forge, so it - # lives under `deploy.forgejo.sso` — see the block below. - }; - }; - - # What stays above is what the forge IS from any hive's point of view: the - # names and ports it answers on, the URLs it advertises, and the client id - # it is registered under. What lives here is what the host running it - # decides — which build it runs, how it is served, what it mirrors, and + # What ./service.nix declares is what the forge IS from any hive's point of + # view: the names and ports it answers on, the URLs it advertises, and the + # client id it is registered under. What lives here is what the host running + # it decides — which build it runs, how it is served, what it mirrors, and # where its host-local secrets sit. Same rule as ./swarm-victorialogs.nix, # and the renames are in ./deploy.nix with the rest. options.services.hyperhive.deploy.forgejo = { diff --git a/nix/host-modules/hive-forge/service.nix b/nix/host-modules/hive-forge/service.nix new file mode 100644 index 00000000..f1dd97cd --- /dev/null +++ b/nix/host-modules/hive-forge/service.nix @@ -0,0 +1,216 @@ +# The swarm's forge as every hive sees it: the names and ports it answers on, +# the URLs it advertises, and the OIDC client it is registered as. What the +# host running it decides, and the container itself, are in ./default.nix. +{ + lib, + config, + ... +}: +let + cfg = config.services.hyperhive.swarm.forge; + gatewayCfg = config.services.hyperhive.gateway; + swarmDomain = config.services.hyperhive.swarm.domain; + deployCfg = config.services.hyperhive.deploy; + + # Forgejo's name for the login source. Duplicated in ./default.nix, which + # registers the source under it. + ssoSourceName = "authelia"; + + # Forgejo's OAuth2 callback shape. `ssoSourceName` and `effectiveRootUrl` + # must equal their copies in ./default.nix: a mismatch is a rejected login + # with no error text worth reading. + ssoRedirectUri = "${effectiveRootUrl}user/oauth2/${ssoSourceName}/callback"; + + # Forgejo's `ROOT_URL`, duplicated from ./default.nix, which documents its + # shape. + defaultRootUrl = + if deployCfg.forgejo.behindGateway then + let + portSuffix = if gatewayCfg.httpsPort == 443 then "" else ":${toString gatewayCfg.httpsPort}"; + in + "https://${cfg.domain}${portSuffix}/" + else + "http://${cfg.domain}:${toString cfg.httpPort}/"; + effectiveRootUrl = if cfg.rootUrl != null then cfg.rootUrl else defaultRootUrl; +in +{ + # Forge moved under `swarm` when the swarm-global services were + # consolidated. One rename for the namespace: the subtree comes with it, + # so existing hives keep evaluating and get one warning naming both paths. + imports = [ + (lib.mkRenamedOptionModule + [ "services" "hyperhive" "forge" ] + [ "services" "hyperhive" "swarm" "forge" ] + ) + (lib.mkRemovedOptionModule [ "services" "hyperhive" "swarm" "forge" "sso" "enable" ] '' + SSO is no longer optional: the forge always registers the swarm's + authelia as a login source. + + Removed rather than defaulted to true so a config that turned it + OFF fails here, where the line is, instead of silently gaining a + login provider on the next rebuild. Drop the line; if it was + false, set services.hyperhive.deploy.forgejo.sso.clientSecretFile and + services.hyperhive.swarm.authelia.url as the assertions describe. + '') + ]; + + # Every swarm needs this forge — it's the canonical store for the meta + # flake + every agent's config repo (and the `internal/*` repos) — but + # only one host runs it: `deploy.forgejo.enable`, in ../deploy.nix. What + # follows is what every hive needs to reach it, wherever it runs. + options.services.hyperhive.swarm.forge = { + httpPort = lib.mkOption { + type = lib.types.port; + default = 3000; + description = '' + TCP port the forge serves HTTP on. Default 3000 sits outside + hyperhive's claimed ranges (dashboard 7000, every agent in + 8100..8999 via FNV-1a hash). Change this if you already have + another forgejo bound to 3000. + ''; + }; + + sshPort = lib.mkOption { + type = lib.types.port; + default = 2222; + description = '' + TCP port the forge's built-in SSH server listens on. Kept off + 22 so it doesn't clash with the host's openssh. Agents push + with `ssh -p git@:/.git`. + ''; + }; + + domain = lib.mkOption { + type = lib.types.str; + # Under the SWARM domain, not this hive's: a swarm runs one forge + # and every hive in it reaches the same host, so the name belongs + # to the swarm rather than to whichever hive happens to run it. + # + # Total on a null swarm domain so the required-domain assertion in + # hive-network.nix is the thing that fires; see the comment there. + default = if swarmDomain == null then "forge.invalid" else "forge.${swarmDomain}"; + defaultText = lib.literalExpression ''"forge.''${services.hyperhive.swarm.domain}"''; + example = "git.example.com"; + description = '' + Public hostname for the forge. Doubles as both the forgejo + `DOMAIN` setting (clone URLs forgejo advertises) AND the + gateway vhost server-name when `deploy.forgejo.behindGateway = true` + (sub-domain routing — see `docs/networking/gateway.md`). + + Defaults to `forge.''${services.hyperhive.swarm.domain}` — the + swarm's domain, not this hive's, because a swarm runs **one** + forge that every hive in it talks to. + + ⚠️ A deployment that was running before this moved keeps its + current name by pinning it here: + `forge.''${services.hyperhive.domain}`, which is exactly what + the old default rendered. Certificates follow either way: the + swarm-services sub-CA is name-constrained to the configured + names (see `./swarm-ca.nix`), not to a fixed tree. + + Set to a full hostname (`git.example.com`, + `forge.internal.lan`, etc.) for a bespoke vhost shape — the + full domain goes here, no separate sub-domain-label option. + ''; + }; + + publicUrl = lib.mkOption { + type = lib.types.nullOr lib.types.str; + default = if deployCfg.forgejo.behindGateway then "https://${cfg.domain}" else null; + defaultText = lib.literalExpression '' + if behindGateway then "https://''${domain}" else null + ''; + example = "https://forge.example.com"; + description = '' + Browser-facing forge URL the dashboard uses to build clickable + forge links (the H0M3 Forge tile, per-agent-row forge links, + the approval-queue's "review PR on forge" link) — sourced into + every agent container + hive-c0re as `HIVE_FORGE_PUBLIC_URL`. + + Defaults to `https://''${cfg.domain}` when `deploy.forgejo.behindGateway = + true` (the gateway vhost is genuinely reachable at that URL) + and `null` otherwise. When `null`, the dashboard **hides** + forge links rather than guessing one — see + `docs/web-ui/dashboard.md::H0M3 page` for the rationale (a + link built from the operator's own browser hostname + a + container port is only an accident away from wrong on any + deployment that isn't plain localhost). + + **Set this explicitly if `deploy.forgejo.behindGateway = false`** and the + forge is still reachable at a stable URL you want linked from + the dashboard (e.g. `http://:''${toString cfg.httpPort}` + for an all-LAN deployment) — leaving it unset there means the + dashboard's forge links are simply absent, not broken. + ''; + }; + + rootUrl = lib.mkOption { + type = lib.types.nullOr lib.types.str; + default = null; + example = "https://forge.example.com/"; + description = '' + Override the auto-derived forgejo `ROOT_URL`. When `null` + (default), `ROOT_URL` is derived from `cfg.domain` + gateway + state, including the scheme: + + - `deploy.forgejo.behindGateway = true` → `https://''${cfg.domain}/`. The gateway + always terminates TLS (self-signed is the implicit floor when no + `gateway.tls.certDir` / ACME is set), so the forge is always + advertised over https. A non-canonical `gateway.httpsPort` is + appended as `:`. + - `deploy.forgejo.behindGateway = false` → `http://''${cfg.domain}:''${cfg.httpPort}/` + + The TLS scheme is derived automatically now, so you only need to + set this for a genuinely bespoke shape (e.g. an external reverse + proxy on a different host/path). Must end with `/` per forgejo's + `ROOT_URL` contract. + ''; + }; + + # The swarm's authelia is always registered as an OpenID Connect + # login source here — there is no toggle: a forge without it has no + # way to log a person in through the swarm's SSO. + # + # **Additive, never exclusive.** Forgejo keeps its local password + # database and gains an extra "sign in with" button; this does not + # disable local login. Deliberate: an identity provider that can take + # the forge offline when it hiccups is a worse forge than one with + # two ways in — which is also what makes always-on safe. + sso = { + clientId = lib.mkOption { + type = lib.types.str; + default = "forgejo"; + description = '' + OAuth2 client id this forge identifies itself with. Must match + the `id` of the corresponding entry in + `services.hyperhive.swarm.authelia.oidc.clients`. + ''; + }; + redirectUri = lib.mkOption { + type = lib.types.str; + readOnly = true; + default = ssoRedirectUri; + defaultText = lib.literalExpression ''"''${ROOT_URL}user/oauth2/authelia/callback"''; + description = '' + OAuth2 callback authelia sends the browser back to, and the URI + it matches **exactly**. + + Read-only: forgejo derives it from its own `ROOT_URL` and the + login source's name, so it is a fact other modules read rather + than a knob. The glue that registers this client wherever + authelia runs reads it from here instead of restating the + format. + + ⚠️ With {option}`services.hyperhive.swarm.forge.rootUrl` unset, + `ROOT_URL` follows + {option}`services.hyperhive.deploy.forgejo.behindGateway` and + the gateway's `httpsPort`, which are per-host. An authelia host + that is not the forge's host renders the forge's callback only + if the two agree on them; set `rootUrl` if they do not. + ''; + }; + # The secret half is a path on the host that runs the forge, so it + # lives under `deploy.forgejo.sso`, in ./default.nix. + }; + }; +}