diff --git a/nix/lib/aspects/fx/pipeline.nix b/nix/lib/aspects/fx/pipeline.nix index c5830a62..7923783e 100644 --- a/nix/lib/aspects/fx/pipeline.nix +++ b/nix/lib/aspects/fx/pipeline.nix @@ -119,28 +119,37 @@ let # It defaults to `name`, so only kinds that rewrite `name` differ here. # Synthetic context values (e.g. a bare `{ name = ...; }` host) carry no # `__scopeName` and fall back to `name`. + # A ctx value that cannot be NAMED carries no identity: it can only render as + # its own type (`den=`), which distinguishes nothing and splits one + # logical scope across two ids depending on which module args happened to be + # bound on the path. Such keys are dropped from the IDENTITY; they stay in the + # context itself, where binding still reads them. + nameScopeValue = + k: v: + if builtins.isAttrs v && v ? __scopeName then + v.__scopeName + else if builtins.isAttrs v && v ? name then + v.name + else if builtins.isString v then + v + else if builtins.isInt v || builtins.isFloat v then + toString v + else + null; + mkScopeId = ctx: lib.concatStringsSep "," ( lib.sort (a: b: a < b) ( - map ( - k: - let - v = ctx.${k}; - in - "${k}=${ - if builtins.isAttrs v && v ? __scopeName then - v.__scopeName - else if builtins.isAttrs v && v ? name then - v.name - else if builtins.isString v then - v - else if builtins.isInt v || builtins.isFloat v then - toString v - else - "<${builtins.typeOf v}:${k}>" - }" - ) (builtins.attrNames ctx) + builtins.filter (s: s != null) ( + map ( + k: + let + named = nameScopeValue k ctx.${k}; + in + if named == null then null else "${k}=${named}" + ) (builtins.attrNames ctx) + ) ) ); diff --git a/templates/ci/modules/features/deadbugs/quirk-instance-includes-darwin.nix b/templates/ci/modules/features/deadbugs/quirk-instance-includes-darwin.nix new file mode 100644 index 00000000..3fc21ec4 --- /dev/null +++ b/templates/ci/modules/features/deadbugs/quirk-instance-includes-darwin.nix @@ -0,0 +1,93 @@ +# theutz on #682: a quirk producer reached through `den.hosts..includes` +# yields an empty collection at the consumer, while the same producer reached +# through `den.aspects..includes` yields its value. Two screenshots, a +# two-byte diff (`hosts` -> `aspects`), same eval: +# +# den.hosts.kocaeli.includes = [ rootshell ]; => homebrew.taps == [ ] +# den.aspects.kocaeli.includes = [ rootshell ]; => homebrew.taps == [ {...} ] +# +# Distinct from #681: ONE definition of the collection, so nothing is being +# dropped by the registry merge. The reported config is darwin and uses the +# flat `den.hosts.` spelling, neither of which the linux cells cover. +{ denTest, lib, ... }: +{ + flake.tests.deadbugs.quirk-instance-includes-darwin = { + + # CONTROL: the aspect spelling from the second screenshot. + test-darwin-aspect-includes = denTest ( + { den, apple, ... }: + { + den.hosts.aarch64-darwin.apple = { }; + den.quirks.taps.description = "homebrew taps"; + + den.aspects.rootshell.taps = [ "kitknox/rootshell" ]; + den.aspects.apple = { + includes = [ den.aspects.rootshell ]; + darwin = + { + taps ? [ ], + ... + }: + { + environment.etc."taps".text = lib.concatStringsSep "," (lib.flatten taps); + }; + }; + + expr = apple.environment.etc."taps".text or ""; + expected = "kitknox/rootshell"; + } + ); + + # The reported case, two-level spelling. + test-darwin-instance-includes = denTest ( + { den, apple, ... }: + { + den.quirks.taps.description = "homebrew taps"; + + den.aspects.rootshell.taps = [ "kitknox/rootshell" ]; + den.aspects.apple.darwin = + { + taps ? [ ], + ... + }: + { + environment.etc."taps".text = lib.concatStringsSep "," (lib.flatten taps); + }; + + den.hosts.aarch64-darwin.apple.includes = [ den.aspects.rootshell ]; + + expr = apple.environment.etc."taps".text or ""; + expected = "kitknox/rootshell"; + } + ); + + # The reported case verbatim: flat spelling, and the host declared in one + # module with the collection added from another, as in the screenshots + # (`kocaeli.nix` declares the host, `rootshell.nix` adds the include). + test-darwin-flat-instance-includes-split = denTest ( + { den, apple, ... }: + { + den.quirks.taps.description = "homebrew taps"; + + den.aspects.rootshell.taps = [ "kitknox/rootshell" ]; + den.aspects.apple.darwin = + { + taps ? [ ], + ... + }: + { + environment.etc."taps".text = lib.concatStringsSep "," (lib.flatten taps); + }; + + imports = [ + { den.hosts.apple.system = "aarch64-darwin"; } + { den.hosts.apple.includes = [ den.aspects.rootshell ]; } + ]; + + expr = apple.environment.etc."taps".text or ""; + expected = "kitknox/rootshell"; + } + ); + + }; +} diff --git a/templates/ci/modules/features/deadbugs/quirk-via-instance-includes.nix b/templates/ci/modules/features/deadbugs/quirk-via-instance-includes.nix new file mode 100644 index 00000000..daf74ff8 --- /dev/null +++ b/templates/ci/modules/features/deadbugs/quirk-via-instance-includes.nix @@ -0,0 +1,306 @@ +# Follow-up to the report behind #681: is quirk collection entrypoint-agnostic, +# or does a quirk reached through `den.hosts...includes` behave +# differently from one reached through `den.aspects..includes`? +# +# It is agnostic. Both spellings land in the same `host=` scope +# (`resolve-entity.nix` folds the instance's collection into the entity root +# aspect), so the pipe effects share a bucket. #681's merge defect was the only +# divergence and it was not quirk-specific: the instance registry dropped one +# of two list definitions whatever the list carried. +# +# Every cell pairs an instance spelling with its aspect control, so a future +# divergence shows up as one arm going red rather than as both. +{ denTest, lib, ... }: +{ + flake.tests.deadbugs.quirk-via-instance-includes = { + + # CONTROL: the aspect spelling — documented working path. + test-via-aspect-includes = denTest ( + { den, igloo, ... }: + { + den.hosts.x86_64-linux.igloo.users.tux = { }; + den.quirks.persist.description = "Paths to persist"; + + den.aspects.libraries.producer.persist.directories = [ "/var/lib/alpha" ]; + den.aspects.libraries.consumer.nixos = + { persist, ... }: + { + environment.etc."persisted".text = lib.concatStringsSep "," ( + lib.concatMap (p: p.directories or [ ]) persist + ); + }; + + den.aspects.igloo.includes = [ + den.aspects.libraries.producer + den.aspects.libraries.consumer + ]; + + expr = igloo.environment.etc."persisted".text or ""; + expected = "/var/lib/alpha"; + } + ); + + # The reported case: same producer/consumer, included at the instance. + test-via-host-instance-includes = denTest ( + { den, igloo, ... }: + { + den.hosts.x86_64-linux.igloo.users.tux = { }; + den.quirks.persist.description = "Paths to persist"; + + den.aspects.libraries.producer.persist.directories = [ "/var/lib/alpha" ]; + den.aspects.libraries.consumer.nixos = + { persist, ... }: + { + environment.etc."persisted".text = lib.concatStringsSep "," ( + lib.concatMap (p: p.directories or [ ]) persist + ); + }; + + den.hosts.x86_64-linux.igloo.includes = [ + den.aspects.libraries.producer + den.aspects.libraries.consumer + ]; + + expr = igloo.environment.etc."persisted".text or ""; + expected = "/var/lib/alpha"; + } + ); + + # Flat host spelling (preprocessHosts groups it by `system`). + test-via-flat-host-instance-includes = denTest ( + { den, igloo, ... }: + { + den.quirks.persist.description = "Paths to persist"; + + den.aspects.libraries.producer.persist.directories = [ "/var/lib/alpha" ]; + den.aspects.libraries.consumer.nixos = + { persist, ... }: + { + environment.etc."persisted".text = lib.concatStringsSep "," ( + lib.concatMap (p: p.directories or [ ]) persist + ); + }; + + den.hosts.igloo = { + system = "x86_64-linux"; + users.tux = { }; + includes = [ + den.aspects.libraries.producer + den.aspects.libraries.consumer + ]; + }; + + expr = igloo.environment.etc."persisted".text or ""; + expected = "/var/lib/alpha"; + } + ); + + # Producer at the instance, consumer on the host aspect. + test-instance-producer-aspect-consumer = denTest ( + { den, igloo, ... }: + { + den.quirks.persist.description = "Paths to persist"; + + den.aspects.libraries.producer.persist.directories = [ "/var/lib/alpha" ]; + + den.aspects.igloo.nixos = + { persist, ... }: + { + environment.etc."persisted".text = lib.concatStringsSep "," ( + lib.concatMap (p: p.directories or [ ]) persist + ); + }; + + den.hosts.x86_64-linux.igloo = { + users.tux = { }; + includes = [ den.aspects.libraries.producer ]; + }; + + expr = igloo.environment.etc."persisted".text or ""; + expected = "/var/lib/alpha"; + } + ); + + # Producer on the host aspect, consumer at the instance. + test-aspect-producer-instance-consumer = denTest ( + { den, igloo, ... }: + { + den.quirks.persist.description = "Paths to persist"; + + den.aspects.igloo.persist.directories = [ "/var/lib/alpha" ]; + + den.aspects.libraries.consumer.nixos = + { persist, ... }: + { + environment.etc."persisted".text = lib.concatStringsSep "," ( + lib.concatMap (p: p.directories or [ ]) persist + ); + }; + + den.hosts.x86_64-linux.igloo = { + users.tux = { }; + includes = [ den.aspects.libraries.consumer ]; + }; + + expr = igloo.environment.etc."persisted".text or ""; + expected = "/var/lib/alpha"; + } + ); + + # Instance-included producer feeding a pipe policy. + test-instance-producer-pipe-policy = denTest ( + { den, igloo, ... }: + { + den.quirks.firewall.description = "Firewall ports"; + + den.aspects.libraries.producer.firewall = [ + { + port = 80; + proto = "tcp"; + } + { + port = 53; + proto = "udp"; + } + ]; + + den.aspects.libraries.consumer.nixos = + { firewall, ... }: + { + networking.hostName = lib.concatMapStringsSep "-" (f: toString f.port) firewall; + }; + + den.policies.filter-tcp = + { host, ... }: + [ (den.lib.policy.pipe.from "firewall" [ (den.lib.policy.pipe.filter (e: e.proto == "tcp")) ]) ]; + + den.default.includes = [ den.policies.filter-tcp ]; + + den.hosts.x86_64-linux.igloo = { + users.tux = { }; + includes = [ + den.aspects.libraries.producer + den.aspects.libraries.consumer + ]; + }; + + expr = igloo.networking.hostName; + expected = "80"; + } + ); + + # User instance includes carrying the producer for that user's own consumer. + test-user-instance-includes = denTest ( + { den, tuxHm, ... }: + { + den.default.homeManager.home.stateVersion = "25.11"; + den.quirks.hmvals.description = "hm values"; + + den.aspects.libraries.producer.hmvals = [ "u" ]; + den.aspects.libraries.consumer.homeManager = + { + hmvals ? [ ], + ... + }: + { + home.sessionVariables.MARKER = lib.concatStringsSep "-" hmvals; + }; + + den.hosts.x86_64-linux.igloo.users.tux.includes = [ + den.aspects.libraries.producer + den.aspects.libraries.consumer + ]; + + expr = tuxHm.home.sessionVariables.MARKER or ""; + expected = "u"; + } + ); + + # The #681 merge, carrying a quirk rather than class content: both + # definitions must survive for the producer to reach the consumer. + test-instance-includes-two-definitions = denTest ( + { den, igloo, ... }: + { + den.quirks.persist.description = "Paths to persist"; + + den.aspects.libraries.producer.persist.directories = [ "/var/lib/alpha" ]; + den.aspects.libraries.consumer.nixos = + { persist, ... }: + { + environment.etc."persisted".text = lib.concatStringsSep "," ( + lib.concatMap (p: p.directories or [ ]) persist + ); + }; + + imports = [ + { den.hosts.x86_64-linux.igloo.users.tux = { }; } + { den.hosts.x86_64-linux.igloo.includes = [ den.aspects.libraries.producer ]; } + { den.hosts.x86_64-linux.igloo.includes = [ den.aspects.libraries.consumer ]; } + ]; + + expr = igloo.environment.etc."persisted".text or ""; + expected = "/var/lib/alpha"; + } + ); + + # CONTROL: the same two-module split on the host ASPECT. + test-aspect-includes-two-definitions = denTest ( + { den, igloo, ... }: + { + den.hosts.x86_64-linux.igloo.users.tux = { }; + den.quirks.persist.description = "Paths to persist"; + + den.aspects.libraries.producer.persist.directories = [ "/var/lib/alpha" ]; + den.aspects.libraries.consumer.nixos = + { persist, ... }: + { + environment.etc."persisted".text = lib.concatStringsSep "," ( + lib.concatMap (p: p.directories or [ ]) persist + ); + }; + + imports = [ + { den.aspects.igloo.includes = [ den.aspects.libraries.producer ]; } + { den.aspects.igloo.includes = [ den.aspects.libraries.consumer ]; } + ]; + + expr = igloo.environment.etc."persisted".text or ""; + expected = "/var/lib/alpha"; + } + ); + + # A flat host may declare `system` in one module and content in another: + # definitions are normalized individually, so the grouping key is gathered + # across all of them first. + test-flat-host-system-and-content-split = denTest ( + { den, igloo, ... }: + { + den.quirks.persist.description = "Paths to persist"; + + den.aspects.libraries.producer.persist.directories = [ "/var/lib/alpha" ]; + den.aspects.libraries.consumer.nixos = + { persist, ... }: + { + environment.etc."persisted".text = lib.concatStringsSep "," ( + lib.concatMap (p: p.directories or [ ]) persist + ); + }; + + imports = [ + { + den.hosts.igloo = { + system = "x86_64-linux"; + users.tux = { }; + }; + } + { den.hosts.igloo.includes = [ den.aspects.libraries.producer ]; } + { den.hosts.igloo.includes = [ den.aspects.libraries.consumer ]; } + ]; + + expr = igloo.environment.etc."persisted".text or ""; + expected = "/var/lib/alpha"; + } + ); + + }; +} diff --git a/templates/ci/modules/features/deadbugs/scope-id-unnameable-ctx.nix b/templates/ci/modules/features/deadbugs/scope-id-unnameable-ctx.nix new file mode 100644 index 00000000..4de5854e --- /dev/null +++ b/templates/ci/modules/features/deadbugs/scope-id-unnameable-ctx.nix @@ -0,0 +1,64 @@ +# theutz on #682: a quirk reached through `den.hosts..includes` arrived +# empty while the same aspect through `den.aspects..includes` delivered. +# +# Cause: `mkScopeId` built the scope IDENTITY from every ctx key, including ones +# whose value it cannot name. Those render as a bare type placeholder +# (`den=`, `inputs=`, `lib=`) — measured on his +# host, where the user scope pushed ctx keys `den|host|inputs|lib|system|user`. +# +# A value that can only render as its own type distinguishes nothing, so it adds +# no identity; all it does is split one logical scope across two ids depending on +# which module args happened to be bound on the path. Pipe emits are bucketed by +# scope id and a consumer reads its own bucket, so producer and consumer landed +# in different buckets and the collection came back empty. +{ denTest, ... }: +{ + flake.tests.deadbugs.scope-id-unnameable-ctx = { + + # An unnameable ctx value must not enter the scope identity. + test-unnameable-ctx-excluded-from-scope-id = denTest ( + { den, ... }: + { + den.hosts.x86_64-linux.igloo.users.tux = { }; + + expr = den.lib.aspects.fx.pipeline.mkScopeId { + host = { + name = "igloo"; + }; + user = { + name = "tux"; + }; + lib = { + genAttrs = "not a name"; + }; + inputs = { + nixpkgs = "not a name"; + }; + }; + expected = "host=igloo,user=tux"; + } + ); + + # CONTROL: nameable coordinates all survive, so the cell above measures the + # exclusion rather than a scope id that dropped everything. + test-nameable-ctx-kept-in-scope-id = denTest ( + { den, ... }: + { + den.hosts.x86_64-linux.igloo.users.tux = { }; + + expr = den.lib.aspects.fx.pipeline.mkScopeId { + host = { + name = "igloo"; + }; + user = { + __scopeName = "tux@igloo"; + name = "tux"; + }; + system = "x86_64-linux"; + }; + expected = "host=igloo,system=x86_64-linux,user=tux@igloo"; + } + ); + + }; +}