Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
67 changes: 64 additions & 3 deletions nix/lib/aspects/fx/handlers/emit-classes.nix
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand All @@ -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=<u>} → 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=<h>} → 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 ? <kind>` 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,
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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;
Expand Down
81 changes: 81 additions & 0 deletions templates/ci/modules/features/user-scoped-host-class-fanout.nix
Original file line number Diff line number Diff line change
@@ -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";
};
}
);

};
}
Loading