diff --git a/docs/networking/gateway.md b/docs/networking/gateway.md index 63838294..7fe765e1 100644 --- a/docs/networking/gateway.md +++ b/docs/networking/gateway.md @@ -1,6 +1,6 @@ # hive-gateway -Single nginx in front of every hyperhive web surface. Runs on the **host**, next to hive-c0re, rather than in its own container: it shares the host netns anyway (see [Vhost map](#vhost-map) below), so containerizing it would buy no network isolation while costing a resolv.conf sync, a machine-bus reload, and three bind mounts. System-config (not meta-flake managed). Configured via `services.hyperhive.gateway.*` + per-subsystem opt-in flags in `services.hyperhive.{forge,matrix,...}`. +Single nginx in front of every hyperhive web surface. Runs on the **host**, next to hive-c0re, rather than in its own container: it shares the host netns anyway (see [Vhost map](#vhost-map) below), so containerizing it would buy no network isolation while costing a resolv.conf sync, a machine-bus reload, and three bind mounts. System-config (not meta-flake managed). Configured via `services.hyperhive.gateway.*` + per-subsystem opt-in flags in `services.hyperhive.{forge,matrix,...}`. `gateway.enable` and `gateway.dns.enable` are asserted by the modules that need them, so a host serving a vhost or resolving hive names gets them without an opt-in. ## Vhost map diff --git a/docs/networking/network.md b/docs/networking/network.md index 0fb39114..920689e7 100644 --- a/docs/networking/network.md +++ b/docs/networking/network.md @@ -1,12 +1,12 @@ # hive-network -Host-side bridge + per-agent private-netns isolation — always on -whenever hyperhive is enabled. Configured via -`services.hyperhive.network.*`. +Host-side bridge + per-agent private-netns isolation, up on a host +where something attaches to it (`services.hyperhive.network.enable`, +asserted by the modules that need it rather than set by hand). +Configured via `services.hyperhive.network.*`. > Isolation is the only mode — there is no shared-netns fallback. The -> former `services.hyperhive.network.enable`, -> `services.hyperhive.network.isolateContainers` and +> former `services.hyperhive.network.isolateContainers` and > `services.hyperhive.network.upstreamDns` options were removed; a > config that still sets one fails eval with a removal message. diff --git a/nix/host-modules/hive-c0re/default.nix b/nix/host-modules/hive-c0re/default.nix index ddcb674f..f587489e 100644 --- a/nix/host-modules/hive-c0re/default.nix +++ b/nix/host-modules/hive-c0re/default.nix @@ -158,6 +158,13 @@ in ]; config = lib.mkIf cfg.enable { + # This module is the host's only knowledge that agent containers + # exist: they hang off the bridge, resolve through dnsmasq, and their + # UIs — plus the operator dashboard — are served by the gateway. + services.hyperhive.gateway.enable = lib.mkDefault true; + services.hyperhive.gateway.dns.enable = lib.mkDefault true; + services.hyperhive.network.enable = lib.mkDefault true; + # The daemon that owns every container on this hive, and the helper it # delegates its root operations to. An agent asking why a container did # not come up is asking about one of these two. diff --git a/nix/host-modules/hive-ci.nix b/nix/host-modules/hive-ci.nix index 3cb86f5c..536ce785 100644 --- a/nix/host-modules/hive-ci.nix +++ b/nix/host-modules/hive-ci.nix @@ -177,6 +177,13 @@ in # name follows that attr rather than `cfg.name`. services.hyperhive.swarm.otel.journaldUnits = [ "gitea-runner-hive" ]; + # The runner container has a private netns of its own, so it is + # attached to the bridge and reaches the forge by name through the + # gateway vhost the assertion above already insists on. + services.hyperhive.network.enable = lib.mkDefault true; + services.hyperhive.gateway.enable = lib.mkDefault true; + services.hyperhive.gateway.dns.enable = lib.mkDefault true; + # Create /run/hive-ci/ on the host and seed runner-token with a # placeholder. The container bind-mounts this file read-only; hive-c0re # (via hive-priv's RegisterCiRunner) overwrites it with the real diff --git a/nix/host-modules/hive-forge/default.nix b/nix/host-modules/hive-forge/default.nix index 32bb8921..1b22d878 100644 --- a/nix/host-modules/hive-forge/default.nix +++ b/nix/host-modules/hive-forge/default.nix @@ -455,6 +455,11 @@ in # fronts forgejo themselves, so this hive must neither claim the # vhost nor answer DNS for it. services.hyperhive.gateway.localNames = lib.optional deployCfg.forgejo.behindGateway cfg.domain; + services.hyperhive.gateway.enable = lib.mkIf deployCfg.forgejo.behindGateway (lib.mkDefault true); + + # Not conditional on `behindGateway`: the forge container resolves the + # rest of the hive through dnsmasq whoever fronts it. + services.hyperhive.gateway.dns.enable = lib.mkDefault true; # This swarm-ui quick-links entry, same `behindGateway` guard as the # vhost/DNS name above — with it off, this host doesn't actually diff --git a/nix/host-modules/hive-gateway/default.nix b/nix/host-modules/hive-gateway/default.nix index fe71e7f7..d0e74831 100644 --- a/nix/host-modules/hive-gateway/default.nix +++ b/nix/host-modules/hive-gateway/default.nix @@ -7,7 +7,9 @@ # Full vhost map + discovery flow + design rationale in # `docs/networking/gateway.md`. Layout: ./options.nix (option declarations), # ./vhosts.nix (the nginx virtual-host tree), ./error-pages.nix -# (styled static pages), ./dnsmasq.nix (resolver + DHCP config). +# (styled static pages), ./dns.nix + ./dnsmasq.nix (the resolver, which +# has an enable of its own because a host can need hive names to resolve +# without serving a vhost). { pkgs, lib, @@ -23,7 +25,6 @@ let swarmServiceDomains = config.services.hyperhive.swarm.serviceDomains; matrixCfg = config.services.hyperhive.swarm.matrix; matrixDeployCfg = config.services.hyperhive.deploy.matrix; - networkCfg = config.services.hyperhive.network; # Every vhost claiming `default_server`, ours and the operator's # alike. Computed once so the assertion below and the message it @@ -113,9 +114,12 @@ let }; in { - imports = [ ./options.nix ]; + imports = [ + ./options.nix + ./dns.nix + ]; - config = lib.mkIf config.services.hyperhive.enable { + config = lib.mkIf cfg.enable { # Publish the kit. Defined here rather than as an option `default` # so it stays a plain value computed once from resolved cert paths — # `tlsFor` closes over `svcCert`/`svcKey`, which are derived in this @@ -126,13 +130,7 @@ in # is the one unit that can say a service was unreachable rather than # merely quiet. Named even on hives that run no swarm collector: the # option is inert unless one is collecting on this host. - # - # dnsmasq alongside it for the same reason one level down: a name that - # stops resolving presents as every client timing out at once. - services.hyperhive.swarm.otel.journaldUnits = [ - "nginx" - "dnsmasq" - ]; + services.hyperhive.swarm.otel.journaldUnits = [ "nginx" ]; assertions = [ { @@ -255,32 +253,6 @@ in "f /var/lib/hive-gateway/conf/gateway.htpasswd 0644 hive-core hive-core - -" ]; - # The host asks the hive's own resolver, at the BRIDGE IP. - # - # Every container inherits a COPY of this host's `/etc/resolv.conf` - # at start (`nixos-containers.nix`: `cp --remove-destination`, one - # shot, not a bind-mount) — so whatever address is written here is - # the address every container will try, in its own netns. - # - # 🚨 That is why this is the bridge IP and not `127.0.0.1`, and the - # distinction is load-bearing rather than stylistic: - # - # value host host-netns containers bridged containers - # 127.0.0.1 ok ok THEIR OWN loopback - # bridge IP ok ok ok - # - # dnsmasq binds both `lo` and the bridge (./dnsmasq.nix), so the - # bridge IP is reachable from the host too — it is the only value - # correct on both sides of a netns boundary. `resolveLocalQueries` - # publishes loopback by default, hence both overrides here; the - # flag stays on for its `resolv-file` plumbing, which is what keeps - # dnsmasq's own upstreams out of the file we are pointing at it. - # - # Cost, stated because it is real: the host's DNS now depends on - # dnsmasq being up. Every container already did. - networking.nameservers = lib.mkForce [ networkCfg.bridgeIp ]; - networking.resolvconf.useLocalResolver = lib.mkForce false; - # ACME (Let's Encrypt) integration. nginx vhosts set # `enableACME = true` via the vhost builder; this provides the # shared ACME config (acceptTerms + email). @@ -423,18 +395,6 @@ in # dir removes the need and the exposure together. Re-adding this line # would restore both. - # dnsmasq is a host service alongside nginx, so it reads the host's - # /etc/resolv.conf directly and picks up network changes as they - # happen — no copy to keep in sync. - services.dnsmasq = import ./dnsmasq.nix { - inherit - lib - cfg - networkCfg - hyperhiveDomain - ; - }; - networking.firewall = lib.mkIf cfg.openFirewall { allowedTCPPorts = [ cfg.port diff --git a/nix/host-modules/hive-gateway/dns.nix b/nix/host-modules/hive-gateway/dns.nix new file mode 100644 index 00000000..3b635811 --- /dev/null +++ b/nix/host-modules/hive-gateway/dns.nix @@ -0,0 +1,63 @@ +# The hive's dnsmasq resolver. Split out of ./default.nix because it has +# its own enable: a host can need hive names to resolve without serving a +# single vhost. Option declared in ./options.nix, config rendered by +# ./dnsmasq.nix. +{ + lib, + config, + ... +}: +let + cfg = config.services.hyperhive.gateway; + networkCfg = config.services.hyperhive.network; + hyperhiveDomain = config.services.hyperhive.domain; +in +{ + config = lib.mkIf cfg.dns.enable { + # dnsmasq is a host service alongside nginx, so it reads the host's + # /etc/resolv.conf directly and picks up network changes as they + # happen — no copy to keep in sync. + services.dnsmasq = import ./dnsmasq.nix { + inherit + lib + cfg + networkCfg + hyperhiveDomain + ; + }; + + # dnsmasq binds the bridge interface and answers with the bridge + # address, so the resolver is itself a consumer one layer down. + services.hyperhive.network.enable = lib.mkDefault true; + + # A name that stops resolving presents as every client timing out at + # once, so this unit's journal is worth reading swarm-wide. + services.hyperhive.swarm.otel.journaldUnits = [ "dnsmasq" ]; + + # The host asks the hive's own resolver, at the BRIDGE IP. + # + # Every container inherits a COPY of this host's `/etc/resolv.conf` + # at start (`nixos-containers.nix`: `cp --remove-destination`, one + # shot, not a bind-mount) — so whatever address is written here is + # the address every container will try, in its own netns. + # + # 🚨 That is why this is the bridge IP and not `127.0.0.1`, and the + # distinction is load-bearing rather than stylistic: + # + # value host host-netns containers bridged containers + # 127.0.0.1 ok ok THEIR OWN loopback + # bridge IP ok ok ok + # + # dnsmasq binds both `lo` and the bridge (./dnsmasq.nix), so the + # bridge IP is reachable from the host too — it is the only value + # correct on both sides of a netns boundary. `resolveLocalQueries` + # publishes loopback by default, hence both overrides here; the + # flag stays on for its `resolv-file` plumbing, which is what keeps + # dnsmasq's own upstreams out of the file we are pointing at it. + # + # Cost, stated because it is real: the host's DNS now depends on + # dnsmasq being up. Every container already did. + networking.nameservers = lib.mkForce [ networkCfg.bridgeIp ]; + networking.resolvconf.useLocalResolver = lib.mkForce false; + }; +} diff --git a/nix/host-modules/hive-gateway/options.nix b/nix/host-modules/hive-gateway/options.nix index 010c96b5..5682144d 100644 --- a/nix/host-modules/hive-gateway/options.nix +++ b/nix/host-modules/hive-gateway/options.nix @@ -1,8 +1,7 @@ -# Option declarations for `services.hyperhive.gateway.*`. The gateway -# is always run alongside hyperhive (it's the single nginx in front of -# every surface and the only thing exposed to the outside); there is -# no enable flag. An operator who wants their own reverse proxy in -# front points it at the gateway's `port`. +# Option declarations for `services.hyperhive.gateway.*`. The gateway is +# the single nginx in front of every surface and the only thing exposed +# to the outside. An operator who wants their own reverse proxy in front +# points it at the gateway's `port`. { lib, config, @@ -22,6 +21,18 @@ in ]; options.services.hyperhive.gateway = { + enable = lib.mkOption { + type = lib.types.bool; + default = false; + description = "Run the hive's reverse proxy (nginx) on this host."; + }; + + dns.enable = lib.mkOption { + type = lib.types.bool; + default = false; + description = "Run the hive's resolver (dnsmasq) on this host."; + }; + port = lib.mkOption { type = lib.types.port; default = 80; diff --git a/nix/host-modules/hive-matrix.nix b/nix/host-modules/hive-matrix.nix index 53aa9c15..eeb72d16 100644 --- a/nix/host-modules/hive-matrix.nix +++ b/nix/host-modules/hive-matrix.nix @@ -705,6 +705,11 @@ in # than fronted, so there is no name to claim and no vhost to serve — # every clause below carries that guard. services.hyperhive.gateway.localNames = lib.optional (cfg.gatewayHost != null) cfg.gatewayHost; + services.hyperhive.gateway.enable = lib.mkIf (cfg.gatewayHost != null) (lib.mkDefault true); + + # Unguarded, unlike the two above: the homeserver container resolves + # through the hive's dnsmasq whether or not the gateway fronts it. + services.hyperhive.gateway.dns.enable = lib.mkDefault true; # The homeserver's own journal (`tuwunel` is the unit name inside the # container, whatever the nixpkgs option is called), plus the host-side diff --git a/nix/host-modules/hive-network.nix b/nix/host-modules/hive-network.nix index c37cefe4..9c1c608e 100644 --- a/nix/host-modules/hive-network.nix +++ b/nix/host-modules/hive-network.nix @@ -29,16 +29,10 @@ let networkBase = builtins.bitAnd (ipToInt cfg.bridgeIp) (4294967295 - hostCount + 1); in { - # Hive-internal network — host-side bridge + per-agent DNS resolver. - # Always active when hyperhive is enabled: agent containers run in - # private netns behind the bridge. Full design: docs/networking/network.md. + # Hive-internal network — the host-side bridge every container in a + # private netns hangs off. Full design: docs/networking/network.md. imports = [ - (lib.mkRemovedOptionModule [ "services" "hyperhive" "network" "enable" ] '' - The hive network (bridge + dnsmasq resolver + private-netns - isolation) is always on whenever hyperhive is enabled. Remove the - setting. - '') (lib.mkRemovedOptionModule [ "services" "hyperhive" "network" "isolateContainers" ] '' Network isolation is the only mode and is always on whenever hyperhive is enabled; the shared-netns path was removed. Remove @@ -52,6 +46,12 @@ in ]; options.services.hyperhive.network = { + enable = lib.mkOption { + type = lib.types.bool; + default = false; + description = "Bring up the hive bridge, its NAT and its firewall rules on this host."; + }; + bridgeName = lib.mkOption { type = lib.types.str; default = "hive-br0"; @@ -148,9 +148,10 @@ in }; config = lib.mkMerge [ - # The hive network + container isolation are unconditional whenever - # hyperhive is enabled: the shared-netns mode was removed, so there - # is one mode (private netns behind the bridge). + # Identity checks belong to hyperhive being on, not to the bridge + # being up: a hive with no domain is misconfigured either way, and + # moving them under the bridge gate would hide them on exactly the + # hosts that are hardest to debug. (lib.mkIf config.services.hyperhive.enable { # This message is only useful if an operator can actually reach # it, and an assertion competes with every eager default that @@ -206,7 +207,11 @@ in ''; } ]; + }) + # The bridge itself, up only where something hangs off it. Private + # netns is still the only container mode. + (lib.mkIf cfg.enable { # Virtual bridge — each agent container attaches a veth pair (isolation # is unconditional now). networking.bridges.${cfg.bridgeName}.interfaces = [ ]; @@ -233,9 +238,10 @@ in }; }) - # Container isolation overlay — now unconditional (the shared-netns - # mode was removed). See docs/networking/network.md#container-isolation. - (lib.mkIf config.services.hyperhive.enable { + # Container isolation overlay — the routing, NAT and firewall half of + # the same bridge, so it rides the same gate. + # See docs/networking/network.md#container-isolation. + (lib.mkIf cfg.enable { # Agents route internet traffic via the bridge; NAT masquerades their RFC-1918 IPs. boot.kernel.sysctl."net.ipv4.ip_forward" = 1; diff --git a/nix/host-modules/otel.nix b/nix/host-modules/otel.nix index 77df0175..9cc0745b 100644 --- a/nix/host-modules/otel.nix +++ b/nix/host-modules/otel.nix @@ -336,6 +336,10 @@ in # the port on the bridge interface only. services.hyperhive.network.exposeHostPorts = [ otel.collector.port ]; + # …and the receiver binds that bridge address, so the interface has + # to exist. `exposeHostPorts` is a firewall hole, not a demand. + services.hyperhive.network.enable = lib.mkDefault true; + # The collector scrapes itself. Its own counters — refused, failed, # queue depth — are the only signal that says telemetry is being # dropped, and they reach no store unless something reads them. diff --git a/nix/host-modules/swarm-authelia.nix b/nix/host-modules/swarm-authelia.nix index 3b03796d..8d3596fa 100644 --- a/nix/host-modules/swarm-authelia.nix +++ b/nix/host-modules/swarm-authelia.nix @@ -1096,6 +1096,11 @@ in # bridge at that wrong answer. services.hyperhive.gateway.localNames = [ cfg.domain ]; + # This host serves the vhost, and the container behind it resolves + # through the hive's dnsmasq. + services.hyperhive.gateway.enable = lib.mkDefault true; + services.hyperhive.gateway.dns.enable = lib.mkDefault true; + # The bridge as well as authelia: it is the half that writes the identity # store, and its refusals are returned to callers as a bare 401. # diff --git a/nix/host-modules/swarm-bao.nix b/nix/host-modules/swarm-bao.nix index dd752c28..762cab99 100644 --- a/nix/host-modules/swarm-bao.nix +++ b/nix/host-modules/swarm-bao.nix @@ -819,6 +819,13 @@ in # this file's header. `localNames` is the one half of the sibling # pattern that applies. services.hyperhive.gateway.localNames = [ cfg.domain ]; + + # All three, and no vhost among them: the `ssl_preread` stream + # server below needs the nginx process without asking it to serve + # anything, and it listens on the bridge address. + services.hyperhive.gateway.enable = lib.mkDefault true; + services.hyperhive.gateway.dns.enable = lib.mkDefault true; + services.hyperhive.network.enable = lib.mkDefault true; }) (lib.mkIf (hyperhiveCfg.enable && deployCfg.bao.enable) { diff --git a/nix/host-modules/swarm-grafana.nix b/nix/host-modules/swarm-grafana.nix index 48252441..f6e9d209 100644 --- a/nix/host-modules/swarm-grafana.nix +++ b/nix/host-modules/swarm-grafana.nix @@ -385,6 +385,11 @@ in # hive declaring the vhost would answer for a service it does not have. services.hyperhive.gateway.localNames = [ cfg.domain ]; + # This host serves the vhost, and the container behind it resolves + # through the hive's dnsmasq. + services.hyperhive.gateway.enable = lib.mkDefault true; + services.hyperhive.gateway.dns.enable = lib.mkDefault true; + # The secret oneshots as well as grafana itself: each runs before it and # fails in ways grafana then reports only as a login that does not work. # diff --git a/nix/host-modules/swarm-nats.nix b/nix/host-modules/swarm-nats.nix index 1e7145da..a13974c3 100644 --- a/nix/host-modules/swarm-nats.nix +++ b/nix/host-modules/swarm-nats.nix @@ -439,6 +439,10 @@ in "swarm-nats-auth" ]; + # Resolver only: NATS speaks its own protocol, so nginx fronts + # nothing here — but the auth responder introspects authelia by name. + services.hyperhive.gateway.dns.enable = lib.mkDefault true; + assertions = [ { # Fail at EVAL, not at boot: a queue that comes up unable to diff --git a/nix/host-modules/swarm-otel.nix b/nix/host-modules/swarm-otel.nix index 3e5271f6..fc62e281 100644 --- a/nix/host-modules/swarm-otel.nix +++ b/nix/host-modules/swarm-otel.nix @@ -570,6 +570,11 @@ in # vhost would answer for a service it does not have. services.hyperhive.gateway.localNames = [ cfg.domain ]; + # This host serves the vhost, and the container behind it resolves + # through the hive's dnsmasq. + services.hyperhive.gateway.enable = lib.mkDefault true; + services.hyperhive.gateway.dns.enable = lib.mkDefault true; + # This collector reads its own journal, so a pipeline that stops # delivering says so in the store it stopped delivering to. That is # less circular than it sounds: the failure that matters here is a diff --git a/nix/host-modules/swarm-required-services.nix b/nix/host-modules/swarm-required-services.nix index bc57d663..a7e392cc 100644 --- a/nix/host-modules/swarm-required-services.nix +++ b/nix/host-modules/swarm-required-services.nix @@ -87,6 +87,18 @@ in # hive that is not the service host is a *client* of it, not a second one. config.services.hyperhive.deploy.victorialogs.enable = lib.mkDefault deployCfg.allSwarmServices; + # The plumbing those services are reached over: every one of them is + # fronted by the gateway, resolved through the hive's dnsmasq, and runs + # in a container hanging off the bridge. Written as `mkIf … mkDefault` + # rather than `mkDefault allSwarmServices` because the modules that need + # these also assert them — a `false` from here would collide with their + # `true` instead of losing to it. + config.services.hyperhive.gateway.enable = lib.mkIf deployCfg.allSwarmServices (lib.mkDefault true); + config.services.hyperhive.gateway.dns.enable = lib.mkIf deployCfg.allSwarmServices ( + lib.mkDefault true + ); + config.services.hyperhive.network.enable = lib.mkIf deployCfg.allSwarmServices (lib.mkDefault true); + # The secret store. Once per swarm and optional, so it belongs to the # same switch: a hive that does not run it is a *client*, reading its # own secrets from whoever does. `mkDefault` is what keeps the store diff --git a/nix/host-modules/swarm-ui.nix b/nix/host-modules/swarm-ui.nix index 44844b87..f196d0b2 100644 --- a/nix/host-modules/swarm-ui.nix +++ b/nix/host-modules/swarm-ui.nix @@ -132,6 +132,10 @@ in # makes the name resolve at all. services.hyperhive.gateway.localNames = [ apex ]; + # Gateway only — static files served by nginx, no container of its + # own and so nothing to resolve. + services.hyperhive.gateway.enable = lib.mkDefault true; + # This UI's own swagger docs, always same-origin (`/api/docs/` below) # so — unlike authelia/matrix/forge's entries — this one needs no # host name and is never conditional on anything but this module diff --git a/nix/host-modules/swarm-victorialogs.nix b/nix/host-modules/swarm-victorialogs.nix index 8ee8f2db..d312b5cf 100644 --- a/nix/host-modules/swarm-victorialogs.nix +++ b/nix/host-modules/swarm-victorialogs.nix @@ -153,6 +153,11 @@ in # `swarm-victoriametrics.nix`). services.hyperhive.gateway.localNames = [ cfg.domain ]; + # This host serves the vhost, and the container behind it resolves + # through the hive's dnsmasq. + services.hyperhive.gateway.enable = lib.mkDefault true; + services.hyperhive.gateway.dns.enable = lib.mkDefault true; + # Declared here rather than in the collector, so this store's logs are # collected because it runs, not because a list elsewhere remembered it. services.hyperhive.swarm.otel.journaldUnits = [ "victorialogs" ]; diff --git a/nix/host-modules/swarm-victoriametrics.nix b/nix/host-modules/swarm-victoriametrics.nix index 5758bafb..90430733 100644 --- a/nix/host-modules/swarm-victoriametrics.nix +++ b/nix/host-modules/swarm-victoriametrics.nix @@ -103,6 +103,11 @@ in # have. services.hyperhive.gateway.localNames = [ cfg.domain ]; + # This host serves the vhost, and the container behind it resolves + # through the hive's dnsmasq. + services.hyperhive.gateway.enable = lib.mkDefault true; + services.hyperhive.gateway.dns.enable = lib.mkDefault true; + # Declared here rather than in the collector, so this store's logs are # collected because it runs, not because a list elsewhere remembered it. services.hyperhive.swarm.otel.journaldUnits = [ "victoriametrics" ]; diff --git a/nix/module-eval.nix b/nix/module-eval.nix index 7ba38cf1..e8607cb1 100644 --- a/nix/module-eval.nix +++ b/nix/module-eval.nix @@ -104,8 +104,20 @@ let # that quietly re-introduces the dependency — or that changes what the # default renders for a hive with the toggle on — fails here. Reading an # option off this fixture forces that option only, not the config, so the - # toggle being off costs nothing. + # toggle being off costs nothing. Also the "installs the modules and turns + # nothing on" host the swarm-service absences below read: none of the + # per-service deployment toggles derives from the hive being on, so it + # renders the same absences `bare` does. centralToggleOff = hive { enable = false; }; + + # The swarm-services toggle with the central one off, so the only thing + # that can enable the gateway/resolver/bridge here is that toggle's own + # module — every other module that asserts them is behind `enable`. + swarmServicesOnly = hive { + enable = false; + deploy.allSwarmServices = true; + }; + withCi = hive { deploy.forgejo.ci.enable = true; }; # A host configured against the pre-rename option path. `mkRenamedOptionModule` @@ -3237,6 +3249,48 @@ let name = "the swarm UI claims the swarm apex where this host serves it"; ok = swarmUiHere.services.nginx.virtualHosts ? "t.local"; } + { + # The three infrastructure toggles are off by default and asserted by + # whoever needs them. With nothing on the host needing them, none of + # the three renders — which is also the control for the arm below. + name = "the gateway, resolver and bridge are absent where nothing on the host needs them"; + ok = + !centralToggleOff.services.hyperhive.gateway.enable + && !centralToggleOff.services.hyperhive.gateway.dns.enable + && !centralToggleOff.services.hyperhive.network.enable + && !(centralToggleOff.services.nginx.enable or false) + && !(centralToggleOff.services.dnsmasq.enable or false) + && !(centralToggleOff.networking.bridges ? hive-br0); + } + { + # hive-c0re asserts all three, and it follows the central toggle — so + # an ordinary hive keeps getting them with no opt-in, which is what + # this change must not break. + name = "an ordinary hive runs the gateway, resolver and bridge because its coordinator needs them"; + ok = + bare.services.hyperhive.gateway.enable + && bare.services.hyperhive.gateway.dns.enable + && bare.services.hyperhive.network.enable + && bare.services.nginx.enable + && bare.services.dnsmasq.enable + && bare.networking.bridges ? hive-br0; + } + { + # The swarm-services toggle enables them explicitly, from its own + # module rather than from any of their defaults. + name = "the swarm-services toggle turns on the gateway, resolver and bridge by itself"; + ok = + swarmServicesOnly.services.hyperhive.gateway.enable + && swarmServicesOnly.services.hyperhive.gateway.dns.enable + && swarmServicesOnly.services.hyperhive.network.enable; + } + { + # An operator's explicit `false` beats every `mkDefault` assertion, + # which is what keeps "asserted by whoever needs it" from being a + # setting the operator cannot turn off. + name = "an explicit gateway.enable = false wins over the modules asserting it"; + ok = !(hive { gateway.enable = false; }).services.nginx.enable; + } ]; bad = builtins.filter (c: !c.ok) cases;