diff --git a/nix/module-eval.nix b/nix/module-eval.nix index 0aa632df..263ee2c3 100644 --- a/nix/module-eval.nix +++ b/nix/module-eval.nix @@ -120,73 +120,6 @@ let withCi = hive { deploy.forgejo.ci.enable = true; }; - # A host configured against the pre-rename option path. `mkRenamedOptionModule` - # is the only thing carrying it, and nothing else in this suite would notice - # if it were dropped: the new path evaluates fine on its own, so a missing - # shim reads as a clean tree and breaks every existing operator config. - wireguardOldPath = hive { - swarm.wireguard.enable = true; - swarm.wireguard.address = "10.100.0.1/24"; - swarm.wireguard.privateKeyFile = "/etc/wireguard/hive.key"; - }; - - # Same shape for the forge, which SPLIT rather than moving whole: the six - # host-side options are set here through their pre-rename paths while the - # rest of `swarm.forge` stays put. All six are defined so that dropping any - # single shim entry fails the eval, not just the two the assertion reads. - forgeOldPath = hive { - swarm.forge.package = pkgs.emptyDirectory; - 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"; - } - ]; - }; - - # The homeserver's turn to split. All seven remaining host-side options are - # set through their pre-rename paths — including `gui.enable`, whose value is - # deliberately the opposite of its default so the definition has to actually - # land, and both packages, stubbed to a derivation neither option defaults to - # for the same reason. All seven so that dropping any single shim entry fails - # the eval, not just the ones the assertions read. - # - # `registrationTokenFile` used to be the eighth: both spellings of it are now - # `mkRemovedOptionModule`, exercised by `matrixRemovedToken` below instead — - # setting it here would make every case that reads this fixture fail on that - # one removed option. - matrixOldPath = hive { - deploy.matrix.enable = true; - swarm.matrix.openFirewall = true; - swarm.matrix.trustedServers = [ "matrix.example.invalid" ]; - swarm.matrix.maxRequestSize = 31457280; - swarm.matrix.gui.enable = false; - swarm.matrix.package = pkgs.emptyDirectory; - swarm.matrix.gui.package = pkgs.emptyDirectory; - swarm.matrix.sso.clientSecretFile = "/etc/matrix/oidc.secret"; - }; - - # Moving the appservice token, which desyncs hive-c0re's independently - # derived path from the homeserver's. Its own fixture because the assertion - # it trips would otherwise fire for every case reading `matrixOldPath`. - matrixMovedAppserviceToken = hive { - deploy.matrix.enable = true; - deploy.matrix.appserviceTokenFile = "/etc/matrix/as.token"; - swarm.matrix.sso.clientSecretFile = "/etc/matrix/oidc.secret"; - }; - - # A config still naming the removed registration token, through the - # pre-rename spelling — the one an old deployment is most likely to carry. - matrixRemovedToken = hive { - deploy.matrix.enable = true; - swarm.matrix.registrationTokenFile = "/etc/matrix/register.token"; - swarm.matrix.sso.clientSecretFile = "/etc/matrix/oidc.secret"; - }; - # The queue's callout identity, fourth split slice. `autoGenerateCallout` is # left FALSE on purpose: that is what makes the seed paths the thing deciding # `responderConfigured`, so the assertion below is about the seeds rather @@ -233,39 +166,6 @@ let deploy.matrix.maxRequestSize = 99000000; }; - # `enable` is spelled the OLD way like everything else here, so all six - # of the controller's shims are exercised rather than five. - controllerOldPath = hive { - swarm.controller.enable = true; - swarm.controller.package = pkgs.emptyDirectory; - swarm.controller.swarmctlPackage = pkgs.emptyDirectory; - swarm.controller.forgeTokenFile = "/run/secrets/ctrl-forge.token"; - swarm.controller.authBridgeUrl = "http://127.0.0.1:19097"; - swarm.controller.queue.clientSecretFile = "/run/secrets/ctrl-queue.secret"; - }; - - # The swarm UI, whose namespace this slice empties: `swarm.ui` had exactly - # two options and both have moved, so BOTH old paths are set here and the - # whole namespace now lives or dies by its two rename entries. - uiOldPath = hive { - swarm.ui.enable = true; - swarm.ui.package = pkgs.emptyDirectory; - }; - - # The two stores, which had NO old-path fixture at all until this slice — - # their `enable` and `retentionPeriod` shims have been uncovered since they - # landed, which is precisely the "a missing shim reads as a clean tree" - # failure ./module-eval.nix's wireguard fixture was written to catch. All six - # old paths are set, so dropping any single entry fails the eval. - storesOldPath = hive { - swarm.victoriametrics.enable = true; - swarm.victorialogs.enable = true; - swarm.victoriametrics.retentionPeriod = "3d"; - swarm.victorialogs.retentionPeriod = "5d"; - swarm.victoriametrics.package = pkgs.emptyDirectory; - swarm.victorialogs.package = pkgs.emptyDirectory; - }; - grafanaOldPath = hive { deploy.grafana.enable = true; swarm.grafana.socketDir = "/run/test-grafana-sock"; @@ -342,37 +242,6 @@ let && lib.hasInfix option a.message ) m.assertions; - # The first slice to leave options on BOTH sides of the split, so the - # fixture sets all three of them through the paths an existing config uses: - # the two movers via their rename entries, `tokenEndpoint` via the path it - # kept. That is also what the all-or-nothing assertion wants, so this hive - # is a valid one rather than one that only evaluates because nothing forced - # the assertion. - statusPublishOldPath = hive { - swarm.statusPublish.natsUrl = "nats://10.0.0.9:4222"; - swarm.statusPublish.tokenEndpoint = "https://auth.example.invalid/api/oidc/token"; - swarm.statusPublish.clientSecretFile = "/run/secrets/status-client.secret"; - }; - - # The eleventh migration, and the only one of the eleven with no old-path - # fixture. It differs from its ten siblings in what a shim DOES: the others - # re-route a value, so a broken one shows up as a wrong rendered config. This - # one's entire deliverable is a warning, which nothing reads back — so an - # unevaluated shim is a warning nobody sees, and it fails silently onto the - # one operator who needed it. - # - # Two peers, only ONE carrying `caCert`, because the module emits a SECOND - # warning filtered on exactly that attribute. With a single peer the filtered - # list and `attrNames` are the same list, and a `withCaCert` that had - # collapsed into `attrNames` would still read green. - peersOldPath = hive { - swarm.peers.old-one.domain = "old-one.t.local"; - swarm.peers.old-two = { - domain = "old-two.t.local"; - caCert = "/etc/ssl/old-two-ca.pem"; - }; - }; - baoPkcs11 = hive { deploy.bao.enable = true; deploy.bao.seal = "pkcs11"; @@ -996,83 +865,6 @@ let name = "the all-local mode turns the swarm controller on"; ok = allLocal.services.hyperhive.deploy.swarm-controller.enable; } - { - # The mesh moved namespace wholesale, so an existing config sets paths - # that no longer exist. Reading the *rendered interface* rather than the - # option: a rename that resolved but stopped reaching the module would - # satisfy an option-level check and still bring up no tunnel. - name = "a config written against the pre-rename wireguard path still configures the interface"; - ok = - let - wg = wireguardOldPath.networking.wireguard.interfaces.wg-hive; - 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; - } - { - # Third split, and the one whose readers were hardest to see: the - # appservice token is read only through a `let` alias in another - # module, so no full path names it anywhere. Both arms read a rendered - # effect — the host firewall and the container's bind-mount table — so a - # rename that resolves but stops reaching the module still fails. - name = "a config written against the pre-rename matrix paths still opens the port and mounts the appservice registration"; - ok = - let - ports = matrixOldPath.networking.firewall.allowedTCPPorts; - httpPort = matrixOldPath.services.hyperhive.swarm.matrix.httpPort; - in - builtins.elem httpPort ports - && matrixOldPath.containers.hive-matrix.bindMounts ? "/var/lib/hyperhive/matrix-appservice"; - } - { - # appserviceTokenFile has no override capability (a store-delivered - # secret doesn't need one — glue-matrix-bao-token.nix writes into the - # fixed path instead of moving it), unlike its siblings in the same - # rename. The fixture proves the override still *resolves* (mkDefault, - # not a crash); this proves it also gets *rejected*, by a named - # assertion rather than nixpkgs' generic conflicting-definition text. - # Reads `.assertions` directly (cheap: a list of `{assertion; - # message;}`, not `system.build.toplevel`) rather than forcing a real - # build just to observe a boolean. - name = "moving appserviceTokenFile trips a named assertion, not a silent desync"; - ok = lib.any ( - a: !a.assertion && lib.hasInfix "appserviceTokenFile" a.message - ) matrixMovedAppserviceToken.assertions; - } - { - # The removal has to SAY so. A config carrying the old option through - # an upgrade is the common case, and nixpkgs' bare "option does not - # exist" would name neither what replaced the token nor where to read - # about it. - name = "a config still setting registrationTokenFile is refused with a message naming the appservice"; - ok = lib.any (a: !a.assertion && lib.hasInfix "appservice" a.message) matrixRemovedToken.assertions; - } - { - # Reads the DELIVERY UNIT, not the options: `responderConfigured` gates - # whether it exists at all, and the seed path is interpolated into its - # script. A rename that resolved but stopped reaching the module would - # leave the responder with no credentials and this case would catch it. - name = "a config written against the pre-rename nats paths still delivers the responder's seeds"; - ok = - let - units = natsOldPath.systemd.services; - in - units ? swarm-nats-auth-secrets - && lib.hasInfix "/run/secrets/nats-user.seed" units.swarm-nats-auth-secrets.script; - } { # Reads the RENDERED settings, not the option: `calloutBlocks {…} // { # … }` is a shallow merge, and a future edit that dropped or shadowed @@ -1121,39 +913,6 @@ let in lib.hasInfix "--agent-publish-subject '$$SWARM.agent-state.{hive}.>'" exec; } - { - # Reads the host's tmpfiles rules, not the options: the socket directory - # nginx and the container share is created there, so a rename that - # resolved but stopped reaching the module would leave the gateway - # proxying to a path nothing creates. All three old paths are defined - # in the fixture, so removing any single shim fails the eval rather - # than only the one this assertion reads. - # THREE movers (authBridgeUrl, forgeTokenFile, queue.clientSecretFile) - # are asserted as rendered effects rather than as option values, so a - # rename that resolved but stopped reaching the module is caught - # per-option instead of only where one assertion happens to look. - # socketPath is no longer one of them: it went readOnly and lost its - # shim in the same slice, so it has no pre-rename path left to prove - # reaches the unit — the SOCKET line below instead pins the one value - # it can ever hold, the default, so the env var vanishing or being - # misspelled still fails this arm. - name = "a config written against the pre-rename swarm-controller paths still reaches the unit"; - ok = - let - u = controllerOldPath.systemd.services.swarm-controller; - creds = u.serviceConfig.LoadCredential; - in - u.environment.SWARM_CONTROLLER_SOCKET == "/run/swarm-controller/controller.sock" - && u.environment.SWARM_CONTROLLER_AUTH_BRIDGE_URL == "http://127.0.0.1:19097" - && lib.any (c: lib.hasInfix "/run/secrets/ctrl-queue.secret" c) creds - && lib.any (c: lib.hasInfix "/run/secrets/ctrl-forge.token" c) creds; - } - { - name = "a config written against the pre-rename grafana paths still creates the socket directory"; - ok = lib.any ( - rule: lib.hasInfix "/run/test-grafana-sock" rule - ) grafanaOldPath.systemd.tmpfiles.rules; - } { # This fixture enables grafana and NOT authelia, which is the shape the # login form used to stay enabled in: the toggle read "both services are @@ -1360,25 +1119,6 @@ let && otelNoIdentity.services.hyperhive.deploy.swarm-otel.clientSecretFile == null && !(lib.any (a: !a.assertion) otelNoIdentity.assertions); } - { - # Reads the daemon's rendered unit, not the options: the queue address - # arrives as an env var whose whole attrset is guarded on `natsUrl`, and - # the secret as a systemd credential. A rename that resolved but stopped - # reaching the module leaves hive-c0re coming up perfectly and reporting - # to nobody, which is the one failure this option set exists to prevent. - # The third arm is the option that did NOT move, read out of the same - # attrset: the guard and the value beside it now come from different - # namespaces, so a hive that renders one and drops the other is exactly - # what a split can silently produce. - name = "a config written against the pre-rename statusPublish paths still reaches the daemon"; - ok = - let - u = statusPublishOldPath.systemd.services.hive-c0re; - in - u.environment.HIVE_C0RE_NATS_URL == "nats://10.0.0.9:4222" - && u.environment.HIVE_C0RE_OIDC_TOKEN_ENDPOINT == "https://auth.example.invalid/api/oidc/token" - && lib.any (c: lib.hasInfix "/run/secrets/status-client.secret" c) u.serviceConfig.LoadCredential; - } { # The collector's half of the same split, and a different arm from the # authenticator case below: this one reads the PATH the unit loads, so a @@ -2343,43 +2083,6 @@ let # steps live in the same script and one indentation level decides it. && lib.hasInfix "bao secrets enable -path=secret kv-v2" s; } - { - # Reads the vhost's rendered `root`, not the option: the UI is served - # straight out of a store path, so a shim that resolves but stops - # reaching the module would leave nginx pointing at the default build. - name = "a config written against the pre-rename swarm-ui paths still serves the operator's build"; - # - # ⚠️ `unsafeDiscardStringContext` on both sides, load-bearing rather than - # tidy: interpolating a derivation carries string *context*, and this - # suite renders its results into a `buildCommand` that may not reference - # store paths. Comparing the paths as plain text is the intent — the case - # asks "does nginx point HERE", not "depend on what it points at". - ok = - let - want = builtins.unsafeDiscardStringContext "${pkgs.emptyDirectory}"; - # `or ""` is NOT enough: nginx's `locations..root` is `nullOr`, so - # a vhost that HAS the attribute set to null skips the default and - # reaches the coercion. Filter nulls, then discard context. - roots = map builtins.unsafeDiscardStringContext ( - lib.filter (r: r != null) ( - map (v: v.locations."/".root or null) (builtins.attrValues uiOldPath.services.nginx.virtualHosts) - ) - ); - in - builtins.elem want roots; - } - { - # Reads the package the CONTAINER renders, not the option: a shim that - # resolves but stops reaching the module would leave the store running - # nixpkgs' default while the operator's override read back fine. - name = "a config written against the pre-rename store paths still picks the operator's package"; - ok = - storesOldPath.containers.swarm-victorialogs.config.services.victorialogs.package - == pkgs.emptyDirectory - && - storesOldPath.containers.swarm-victoriametrics.config.services.victoriametrics.package - == pkgs.emptyDirectory; - } { # What makes the granting-unit cases mean something, and the property # the host-side half depends on: no store here, so no bind mount and no @@ -2388,47 +2091,6 @@ let name = "a bootstrap token on a host that runs no store grants nothing"; ok = !(baoGrantNoStore.systemd.services ? swarm-bao-bootstrap-dir); } - { - # Matches the option path, not the surrounding prose: the warning should - # stay rewordable, but it must not stop naming what was set or where it - # went. - name = "a config still setting the removed swarm.peers is told where the entries moved"; - ok = - let - hit = lib.filter ( - w: lib.hasInfix "services.hyperhive.swarm.peers is removed" w - ) peersOldPath.warnings; - in - lib.length hit == 1 - && lib.hasInfix "services.hyperhive.swarm.hives" (lib.head hit) - && lib.hasInfix "old-one, old-two" (lib.head hit); - } - { - # The caCert half is a separately filtered warning, so it has to name - # ONLY the peer that set one. Asserting the peer that did NOT is absent - # is what distinguishes the filter from `attrNames`. - name = "the removed peers.caCert warns about only the peers that set one"; - ok = - let - hit = lib.filter (w: lib.hasInfix "caCert is removed" w) peersOldPath.warnings; - in - lib.length hit == 1 - && lib.hasInfix "Still set on: old-two" (lib.head hit) - && !(lib.hasInfix "old-one" (lib.head hit)); - } - { - # Control for both cases above. Without it they pass on any config whose - # warning list happens to carry the string, including one where the shim - # fires unconditionally — which is the same defect wearing the other sign. - name = "a hive that never set swarm.peers gets neither removal warning"; - ok = !(lib.any (w: lib.hasInfix "swarm.peers" w) bare.warnings); - } - { - name = "a config written against the pre-rename authelia usersFile still reaches the bridge"; - ok = - autheliaOldPath.containers.swarm-authelia.config.systemd.services.swarm-authelia-bridge.environment.SWARM_AUTHELIA_BRIDGE_USERS_FILE - == "/var/lib/test-authelia/users.yml"; - } { # Not a rename test. `hostClientSecretDir` is `readOnly`, so the fixture # cannot define it; what can break is a reader left pointing at the