# `checks.module-eval` — the flake check that covers **nix**. # # Why this exists: every other check in ./checks.nix is a Rust # derivation, so a `.nix`-only diff moves no hash, every check is a # cache hit, and `nix flake check` reports green **without evaluating # what changed**. This one's derivation hash is a function of the # evaluated *results* below, so a nix change that flips a property # rebuilds it and the builder fails naming that property. # # ## What belongs here, and what does not # # 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 be an assertion is the **absence class** — "a hive that # hasn't opted in renders exactly what it did before", "this unit does # not exist unless X". Those are claims about the *rendered config* # rather than about a config being invalid, so they need an evaluator. # # ⚠️ **Cases are named by the PROPERTY they defend, never by the ticket # that prompted them.** A case named after a ticket has the ticket's # lifetime; a case named after a property lives as long as the property. # # ⚠️ **This check evaluates. It does not execute.** Where the artifact is # a command line, an HTTP request or a certificate, a value assertion # cannot stand in — those need something that *runs* them. And a case # that needs a **rendered file** must stub the packages that file drags # in (`swarm.ui.package = pkgs.emptyDirectory`), or it costs a full # frontend build to answer a question about a listen directive. { 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; allLocal = hive { enableAllLocalDefaults = true; }; bare = hive { }; withCi = hive { swarm.forge.ci.enable = true; }; # A priority collision is a property of the *option*, not # of the merged value's interior — nix throws the moment the value is # demanded at all, so `seq`-ing each `serviceConfig` value to WHNF is # both necessary and sufficient. `deepSeq` over-specifies this: it keeps # walking *into* the resulting value after the merge already succeeded, # and a package/derivation-shaped value's `override`/`overrideAttrs` # self-reference sends it into nixpkgs' fixpoint machinery and blows the # stack (measured — this is not a hypothetical). forceCiServiceConfigs = let svcs = withCi.containers.hive-ci.config.systemd.services; vals = lib.concatMap (s: builtins.attrValues (s.serviceConfig or { })) (builtins.attrValues svcs); in builtins.foldl' (acc: v: builtins.seq v acc) true vals; # Each case: a name stating the property, and `ok`. cases = [ { name = "a hive that has not opted into all-local runs no swarm controller"; ok = !bare.services.hyperhive.swarm.controller.enable; } { name = "the all-local mode turns the swarm controller on"; ok = allLocal.services.hyperhive.swarm.controller.enable; } { # The gateway's per-name issuer choice. If this ever collapses to a # constant, every swarm-service vhost serves a certificate its CA # is name-constrained out of — which evaluates cleanly and fails in # a browser. name = "a swarm service name gets the swarm-services leaf and the default server does not"; ok = let l = allLocal.services.hyperhive.gateway.lib; in (l.tlsFor "t.local").sslCertificate != (l.tlsFor "_").sslCertificate; } { # nixos asserts when a vhost declares both, so this is also a # statement that the `removeAttrs` upstream of it still happens. name = "the swarm UI vhost forces TLS instead of merely adding it"; ok = let v = allLocal.services.nginx.virtualHosts."t.local"; in v.forceSSL && !(v.addSSL or false); } { name = "a hive with matrix off serves no matrix discovery endpoint"; ok = !(builtins.hasAttr "= /.well-known/matrix/client" bare.services.nginx.virtualHosts."_".locations); } { # main got eval-borked twice by this exact class of bug (once on the # unit's `Restart` key, once on `RestartSec`) — a nixpkgs bump to # `gitea-actions-runner.nix` adds a plain `serviceConfig.*` # definition that collides with one of ours, and nix refuses to # merge two plain definitions at *host* eval. No other check # instantiates a host with `containers.hive-ci` actually enabled, so # the collision only surfaces on operator deploy, not in CI. name = "the CI container's unit definitions merge without a priority collision"; ok = forceCiServiceConfigs; } ]; bad = builtins.filter (c: !c.ok) cases; report = lib.concatMapStringsSep "\n" (c: " echo '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" { } '' ${report} ${ if bad == [ ] then "echo '${toString (builtins.length cases)} module properties hold' && touch $out" else "echo 'module-eval: ${toString (builtins.length bad)} of ${toString (builtins.length cases)} properties broke' >&2 && exit 1" } ''