# `checks.module-eval-*` — the flake checks that cover **nix**. # # Split from one monolithic `module-eval` derivation into small # independent checks, one per subsystem cluster, so that evaluating any # single derivation only has to hold a handful of full `nixosSystem` # fixtures live at once instead of all of them together — the original # combined ~62 fixtures into one derivation and measured 10.6GB peak RSS / # 5m25s to evaluate. Every other check in ./checks.nix is a Rust # derivation, so a `.nix`-only diff moves no hash and `nix flake check` # goes green **without evaluating what changed**; these derivations' hash # is a function of their cases' results, so a change that flips a # property rebuilds the owning check and fails naming it. # # Anything expressible as a module `assertion` **should be one instead** — # an assertion fires at deploy time for a real operator, not only in CI. # What cannot is the **absence class**: "a hive that hasn't opted in # renders what it did before", "this unit does not exist unless X" — claims # about the rendered config rather than about a config being invalid. # # ⚠️ **Name a case for the PROPERTY it defends, never the ticket that # prompted it**; a case named after a ticket has the ticket's lifetime. # # ⚠️ **This evaluates, it does not execute** — a command line, a request or # a certificate needs something that *runs* it. A case needing a rendered # file must stub what it drags in (`deploy.swarm-ui.package = pkgs.emptyDirectory`). # # `hive`/`agent`/`agentWith` builders, the shared cross-group helpers, and # the `runGroup` derivation builder live in ./lib.nix; each file below # defines only the fixtures and cases its own cluster needs. { pkgs, lib, self, nixosSystem, }: let # Stub host, same shape ./docs/default.nix already uses: enough for a # `nixosSystem` to evaluate, nothing that pulls a real disk or # bootloader in. hive = extra: (nixosSystem { system = pkgs.stdenv.hostPlatform.system; modules = [ self.nixosModules.default { fileSystems."/" = { device = "/dev/null"; fsType = "tmpfs"; }; boot.loader.grub.enable = false; system.stateVersion = "25.11"; # `recursiveUpdate`, not `//`: a plain `//` only merges the # *top-level* keys of `extra` in, so an `extra` that touches a # nested attr under an existing top-level key (e.g. `swarm.*`) # silently drops every sibling under that key instead of merging # into it — the same class of bug as the `services.hyperhive` # double-nesting mistake this stub already had to dodge once, # just one level down. `recursiveUpdate` merges nested attrsets # all the way down instead. services.hyperhive = lib.recursiveUpdate { enable = true; hiveName = "h1"; swarm.domain = "t.local"; swarm.hives.h1.domain = "h1.t.local"; } extra; } ]; }).config; # The other half of the tree. `nix/agent-modules/` is evaluated by nothing # else in this suite — every fixture above is a host — so a rendered # container config was only ever read by a real deploy. Same entry point # the meta flake hands a container, so what this evaluates is what an # agent gets. # # Takes a whole module rather than a settings attrset, so a fixture can # reach either spelling of the agent tier. `user.name` is the one option # with no usable default, set here at its real path and at `mkDefault` so a # fixture naming its own agent still wins. agentWith = module: (nixosSystem { system = pkgs.stdenv.hostPlatform.system; modules = [ self.nixosModules.agent-base { fileSystems."/" = { device = "/dev/null"; fsType = "tmpfs"; }; boot.loader.grub.enable = false; system.stateVersion = "25.11"; services.hyperhive.agent.user.name = lib.mkDefault "a1"; } module ]; }).config; # Note `hyperhive`, not `services.hyperhive`: the agent tier's options moved # under `services.hyperhive.agent`, and ../agent-modules/renamed-options.nix # keeps the top-level spelling reaching them. ⚠️ That shim covers the # options that existed when the tier moved and nothing since, so a fixture # for an option added afterwards has to go through [`agentWith`] and name # the real path. agent = extra: agentWith { hyperhive = extra; }; baoNames = machine: machine.services.hyperhive.gateway.localNames; # What nginx is handed for its `stream {}` block. Deliberately not # `virtualHosts`: a vhost is the terminating shape ./host-modules/ # swarm-bao.nix's header refuses, and this is the passthrough that is not. baoStream = machine: machine.services.nginx.streamConfig; # The bridge-interface firewall — the list `network.exposeHostPorts` merges # into, and the only place a port is opened for agents. The host's own # `allowedTCPPorts` is a different list and a different exposure. bridgePorts = machine: machine.networking.firewall.interfaces.${machine.services.hyperhive.network.bridgeName}.allowedTCPPorts; # Same list, on a host that may not declare the interface at all: the # `[ 80 443 ]` block is what creates the attr, and it is off where the hive # is. Reading it through `bridgePorts` would throw rather than report an # empty exposure, which is precisely the state the cases below assert. bridgePortsOrNone = machine: (machine.networking.firewall.interfaces.${machine.services.hyperhive.network.bridgeName} or { allowedTCPPorts = [ ]; } ).allowedTCPPorts; # The config file openbao parses, not the nix that produces it: a setting it # requires is absent here without anything in the module system minding, so # the daemon's own startup is otherwise the first reader. baoSettings = machine: machine.containers.swarm-bao.config.services.openbao.settings; otelSettings = machine: machine.containers.swarm-otel.config.services.opentelemetry-collector.settings; agentHarness = machine: machine.systemd.services.hive-agent; # The enables that switch owns. ⚠️ `otel` is the per-hive collector's own # option and is NOT under `deploy` — spelled at the wrong path it would be # undeclared rather than false, and a roster that quietly loses a member is # what the count guard in the cases below exists to catch. Its membership # here is deliberate and was the fix for a gap, not an oversight: the hive # tier lands wherever the swarm services do. swarmServiceEnables = machine: let h = machine.services.hyperhive; in { matrix = h.deploy.matrix.enable; otel = h.otel.enable; authelia = h.deploy.authelia.enable; nats = h.deploy.nats.enable; swarm-otel = h.deploy.swarm-otel.enable; victoriametrics = h.deploy.victoriametrics.enable; grafana = h.deploy.grafana.enable; victorialogs = h.deploy.victorialogs.enable; bao = h.deploy.bao.enable; }; runGroup = name: cases: let bad = builtins.filter (c: !c.ok) cases; # Escaped, because a name is prose and prose contains apostrophes. Hand-quoting # broke the builder mid-report on the first such name that failed — and only # ever on failure, so every green run agreed the reporter was fine. report = lib.concatMapStringsSep "\n" ( c: " echo ${lib.escapeShellArg "FAILED: ${c.name}"} >&2" ) bad; in # The results are embedded in the builder text on purpose: that is what # makes this derivation's hash depend on them, so a nix-only change that # flips a case cannot be answered from cache. pkgs.runCommand "hyperhive-module-eval-${name}" { } '' ${report} ${ if bad == [ ] then "echo '${toString (builtins.length cases)} module properties hold in ${name}' && touch $out" else "echo 'module-eval-${name}: ${toString (builtins.length bad)} of ${toString (builtins.length cases)} properties broke' >&2 && exit 1" } ''; in { inherit hive; inherit agent; inherit agentWith; inherit baoNames; inherit baoStream; inherit bridgePorts; inherit bridgePortsOrNone; inherit baoSettings; inherit otelSettings; inherit agentHarness; inherit swarmServiceEnables; inherit runGroup; }