diff --git a/nix/lib/aspects/fx/handlers/emit-classes.nix b/nix/lib/aspects/fx/handlers/emit-classes.nix index 6fe51c784..d6dca19f4 100644 --- a/nix/lib/aspects/fx/handlers/emit-classes.nix +++ b/nix/lib/aspects/fx/handlers/emit-classes.nix @@ -9,6 +9,7 @@ let inherit (den.lib) fx; inherit (den.lib.aspects.fx) identity; inherit (den.lib.aspects.fx.contentUtil) unwrapContentValuesList; + inherit (den.lib.schemaUtil) schemaEntityKindsSet; inherit (den.lib.aspects.fx.aspect) ctxFromHandlers; @@ -20,6 +21,40 @@ let (resolvedArgs != [ ] && builtins.any (ak: ctx ? ${ak}) resolvedArgs) || (aspect.meta.contextDependent or false); + # Entity kinds a class-content module NAMES as function args AND that are + # present in the emitting scope's context. Such content is per-instance of + # those entities: key its identity by them so it neither over-collapses nor + # over-fans. + # - `nixos = { user, ... }:` → keyed {user=} → fans per user (the bug + # this fixes: a static user-scoped aspect's host-class content used to + # collapse N users → 1 at the shared host merge). + # - `nixos = { host, ... }:` → keyed {host=} → dedups across every scope + # the aspect is delivered from (host scope + the user scopes it reaches via + # home content), so host-targeted content stays one module. + # - `nixos = { persist, ... }:` / `_:` (no entity kind) → no suffix → singular + # (shared infra aspects like impermanence keep deduping; no double option + # declaration). + # Relies on the emit ctx being the authoritative scope context (see handler) — + # otherwise `ctx ? ` is unreliable and the keying is path-dependent. + namedEntityArgs = + ctx: module: + if builtins.isFunction module then + builtins.filter (a: (schemaEntityKindsSet ? ${a}) && (ctx ? ${a})) ( + builtins.attrNames (builtins.functionArgs module) + ) + else + [ ]; + + # Per-instance identity suffix for the named entity kinds, e.g. + # "/{host=cortex,user=sini}". Empty when the content names no entity kind. + # attrNames is already sorted, so the suffix is deterministic. + entityIdSuffix = + ctx: args: + if args == [ ] then + "" + else + "/{" + lib.concatStringsSep "," (map (a: "${a}=${ctx.${a}.name or "?"}") args) + "}"; + emitClassEntry = { class, @@ -50,16 +85,22 @@ let isMulti = builtins.length modules > 1; mkEntry = idx: module: + let + entityArgs = namedEntityArgs ctx module; + baseId = if isMulti then "${nodeIdentity}[${toString idx}]" else nodeIdentity; + in emitClassEntry { class = k; - identity = if isMulti then "${nodeIdentity}[${toString idx}]" else nodeIdentity; + identity = baseId + entityIdSuffix ctx entityArgs; inherit module ctx aspectPolicy globalPolicy ; - isContextDependent = contextDep; + # Content naming an entity kind is per-instance ⇒ keep the {…} suffix + # through identity computation (wrap-classes.nix finalIdentity). + isContextDependent = contextDep || entityArgs != [ ]; }; in fx.seq (lib.imap0 mkEntry modules); @@ -93,7 +134,27 @@ in classKeys = param.classKeys; pipeKeys = param.pipeKeys or [ ]; nodeIdentity = param.identity; - ctx = ctxFromHandlers (aspect.__scopeHandlers or { }); + # Authoritative emit context: the scope's own context from pipeline state + # (host + any descendant entity bindings), the SAME source bind.nix reads + # — not the per-aspect `__scopeHandlers`, which is only populated for + # parametric aspects / propagated includes and is ABSENT for a static + # aspect on a static include chain (→ empty ctx → path-dependent identity + # and the N→1 host-class collapse). The aspect's own handlers (fan-out + # child bindings) layer on top so they still win. Child scopes only; the + # root scope keeps the historic handler-only path (byte-stable). + currentScope = state.currentScope or null; + rootScopeId = state.rootScopeId or null; + isChildScope = currentScope != null && rootScopeId != null && currentScope != rootScopeId; + scopeCtx = + if isChildScope then + let + ctxs = (state.scopeContexts or (_: { })) null; + entityCls = ((state.scopeEntityClass or (_: { })) null).${currentScope} or null; + in + (ctxs.${currentScope} or { }) // lib.optionalAttrs (entityCls != null) { class = entityCls; } + else + { }; + ctx = scopeCtx // ctxFromHandlers (aspect.__scopeHandlers or { }); aspectPolicy = aspect.meta.collisionPolicy or null; globalPolicy = den.config.classModuleCollisionPolicy or "error"; contextDep = isContextDep aspect ctx; diff --git a/templates/ci/modules/features/user-scoped-host-class-fanout.nix b/templates/ci/modules/features/user-scoped-host-class-fanout.nix new file mode 100644 index 000000000..e895dadfa --- /dev/null +++ b/templates/ci/modules/features/user-scoped-host-class-fanout.nix @@ -0,0 +1,81 @@ +# Regression: a STATIC (constant-name) aspect included via den.schema.user.includes +# whose host-class (nixos) content NAMES `user` must fan PER-USER. Its content +# merges into the single shared host config, so without per-content keying it +# collapses N users → 1 (dedupByKey on a sid-free aspect identity) and all but +# one user's content is dropped before evaluation. The fix keys class content by +# the entity kinds its function NAMES (emit-classes.nix): naming `user` ⇒ per-user +# identity. Content that names NO entity kind stays singular (shared infra aspects +# like impermanence keep deduping) — exercised throughout the rest of the suite. +{ denTest, ... }: +{ + flake.tests.user-scoped-host-class-fanout = { + + # Two users on one host. A static user-include sets per-user SYSTEM config + # (environment.etc keyed by user.name). BOTH must materialize on the host. + test-static-user-include-nixos-fans-per-user = denTest ( + { + den, + igloo, + lib, + ... + }: + { + den.hosts.x86_64-linux.igloo.users = { + tux = { }; + pingu = { }; + }; + + # Static aspect: same identity for every user. Its nixos content names + # `user`, so it must key per-user rather than collapse to one identity. + den.aspects.per-user-probe.nixos = + { user, ... }: + { + environment.etc."probe-${user.name}".text = "user=${user.name}"; + }; + den.schema.user.includes = [ den.aspects.per-user-probe ]; + + expr = lib.sort (a: b: a < b) ( + builtins.filter (n: lib.hasPrefix "probe-" n) (builtins.attrNames igloo.environment.etc) + ); + # Pre-fix this was a single entry (one arbitrary user won the dedup). + expected = [ + "probe-pingu" + "probe-tux" + ]; + } + ); + + # The per-user content closes over the RIGHT user (no cross-user leakage): + # each probe's text reflects its own user, not the survivor's. + test-per-user-content-binds-own-user = denTest ( + { + den, + igloo, + ... + }: + { + den.hosts.x86_64-linux.igloo.users = { + tux = { }; + pingu = { }; + }; + + den.aspects.per-user-probe.nixos = + { user, ... }: + { + environment.etc."probe-${user.name}".text = "user=${user.name}"; + }; + den.schema.user.includes = [ den.aspects.per-user-probe ]; + + expr = { + tux = igloo.environment.etc."probe-tux".text; + pingu = igloo.environment.etc."probe-pingu".text; + }; + expected = { + tux = "user=tux"; + pingu = "user=pingu"; + }; + } + ); + + }; +}