diff --git a/docs/integrations/forge.md b/docs/integrations/forge.md index 10c15f01..26e899ad 100644 --- a/docs/integrations/forge.md +++ b/docs/integrations/forge.md @@ -2,7 +2,11 @@ Private Forgejo instance running in a nixos-container, used as the swarm's persistent code-collaboration surface (issues, PRs, reviews, -attachments). Configured via `services.hyperhive.swarm.forge.*`. Container +attachments). Configured via `services.hyperhive.swarm.forge.*` (what the +forge IS to every hive — its package, names, ports, URLs) plus +`services.hyperhive.deploy.forgejo.*` (what the host running it decides — +how it is served, what it mirrors, where its host-local secrets sit). +Container shape, ROOT_URL / sub-domain routing, and operator-vs-in-cluster URL handling live in [`docs/networking/gateway.md`](../networking/gateway.md); this file owns the per-agent integration story and the notification pump that wakes diff --git a/docs/networking/gateway.md b/docs/networking/gateway.md index a5aa5c75..338a019e 100644 --- a/docs/networking/gateway.md +++ b/docs/networking/gateway.md @@ -10,7 +10,7 @@ Single nginx in front of every hyperhive web surface. Runs on the **host**, next | `/agent//` | `_` | per-agent harness (UDS or TCP) | `agents.conf` (runtime-generated) | | `/.well-known/matrix/{client,server}` | `_` | inline JSON (no upstream) | `matrix.enable && domain != null` | | `/matrix/` (deprecated) | `_` | 301 → `chat./` | `matrix.gui.enable` | -| `forge./` | `forge.` | forgejo (`3000`) | `forge.behindGateway` | +| `forge./` | `forge.` | forgejo (`3000`) | `deploy.forgejo.behindGateway` | | `chat./_matrix/*` | `chat.` | tuwunel (`8008`) | `matrix.gatewayHost != null` | | `chat./` | `chat.` | fluffychat-web static | `matrix.gui.enable` | | `chat./config.json` | `chat.` | inline JSON (FluffyChat boot config) | `matrix.gui.enable && domain != null` | @@ -62,7 +62,7 @@ Each location carries a duplicated `auth_basic` block (separate locations don't `services.hyperhive.gateway.localHostsEntry = true` adds entries to the host's `/etc/hosts`: - `` → `127.0.0.1` -- `forge.` → `127.0.0.1` (when forge.behindGateway) +- `forge.` → `127.0.0.1` (when deploy.forgejo.behindGateway) - `chat.` → `127.0.0.1` (when matrix.gatewayHost set) - `auth.` → `127.0.0.1` (when deploy.authelia) @@ -159,7 +159,7 @@ flip together: the primary agent-name link, the favicon fetch (`/icon`), and the nav-strip `container`-kind links from `DashboardState.links` (`GET /api/dashboard-state`). `forge`-kind nav-strip links still resolve against `http://:3000` (separate sub-domain transition -tracked by `forge.behindGateway`); `external`-kind links are +tracked by `deploy.forgejo.behindGateway`); `external`-kind links are already absolute. See `docs/web-ui/dashboard.md::Container row` for the frontend-side derivation. @@ -333,10 +333,12 @@ container. ```nix services.hyperhive.swarm.forge = { - httpPort = 3000; # default — HTTP listener; outside hyperhive's 7000/8100-8999 range - sshPort = 2222; # default — git-over-SSH; kept off 22 so it doesn't collide with the host openssh - openFirewall = false; # default — expose httpPort + sshPort to the host firewall + httpPort = 3000; # default — HTTP listener; outside hyperhive's 7000/8100-8999 range + sshPort = 2222; # default — git-over-SSH; kept off 22 so it doesn't collide with the host openssh }; +# Which ports the forge answers on is swarm-wide; whether THIS host opens +# them in its firewall is a deployment decision, so it lives under deploy.*. +services.hyperhive.deploy.forgejo.openFirewall = false; # default ``` `httpPort` (default **3000**) is the port Forgejo's HTTP server binds to. @@ -357,7 +359,7 @@ the bridge), not the raw port, so no firewall hole is needed. Flip to (not behind the gateway). - External git clients that push/pull via SSH directly to the host. -Forgejo served through the gateway (`forge.behindGateway = true`) does +Forgejo served through the gateway (`deploy.forgejo.behindGateway = true`) does not need `openFirewall` — the gateway's own `openFirewall` option covers that path. @@ -373,8 +375,8 @@ covers most cases: | Shape | Auto-derived `ROOT_URL` | |---|---| -| `behindGateway = true` | `https:///` (port suffix omitted when `gateway.httpsPort == 443`) | -| `behindGateway = false` | `http://:/` | +| `deploy.forgejo.behindGateway = true` | `https:///` (port suffix omitted when `gateway.httpsPort == 443`) | +| `deploy.forgejo.behindGateway = false` | `http://:/` | The gateway always terminates TLS, so the `behindGateway = true` case is always advertised over `https://`; only the direct (`behindGateway = diff --git a/docs/scheduler/ci.md b/docs/scheduler/ci.md index 6d1ced37..2640a155 100644 --- a/docs/scheduler/ci.md +++ b/docs/scheduler/ci.md @@ -100,7 +100,7 @@ build can't hold the runner's single slot indefinitely). ## Container design -- **Private netns, bridge-attached**: the container runs in its own network namespace (`privateNetwork = true`, `hostBridge`) and reaches hive-forge through the gateway at `http://` (resolved to the bridge IP via `networking.extraHosts`). It cannot reach host-loopback services — the core dashboard at `127.0.0.1:7000` and the raw forge port are unreachable from CI. Requires `forge.behindGateway = true`. +- **Private netns, bridge-attached**: the container runs in its own network namespace (`privateNetwork = true`, `hostBridge`) and reaches hive-forge through the gateway at `http://` (resolved to the bridge IP via `networking.extraHosts`). It cannot reach host-loopback services — the core dashboard at `127.0.0.1:7000` and the raw forge port are unreachable from CI. Requires `deploy.forgejo.behindGateway = true`. - **Non-ephemeral**: runner credentials persist across restarts (written to container's stateDir on first registration, reused thereafter). - **Sandbox fallback**: nspawn containers can't create user-namespaces, so nix's sandboxing would always fail. Module sets `nix.settings.sandbox-fallback = true` in the container — nix builds run unsandboxed (safe because the container is already isolated). See `docs/process/gotchas.md`. - **Credential isolation**: the forge admin token (`forge-core-token`) never enters the container. hive-c0re holds it and performs all forge API calls (runner validation + registration-token mint, in `forge/ci_runner.rs`); via hive-priv it writes only the runner registration token to the host env-file `/run/hive-ci/runner-token`, which the container bind-mounts read-only. @@ -150,10 +150,10 @@ forge provisioning sweep (orgs, agent accounts, etc.), so mirror seeding lives in the same place rather than a separate host-side unit. **General-purpose mirrors**: you can pre-seed any external repo as a -pull-mirror via `services.hyperhive.swarm.forge.mirrors`: +pull-mirror via `services.hyperhive.deploy.forgejo.mirrors`: ```nix -services.hyperhive.swarm.forge.mirrors = [ +services.hyperhive.deploy.forgejo.mirrors = [ { upstream = "https://github.com/actions/checkout"; dest = "actions/checkout"; } { upstream = "https://github.com/example/tool"; dest = "mirrors/tool"; } ]; diff --git a/docs/tools/hivectl.md b/docs/tools/hivectl.md index c701e39e..d985d582 100644 --- a/docs/tools/hivectl.md +++ b/docs/tools/hivectl.md @@ -314,6 +314,6 @@ an error. A surface has no URL when it isn't browser-reachable: `home` needs `services.hyperhive.domain`; `forge` needs -`services.hyperhive.swarm.forge.behindGateway = true`; `matrix` needs +`services.hyperhive.deploy.forgejo.behindGateway = true`; `matrix` needs `services.hyperhive.swarm.matrix.gui.enable = true`. In those cases the command exits with a hint naming the option to set. diff --git a/hive-c0re/src/dashboard/state_snapshot.rs b/hive-c0re/src/dashboard/state_snapshot.rs index 5b7fc68b..be12d782 100644 --- a/hive-c0re/src/dashboard/state_snapshot.rs +++ b/hive-c0re/src/dashboard/state_snapshot.rs @@ -94,8 +94,8 @@ pub(super) struct StateSnapshot { /// Public URL of the forge (e.g. /// `"https://forge.pr1ma.darkest.space"`). Sourced from the /// `HIVE_FORGE_PUBLIC_URL` env var, which the c0re NixOS module - /// sets from `services.hyperhive.forge.publicUrl` (defaults to - /// the gateway vhost URL when `forge.behindGateway = true`, + /// sets from `services.hyperhive.swarm.forge.publicUrl` (defaults to + /// the gateway vhost URL when `deploy.forgejo.behindGateway = true`, /// `null` otherwise). `None` when absent — the frontend **hides** /// forge links rather than guessing `http://:3000`, /// which is only right by accident on deployments that aren't diff --git a/hivectl/src/open.rs b/hivectl/src/open.rs index c497cbf0..16e366dd 100644 --- a/hivectl/src/open.rs +++ b/hivectl/src/open.rs @@ -26,7 +26,7 @@ pub(crate) async fn open_url(socket: &Path, target: OpenTarget) -> Result<()> { ), OpenTarget::Forge => ( urls.forge, - "the public forge URL needs `services.hyperhive.forge.behindGateway = true`", + "the public forge URL needs `services.hyperhive.deploy.forgejo.behindGateway = true`", ), OpenTarget::Matrix => ( urls.matrix, diff --git a/nix/host-modules/deploy.nix b/nix/host-modules/deploy.nix index 29e0171c..78d74f71 100644 --- a/nix/host-modules/deploy.nix +++ b/nix/host-modules/deploy.nix @@ -98,6 +98,38 @@ in [ "services" "hyperhive" "deploy" "forgejo" "ci" "package" ] ) + # The rest of the forge split. What stays under `swarm.forge` is what the + # forge IS from any hive's point of view — its package, the names and ports + # it answers on, the URLs it advertises, the client id it is registered + # under; these five are what the host running it decides. `sso` splits + # because its two halves are different facts: the client id must match the + # entry in authelia's register, the secret is a path on this machine. + # + # `mirrors` renames in one entry rather than one per field: it is a single + # option of a list-of-submodule type, so the rename carries its whole + # value. The `ci` block above needs five because it is a plain attrset of + # separate options, which is the case with no parent path to rename. + (lib.mkRenamedOptionModule + [ "services" "hyperhive" "swarm" "forge" "behindGateway" ] + [ "services" "hyperhive" "deploy" "forgejo" "behindGateway" ] + ) + (lib.mkRenamedOptionModule + [ "services" "hyperhive" "swarm" "forge" "openFirewall" ] + [ "services" "hyperhive" "deploy" "forgejo" "openFirewall" ] + ) + (lib.mkRenamedOptionModule + [ "services" "hyperhive" "swarm" "forge" "mirrors" ] + [ "services" "hyperhive" "deploy" "forgejo" "mirrors" ] + ) + (lib.mkRenamedOptionModule + [ "services" "hyperhive" "swarm" "forge" "sso" "clientSecretFile" ] + [ "services" "hyperhive" "deploy" "forgejo" "sso" "clientSecretFile" ] + ) + (lib.mkRenamedOptionModule + [ "services" "hyperhive" "swarm" "forge" "hostSwarmControllerTokenFile" ] + [ "services" "hyperhive" "deploy" "forgejo" "hostSwarmControllerTokenFile" ] + ) + # Retention is read only where the container is defined, so it is a # decision of the host running the store rather than something the swarm # agrees on. The two stores keep everything else — package, domain, port diff --git a/nix/host-modules/hive-c0re/environment.nix b/nix/host-modules/hive-c0re/environment.nix index 8539e6b4..9882bead 100644 --- a/nix/host-modules/hive-c0re/environment.nix +++ b/nix/host-modules/hive-c0re/environment.nix @@ -220,8 +220,8 @@ in # breaks the moment the operator's browser hostname isn't the forge # host, e.g. through the gateway or a reverse proxy). Sourced from # `services.hyperhive.swarm.forge.publicUrl`, which itself defaults to the - # gateway vhost URL when `behindGateway = true` and `null` otherwise - # — see that option's doc for the "hide, don't guess" rationale. + # gateway vhost URL when `deploy.forgejo.behindGateway = true` and `null` + # otherwise — see that option's doc for the "hide, don't guess" rationale. # Absent here whenever `publicUrl` is `null`; the dashboard hides # forge links rather than emitting one it can't justify. HIVE_FORGE_PUBLIC_URL = config.services.hyperhive.swarm.forge.publicUrl; diff --git a/nix/host-modules/hive-ci.nix b/nix/host-modules/hive-ci.nix index e52f8adb..1e41285d 100644 --- a/nix/host-modules/hive-ci.nix +++ b/nix/host-modules/hive-ci.nix @@ -7,6 +7,7 @@ let cfg = config.services.hyperhive.deploy.forgejo.ci; forgeCfg = config.services.hyperhive.swarm.forge; + forgeDeployCfg = config.services.hyperhive.deploy.forgejo; gatewayCfg = config.services.hyperhive.gateway; networkCfg = config.services.hyperhive.network; tlsCfg = config.services.hyperhive.deploy.hive-controller.tls; @@ -79,8 +80,8 @@ in example = true; description = '' Run a Forgejo Actions runner in a `hive-ci` nixos-container. - Grouped under `services.hyperhive.swarm.forge` because the runner is - tightly coupled to the forge instance it registers against. + Grouped under `services.hyperhive.deploy.forgejo` because the runner + is tightly coupled to the forge instance it registers against. Disabled by default; the internal forge it registers against is always present (mandatory), so enabling this is all that's needed. @@ -153,17 +154,17 @@ in }; config = lib.mkIf cfg.enable { - # `forge.behindGateway = true` (the default) is required because the - # CI container uses private networking and reaches the forge through + # `deploy.forgejo.behindGateway = true` (the default) is required because + # the CI container uses private networking and reaches the forge through # the gateway vhost. Without the gateway vhost there is no HTTP # listener for `forgeCfg.domain` on the bridge that the runner can # connect to. assertions = [ { - assertion = forgeCfg.behindGateway; + assertion = forgeDeployCfg.behindGateway; message = '' services.hyperhive.deploy.forgejo.ci.enable requires - services.hyperhive.swarm.forge.behindGateway = true. + services.hyperhive.deploy.forgejo.behindGateway = true. The CI container runs with a private network namespace and reaches the forge through the gateway vhost on the bridge IP. Set behindGateway = true (it defaults to true alongside @@ -216,8 +217,8 @@ in # Private network namespace, attached to the hive bridge so the # runner reaches the forge via the gateway — and cannot reach # host-loopback (127.0.0.1:7000 dashboard, raw forge port, etc.). - # Requires `forge.behindGateway = true` (asserted in the options - # block above). See docs/networking/network.md. + # Requires `deploy.forgejo.behindGateway = true` (asserted in the + # options block above). See docs/networking/network.md. privateNetwork = true; hostBridge = networkCfg.bridgeName; diff --git a/nix/host-modules/hive-forge/default.nix b/nix/host-modules/hive-forge/default.nix index 43bc9ea1..b89b5120 100644 --- a/nix/host-modules/hive-forge/default.nix +++ b/nix/host-modules/hive-forge/default.nix @@ -75,7 +75,7 @@ let # links. Operators can still override via `cfg.rootUrl` for bespoke # shapes. defaultRootUrl = - if cfg.behindGateway then + if deployCfg.forgejo.behindGateway then let portSuffix = if gatewayCfg.httpsPort == 443 then "" else ":${toString gatewayCfg.httpsPort}"; in @@ -98,9 +98,9 @@ let # operator hasn't already declared that dest themselves (else CI-on + # an explicit `actions/checkout` entry would duplicate it). effectiveMirrors = - cfg.mirrors + deployCfg.forgejo.mirrors ++ lib.optional ( - ciEnabled && !(lib.any (m: m.dest == actionCheckoutMirror.dest) cfg.mirrors) + ciEnabled && !(lib.any (m: m.dest == actionCheckoutMirror.dest) deployCfg.forgejo.mirrors) ) actionCheckoutMirror; in { @@ -133,7 +133,7 @@ in 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.swarm.forge.sso.clientSecretFile and + false, set services.hyperhive.deploy.forgejo.sso.clientSecretFile and services.hyperhive.swarm.authelia.url as the assertions describe. '') ]; @@ -178,7 +178,7 @@ in description = '' Public hostname for the forge. Doubles as both the forgejo `DOMAIN` setting (clone URLs forgejo advertises) AND the - gateway vhost server-name when `behindGateway = true` + 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 @@ -201,7 +201,10 @@ in publicUrl = lib.mkOption { type = lib.types.nullOr lib.types.str; default = - if config.services.hyperhive.enable && cfg.behindGateway then "https://${cfg.domain}" else null; + if config.services.hyperhive.enable && deployCfg.forgejo.behindGateway then + "https://${cfg.domain}" + else + null; defaultText = lib.literalExpression '' if behindGateway then "https://''${domain}" else null ''; @@ -212,7 +215,7 @@ in 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 `behindGateway = + 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 @@ -221,7 +224,7 @@ in container port is only an accident away from wrong on any deployment that isn't plain localhost). - **Set this explicitly if `behindGateway = false`** and the + **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 @@ -244,6 +247,60 @@ in ''; }; + 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, for the same reason the + # forge itself has none. + # + # **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`. + ''; + }; + # 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: its + # package, 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 — 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 = { behindGateway = lib.mkOption { type = lib.types.bool; default = config.services.hyperhive.enable; @@ -274,29 +331,6 @@ in ''; }; - 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: - - - `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 `:`. - - `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. - ''; - }; - openFirewall = lib.mkOption { type = lib.types.bool; default = false; @@ -312,8 +346,8 @@ in **Breaking change**: this used to default to `true`. If you relied on the old default for external reach, add - `services.hyperhive.swarm.forge.openFirewall = true;` to your host - config before rebuilding. + `services.hyperhive.deploy.forgejo.openFirewall = true;` to your + host config before rebuilding. ''; }; @@ -360,45 +394,27 @@ in ''; }; - # The swarm's authelia is always registered as an OpenID Connect - # login source here — there is no toggle, for the same reason the - # forge itself has none. - # - # **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`. - ''; - }; + # The other half of the SSO pair: the client *id* is swarm-wide (it has + # to match authelia's register), the secret is a path on this host. The + # design note for the login source itself is with the id. + sso.clientSecretFile = lib.mkOption { + type = lib.types.nullOr lib.types.str; + default = null; + example = "/var/lib/hyperhive/forge-oidc-secret"; + description = '' + Path **inside the forge container** holding the client + secret's plaintext. - clientSecretFile = lib.mkOption { - type = lib.types.nullOr lib.types.str; - default = null; - example = "/var/lib/hyperhive/forge-oidc-secret"; - description = '' - Path **inside the forge container** holding the client - secret's plaintext. + A path, never a value: an OIDC client secret has two holders + in two containers (authelia keeps a hash, this forge needs the + plaintext), and a literal written here would be rendered into + the world-readable nix store. - A path, never a value: an OIDC client secret has two holders - in two containers (authelia keeps a hash, this forge needs the - plaintext), and a literal written here would be rendered into - the world-readable nix store. - - Required when `enable` is set — deliberately no fallback. A - forge that boots with SSO half-configured presents as a login - button that always fails, which is harder to diagnose than an - eval error. - ''; - }; + Required when `enable` is set — deliberately no fallback. A + forge that boots with SSO half-configured presents as a login + button that always fails, which is harder to diagnose than an + eval error. + ''; }; hostSwarmControllerTokenFile = lib.mkOption { @@ -443,13 +459,13 @@ in # Both halves are gated on `behindGateway`: with it off the operator # fronts forgejo themselves, so this hive must neither claim the # vhost nor answer DNS for it. - services.hyperhive.gateway.localNames = lib.optional cfg.behindGateway cfg.domain; + services.hyperhive.gateway.localNames = lib.optional deployCfg.forgejo.behindGateway cfg.domain; # This swarm-ui quick-links entry, same `behindGateway` guard as the # vhost/DNS name above — with it off, this host doesn't actually # serve `cfg.domain`, so linking to it would be dead. See # `services.hyperhive.swarm.controller.links`'s description. - services.hyperhive.swarm.controller.links = lib.optional cfg.behindGateway { + services.hyperhive.swarm.controller.links = lib.optional deployCfg.forgejo.behindGateway { label = "Forge"; icon = "⚒"; url = "https://${cfg.domain}/"; @@ -466,15 +482,17 @@ in # what authelia compares against — this string agreeing with the # `location` block above it is the whole mechanism. A near miss is a # correctly minted token refused at the target. - services.hyperhive.swarm.otel.publishedScrapeTargets = lib.optionalAttrs cfg.behindGateway { - forgejo = "https://${cfg.domain}/metrics"; - }; + services.hyperhive.swarm.otel.publishedScrapeTargets = + lib.optionalAttrs deployCfg.forgejo.behindGateway + { + forgejo = "https://${cfg.domain}/metrics"; + }; # `server_name = forge.domain`, proxies all `/` → forgejo. Tuned for # git: `client_max_body_size 1G`, `proxy_read_timeout 1h` (multi-GB # clones). SSH stays direct on `forge.sshPort`. See # `docs/networking/gateway.md`. - services.nginx.virtualHosts = lib.optionalAttrs cfg.behindGateway { + services.nginx.virtualHosts = lib.optionalAttrs deployCfg.forgejo.behindGateway { "${cfg.domain}" = (gatewayCfg.lib.tlsFor cfg.domain) // { listen = gatewayCfg.lib.listen; extraConfig = gatewayCfg.lib.securityHeaders; @@ -543,10 +561,10 @@ in { # Fail at EVAL, not at boot. The alternative failure is a login # button that always 401s, three layers from the missing file. - assertion = cfg.sso.clientSecretFile != null; + assertion = deployCfg.forgejo.sso.clientSecretFile != null; message = '' The forge's SSO login source requires - services.hyperhive.swarm.forge.sso.clientSecretFile — the path + services.hyperhive.deploy.forgejo.sso.clientSecretFile — the path (inside the forge container) holding the OIDC client secret's plaintext. @@ -600,7 +618,7 @@ in # splits on the single slash to create the org + repo. assertion = lib.all (m: lib.length (lib.splitString "/" m.dest) == 2) effectiveMirrors; message = '' - Every services.hyperhive.swarm.forge.mirrors[].dest must be exactly + Every services.hyperhive.deploy.forgejo.mirrors[].dest must be exactly "/" (one slash). Got: ${lib.concatMapStringsSep ", " (m: m.dest) effectiveMirrors} ''; } @@ -618,7 +636,7 @@ in ]) ) effectiveMirrors; message = '' - services.hyperhive.swarm.forge.mirrors[].dest must not place a mirror + services.hyperhive.deploy.forgejo.mirrors[].dest must not place a mirror in a hive-c0re-managed org (config / shared / agents / core) — those are provisioned by hive-c0re and a mirror there would collide. Use a dedicated org (e.g. "actions/checkout"). @@ -771,7 +789,7 @@ in # scraper at the gateway, so a second credential system per # service would buy nothing and would be the one that stops # getting rotated. - metrics.ENABLED = cfg.behindGateway; + metrics.ENABLED = deployCfg.forgejo.behindGateway; # The two per-dimension breakdowns, on the same condition as # the endpoint itself: `gitea_issues_by_label{label=…}` and # `gitea_issues_by_repository{repository=…}`. Off by default @@ -790,8 +808,8 @@ in # forge that grew to thousands of repos would want this # revisited — that is a real trigger, unlike a time-based one: # `count(gitea_issues_by_repository)` answers it directly. - metrics.ENABLED_ISSUE_BY_LABEL = cfg.behindGateway; - metrics.ENABLED_ISSUE_BY_REPOSITORY = cfg.behindGateway; + metrics.ENABLED_ISSUE_BY_LABEL = deployCfg.forgejo.behindGateway; + metrics.ENABLED_ISSUE_BY_REPOSITORY = deployCfg.forgejo.behindGateway; # Repo migrations / pull-mirrors fetch from the source # URL *inside* Forgejo. hyperhive code is synced from # `localhost` (and the host LAN), which Forgejo's @@ -1025,9 +1043,9 @@ in script = '' set -euo pipefail - secret=$(cat ${lib.escapeShellArg cfg.sso.clientSecretFile}) + secret=$(cat ${lib.escapeShellArg deployCfg.forgejo.sso.clientSecretFile}) if [ -z "$secret" ]; then - echo "empty OIDC client secret at ${cfg.sso.clientSecretFile}" >&2 + echo "empty OIDC client secret at ${deployCfg.forgejo.sso.clientSecretFile}" >&2 exit 1 fi @@ -1204,7 +1222,7 @@ in # Same case, same reasoning: this host minted the secret, so it can # say where the forge will find it. - services.hyperhive.swarm.forge.sso.clientSecretFile = lib.mkIf ssoLocal ( + services.hyperhive.deploy.forgejo.sso.clientSecretFile = lib.mkIf ssoLocal ( lib.mkDefault forgeSecretPath ); @@ -1298,7 +1316,7 @@ in set -euo pipefail src=${lib.escapeShellArg "/var/lib/nixos-containers/hive-forge${swarmControllerTokenPath}"} - dst=${lib.escapeShellArg cfg.hostSwarmControllerTokenFile} + dst=${lib.escapeShellArg deployCfg.forgejo.hostSwarmControllerTokenFile} for _ in $(seq 1 60); do [ -s "$src" ] && break @@ -1313,7 +1331,7 @@ in ''; }; - networking.firewall = lib.mkIf cfg.openFirewall { + networking.firewall = lib.mkIf deployCfg.forgejo.openFirewall { allowedTCPPorts = [ cfg.httpPort cfg.sshPort diff --git a/nix/host-modules/swarm-authelia.nix b/nix/host-modules/swarm-authelia.nix index 9684293f..afb989f6 100644 --- a/nix/host-modules/swarm-authelia.nix +++ b/nix/host-modules/swarm-authelia.nix @@ -1361,7 +1361,7 @@ in # Denied or client-scoped depending on whether a # collector is registered — see `metricsRule` above, # which is where the reasoning for both halves lives. - lib.optional forgeCfg.behindGateway metricsRule + lib.optional deployCfg.forgejo.behindGateway metricsRule # Also guarded on the domain being set: without it a null # apex would render a rule matching the string "null". ++ lib.optional (deployCfg.swarm-ui.enable && swarmDomain != null) { diff --git a/nix/host-modules/swarm-controller.nix b/nix/host-modules/swarm-controller.nix index e46f2c2e..bb7d8ddd 100644 --- a/nix/host-modules/swarm-controller.nix +++ b/nix/host-modules/swarm-controller.nix @@ -413,8 +413,8 @@ in 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 + # The forge has no `enable` of its own to check — neither half of + # its split namespace carries one — 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 @@ -423,7 +423,8 @@ in # 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; + default = + if config.services.hyperhive.enable then deployCfg.forgejo.hostSwarmControllerTokenFile else null; defaultText = lib.literalExpression '' forge's own `hostSwarmControllerTokenFile` when this host runs hyperhive at all (forge has no separate enable), else null @@ -440,7 +441,7 @@ in `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` + {option}`services.hyperhive.deploy.forgejo.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 diff --git a/nix/module-eval.nix b/nix/module-eval.nix index 396d356a..9af2b083 100644 --- a/nix/module-eval.nix +++ b/nix/module-eval.nix @@ -72,6 +72,23 @@ let swarm.wireguard.privateKeyFile = "/etc/wireguard/hive.key"; }; + # Same shape for the forge, which SPLIT rather than moving whole: the five + # host-side options are set here through their pre-rename paths while the + # rest of `swarm.forge` stays put. All five are defined so that dropping any + # single shim entry fails the eval, not just the two the assertion reads. + forgeOldPath = hive { + swarm.forge.behindGateway = true; + swarm.forge.openFirewall = true; + swarm.forge.hostSwarmControllerTokenFile = "/etc/forge/sc.token"; + swarm.forge.sso.clientSecretFile = "/etc/forge/oidc.secret"; + swarm.forge.mirrors = [ + { + upstream = "https://example.invalid/tool"; + dest = "mirrors/tool"; + } + ]; + }; + baoPkcs11 = hive { deploy.bao.enable = true; deploy.bao.seal = "pkcs11"; @@ -205,6 +222,20 @@ let in wg.ips == [ "10.100.0.1/24" ] && wg.privateKeyFile == "/etc/wireguard/hive.key"; } + { + # Same reasoning one namespace over, plus the shape the mesh did not + # have: `mirrors` is a single option of a list-of-submodule type, so its + # one rename entry has to carry a whole compound value rather than a + # scalar. Both arms read a rendered effect — the host firewall and the + # env var c0re seeds mirrors from — not the option. + name = "a config written against the pre-rename forge paths still opens the firewall and seeds the mirror"; + ok = + let + ports = forgeOldPath.networking.firewall.allowedTCPPorts; + seeded = builtins.fromJSON forgeOldPath.systemd.services.hive-c0re.environment.HYPERHIVE_FORGE_MIRRORS; + in + builtins.elem 3000 ports && builtins.any (m: m.dest == "mirrors/tool") seeded; + } { # The gateway's per-name issuer choice. If this ever collapses to a # constant, every swarm-service vhost serves a certificate its CA