# `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-controller.ui.package = pkgs.emptyDirectory`). # # `hive`/`hiveWithController`/`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 { hiveName = "h1"; swarm.domain = "t.local"; swarm.hives.h1.domain = "h1.t.local"; } extra; } ]; }).config; # `hive` with the hive-controller tier on (hive-c0re, the agent containers, # `deploy.hive-controller.*`). Only a fixture whose claim reads that tier # takes this; every other claim is about the swarm services and evaluates # without a hive on the host. hiveWithController = extra: hive (lib.recursiveUpdate { deploy.hive-controller.enable = true; } extra); # 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; agent = extra: agentWith { services.hyperhive.agent = 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; autheliaRules = machine: machine.containers.swarm-authelia.config.services.authelia.instances.swarm.settings.access_control.rules; # Whether authelia's `rules`, read first-match-wins, leave no subject-scoped # allow open to other subjects, and leave no rule unreachable. # # A rule with a `subject` does not match a subject outside it, so that # subject falls through to `default_policy` unless a later subject-less # `deny` on the same domain, with no `resources` or the same ones, catches # it first. Walking forward from the allow, the first subject-less rule on # its domain that is either an allow or such a `deny` must be the `deny`. # A subject-less `deny` on narrower `resources` refuses everyone it # matches, so it is passed over. Rules naming a subject are passed over too. # # A subject-less `deny` with no `resources` matches every request on its # domain, so no rule for that domain may follow it. # ⚠️ True of a list with no subject rule at all; a case using it also # asserts that the rules it reads contain the ones it means. autheliaSubjectRulesClosed = rules: let n = lib.length rules; at = lib.elemAt rules; after = i: map at (lib.range (i + 1) (n - 1)); anyone = r: !(r ? subject); closes = r: d: d.policy == "deny" && (!(d ? resources) || d.resources == (r.resources or null)); closed = i: let r = at i; stop = lib.findFirst ( d: d.domain == r.domain && anyone d && (d.policy != "deny" || closes r d) ) null (after i); in stop != null && closes r stop; endsDomain = d: anyone d && d.policy == "deny" && !(d ? resources); nothingAfter = i: lib.all (r: r.domain != (at i).domain) (after i); in lib.all ( i: (anyone (at i) || (at i).policy == "deny" || closed i) && (!(endsDomain (at i)) || nothingAfter i) ) (lib.range 0 (n - 1)); agentHarness = machine: machine.systemd.services.hive-agent; # The PRIORITY → severity operator both journald receivers attach, read # straight off the file that defines it. What each TIER's case then asks is # only whether its own receiver carries this exact value — the contents are # somebody else's case (./journald-severity.nix), asserted once. # # Here rather than in one of the suites because three of them want it: the # two tiers, to say they are wired to it, and the suite that reads what it # says. A parser rather than the whole list, so a tier that later grows an # unrelated operator of its own still passes the wiring case. journaldSeverityParser = lib.findFirst (o: o.type or null == "severity_parser") null ( import ../journald-severity.nix ); # Whether a given receiver is wired to it. One expression, used by both # tiers, so "this receiver carries the shared mapping" is spelled once and # the two tier cases differ only in which receiver they hand it. carriesJournaldSeverity = receiver: lib.elem journaldSeverityParser (receiver.operators or [ ]); # 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 hiveWithController; inherit agent; inherit agentWith; inherit baoNames; inherit baoStream; inherit bridgePorts; inherit bridgePortsOrNone; inherit baoSettings; inherit otelSettings; inherit autheliaRules; inherit autheliaSubjectRulesClosed; inherit agentHarness; inherit journaldSeverityParser; inherit carriesJournaldSeverity; inherit swarmServiceEnables; inherit runGroup; }