Modular, context-aware and aspect-oriented dendritic Nix configurations. Discussions: https://oeiuwq.zulipchat.com/join/nqp26cd4kngon6mo3ncgnuap/ den.oeiuwq.com
configurations den dendritic nix aspect oriented
8
fork

Configure Feed

Select the types of activity you want to include in your feed.

feat: forward and output resolution respects meta.adapter (#405)

## Summary
- Entities automatically expose `.resolved` — the result of their
context pipeline — derived from the schema type system
- `forward.nix` defaults to `item.resolved` when `fromAspect` is absent,
enabling cross-context forwarding without manual wiring
- `mainModule` helper eliminated — entity types resolve directly via
`config.resolved`

## What changed
**options.nix** — `schemaEntryType` wraps `deferredModule` to
auto-inject `config.resolved` for any schema entry where
`den.ctx.${kind}` exists. Context args are derived from the entity's
`_module.args`, filtered to known context kinds. No per-entity
boilerplate — host, user, and home all get `.resolved` automatically.

**types.nix** — `mainModule` helper removed. Both host and home
`mainModule` options simplified to `den.lib.aspects.resolve config.class
config.resolved`.

**forward.nix** — `asp` falls back to `item.resolved or item` when
`fromAspect` is absent.

## How context adapters flow through forwards
`den.ctx.host.meta.adapter` is carried through `ctxApply` (via
`withIdentity` from #398) onto `.resolved`. When `resolve` processes it,
`adapters.filterIncludes` (from #397) picks up the adapter and applies
it transitively to the entire subtree — including nested aspects reached
through forwards.

authored by

Jason Bowman and committed by
GitHub
37297dd1 f9ef7e5d

+649 -14
+78
docs/src/content/docs/reference/aspects.mdx
··· 71 71 arguments (`{ host }`, `{ user }`, etc.) are **parametric** -- evaluated 72 72 per context during `ctxApply`. 73 73 74 + ### `meta.adapter` 75 + 76 + An aspect can declare a subtree adapter that controls how its includes 77 + are resolved. The adapter composes with the inherited adapter from 78 + parent aspects or context nodes. 79 + 80 + ```nix 81 + den.aspects.monitoring = { 82 + meta.adapter = inherited: 83 + den.lib.aspects.adapters.filter (a: a.name != "noisy-exporter") inherited; 84 + 85 + includes = [ 86 + den.aspects.prometheus 87 + den.aspects.noisy-exporter # filtered out 88 + den.aspects.grafana 89 + ]; 90 + }; 91 + ``` 92 + 93 + The adapter receives the inherited adapter and returns a new one. This 94 + enables composition — a parent's filter cannot be overridden by children. 95 + 96 + Adapters set on context nodes (`den.ctx.host.meta.adapter`) apply 97 + transitively to all aspects resolved within that context: 98 + 99 + ```nix 100 + # Exclude "debug-tools" from all host resolution 101 + den.ctx.host.meta.adapter = inherited: 102 + den.lib.aspects.adapters.filter (a: a.name != "debug-tools") inherited; 103 + ``` 104 + 105 + ### `meta.provider` 106 + 107 + Tracks the structural origin of an aspect as a path. Top-level aspects 108 + have `meta.provider = []`. An aspect provided by `foo` (via 109 + `foo.provides.bar` or `foo._.bar`) has `meta.provider = ["foo"]`. 110 + Deeply nested providers accumulate: `foo._.bar._.baz` has 111 + `meta.provider = ["foo" "bar"]`. 112 + 113 + Adapters can use this to distinguish aspects by origin: 114 + 115 + ```nix 116 + # Only include aspects provided by "monitoring" 117 + meta.adapter = inherited: 118 + den.lib.aspects.adapters.filter 119 + (a: lib.take 1 (a.meta.provider or []) == ["monitoring"]) 120 + inherited; 121 + ``` 122 + 74 123 ## `den.provides` (aliased as `den._`) 75 124 76 125 This is the place for Den built-in batteries, reusable aspects that serve 77 126 as basic utilities and examples. 78 127 79 128 See [Batteries Reference](/reference/batteries/). 129 + 130 + ## Cross-context forwarding 131 + 132 + Entities (hosts, users, homes) expose `.resolved` — the aspect produced 133 + by their context pipeline. Forwards can use this to pull configuration 134 + from one entity into another without manually wiring context calls. 135 + 136 + When `den._.forward` is called without `fromAspect`, it defaults to 137 + `item.resolved`, resolving the source entity through its own context 138 + pipeline: 139 + 140 + ```nix 141 + # Collect SSH host keys from all other hosts 142 + den.aspects.iceberg.includes = [ 143 + ({ host }: 144 + den._.forward { 145 + each = lib.filter (h: h != host) (lib.attrValues den.hosts.${host.system}); 146 + fromClass = _: "ssh-host-key"; 147 + intoClass = _: host.class; # "nixos" or "darwin" 148 + intoPath = _: [ ]; 149 + } 150 + ) 151 + ]; 152 + ``` 153 + 154 + Each host in `each` is resolved via its `.resolved` attribute, and the 155 + `ssh-host-key` class content is forwarded into the target's OS config. 156 + `host.class` is the OS class name (`"nixos"`, `"darwin"`), not the 157 + context type. 80 158 81 159 ## Class resolution 82 160
+16
docs/src/content/docs/reference/ctx.mdx
··· 51 51 map (user: { inherit host user; }) (lib.attrValues host.users); 52 52 ``` 53 53 54 + ### `meta.adapter` 55 + 56 + Type: `nullOr (functionTo raw)` 57 + 58 + An adapter that controls how aspects are resolved within this context. 59 + Set on a context node to filter or transform aspects transitively 60 + during resolution. 61 + 62 + ```nix 63 + den.ctx.host.meta.adapter = inherited: 64 + den.lib.aspects.adapters.filter (a: a.name != "unwanted") inherited; 65 + ``` 66 + 67 + The adapter composes with aspect-level `meta.adapter` values — context 68 + adapters are outermost, aspect adapters are innermost. 69 + 54 70 ### `includes` 55 71 56 72 Aspect includes attached to this context type. Used by batteries to inject
+28 -1
docs/src/content/docs/reference/lib.mdx
··· 146 146 147 147 ## `den.lib.aspects` 148 148 149 - Den aspects API. Provides aspect type definitions, `resolve`, `resolve.withAdapter` and basic `adapters` 149 + Den aspects API. Provides aspect type definitions, `resolve`, 150 + `resolve.withAdapter`, and composable `adapters`. 151 + 152 + ### `den.lib.aspects.resolve class aspect` 153 + 154 + Resolves an aspect for a given class (e.g., `"nixos"`), returning a 155 + module with `imports`. Uses `adapters.default` which honors 156 + `meta.adapter` on aspects. 157 + 158 + ### `den.lib.aspects.resolve.withAdapter adapter class aspect` 159 + 160 + Resolves with a custom adapter instead of the default. 161 + 162 + ### `den.lib.aspects.adapters` 163 + 164 + Composable adapters for `resolve.withAdapter`. Each adapter receives 165 + `{ aspect, class, classModule, recurse, aspect-chain, resolveChild }` 166 + and returns the resolved result. 167 + 168 + | Adapter | Description | 169 + |---------|-------------| 170 + | `module` | Collects class modules and recurses on includes | 171 + | `default` | `filterIncludes module` — the default pipeline | 172 + | `filter pred adapter` | Returns `{}` if `pred aspect` is false | 173 + | `map f adapter` | Transforms the adapter's result with `f` | 174 + | `mapAspect f adapter` | Transforms the aspect before passing to adapter | 175 + | `mapIncludes f adapter` | Transforms each include before recursion | 176 + | `filterIncludes adapter` | Honors `meta.adapter`, filters empty includes, tags survivors for propagation |
+21
docs/src/content/docs/reference/schema.mdx
··· 96 96 | `class` | `str` | auto | `"nixos"` or `"darwin"` based on system | 97 97 | `aspect` | `str` | `name` | Main aspect name for this host | 98 98 | `description` | `str` | auto | `class.hostName@system` | 99 + | `resolved` | `raw` | auto | Resolved aspect from context pipeline (see below) | 99 100 | `users` | `attrsOf userType` | `{}` | User accounts on this host | 100 101 | `instantiate` | `raw` | auto | OS builder function | 101 102 | `intoAttr` | `listOf str` | auto | Flake output path | ··· 130 131 | `userName` | `str` | `name` | System account name | 131 132 | `classes` | `listOf str` | `[ "homeManager" ]` | Home management classes | 132 133 | `aspect` | `str` | `name` | Main aspect name | 134 + | `resolved` | `raw` | auto | Resolved aspect from context pipeline (see below) | 133 135 | `*` | `den.schema.user` options | | Options from base module | 134 136 | `*` | | | free-form attributes | 135 137 ··· 157 159 | `description` | `str` | auto | `home.userName@system` | 158 160 | `pkgs` | `raw` | `inputs.nixpkgs.legacyPackages.$sys` | Nixpkgs instance | 159 161 | `instantiate` | `raw` | `inputs.home-manager.lib.homeManagerConfiguration` | Builder | 162 + | `resolved` | `raw` | auto | Resolved aspect from context pipeline (see below) | 160 163 | `intoAttr` | `listOf str` | `[ "homeConfigurations" name ]` | Output path | 161 164 | `*` | `den.schema.home` options | | Options from base module | 162 165 | `*` | | | free-form attributes | 163 166 167 + ## Entity `resolved` 168 + 169 + Every entity (host, user, home) has a `resolved` attribute — the aspect 170 + produced by running the entity through its context pipeline 171 + (`den.ctx.${kind}`). This is auto-derived and used internally by 172 + `mainModule` to produce the entity's final configuration. 173 + 174 + To control which aspects are included during resolution, set 175 + `meta.adapter` on a context node: 176 + 177 + ```nix 178 + den.ctx.host.meta.adapter = inherited: 179 + den.lib.aspects.adapters.filter (a: a.name != "unwanted") inherited; 180 + ``` 181 + 182 + This filters transitively — nested aspects reached through includes 183 + are also subject to the adapter. 184 +
+40 -1
modules/options.nix
··· 14 14 config 15 15 ; 16 16 }; 17 + 18 + # Schema entries auto-inject config.resolved when den.ctx.${kind} exists. 19 + # Context args are derived from the entity's _module.args, filtered to 20 + # known context kinds so framework args don't leak through. 21 + schemaEntryType = 22 + let 23 + base = lib.types.deferredModule; 24 + in 25 + base 26 + // { 27 + merge = 28 + loc: defs: 29 + let 30 + kind = lib.last loc; 31 + merged = base.merge loc defs; 32 + resolvedCtx = 33 + { config, ... }: 34 + { 35 + options.resolved = lib.mkOption { 36 + description = "The resolved aspect for this ${kind}, produced by den.ctx.${kind}."; 37 + readOnly = true; 38 + type = lib.types.raw; 39 + default = den.ctx.${kind} ( 40 + lib.filterAttrs (n: _: den.ctx ? ${n}) config._module.args // { ${kind} = config; } 41 + ); 42 + }; 43 + }; 44 + in 45 + if den.ctx ? ${kind} then 46 + { 47 + imports = [ 48 + merged 49 + resolvedCtx 50 + ]; 51 + } 52 + else 53 + merged; 54 + }; 55 + 17 56 schemaOption = lib.mkOption { 18 57 description = "freeform deferred modules per entity kind"; 19 58 defaultText = lib.literalExpression "{ }"; 20 59 default = { }; 21 60 type = lib.types.submodule { 22 - freeformType = lib.types.lazyAttrsOf lib.types.deferredModule; 61 + freeformType = lib.types.lazyAttrsOf schemaEntryType; 23 62 }; 24 63 }; 25 64 in
+2 -1
nix/lib/forward.nix
··· 18 18 intoPathFn = if lib.isFunction intoPath then intoPath else _: intoPath; 19 19 staticIntoPath = if lib.isFunction intoPath then [ ] else intoPath; 20 20 21 - asp = fwd.fromAspect item; 21 + # Entities have .resolved (their context pipeline result); raw aspects don't. 22 + asp = if fwd ? fromAspect then fwd.fromAspect item else item.resolved or item; 22 23 sourceModule = mapModule (den.lib.aspects.resolve fromClass asp); 23 24 24 25 forward =
+4 -11
nix/lib/types.nix
··· 103 103 visible = false; 104 104 readOnly = true; 105 105 type = lib.types.deferredModule; 106 - defaultText = ''den.lib.aspects.resolve "nixos" (den.ctx.host { inherit host; })''; 107 - default = mainModule config den.ctx.host "host"; 106 + defaultText = "den.lib.aspects.resolve config.class config.resolved"; 107 + default = den.lib.aspects.resolve config.class config.resolved; 108 108 }; 109 109 }; 110 110 } ··· 258 258 visible = false; 259 259 readOnly = true; 260 260 type = lib.types.deferredModule; 261 - defaultText = lib.literalExpression "mainModule"; 262 - default = mainModule config den.ctx.home "home"; 261 + defaultText = "den.lib.aspects.resolve config.class config.resolved"; 262 + default = den.lib.aspects.resolve config.class config.resolved; 263 263 }; 264 264 }; 265 265 } 266 266 ); 267 - 268 - mainModule = 269 - from: intent: name: 270 - let 271 - asp = intent { ${name} = from; }; 272 - in 273 - den.lib.aspects.resolve (from.class) asp; 274 267 in 275 268 { 276 269 inherit hostsOption homesOption;
+180
templates/ci/modules/features/adapter-propagation.nix
··· 1 + { denTest, lib, ... }: 2 + { 3 + flake.tests.adapter-propagation = 4 + let 5 + traceName = 6 + { aspect, recurse, ... }: 7 + { 8 + trace = [ (aspect.name or null) ] ++ map (i: (recurse i).trace or [ ]) (aspect.includes or [ ]); 9 + }; 10 + in 11 + { 12 + 13 + # --- Aspect-level meta.adapter --- 14 + 15 + test-resolve-honors-meta-adapter = denTest ( 16 + { den, ... }: 17 + { 18 + den.aspects.foo.includes = [ den.aspects.bar ]; 19 + den.aspects.foo.meta.adapter = 20 + inherited: den.lib.aspects.adapters.filter (a: (a.name or null) != "bar") inherited; 21 + den.aspects.bar.nixos = { }; 22 + 23 + expr = (den.lib.aspects.resolve "nixos" den.aspects.foo) ? imports; 24 + expected = true; 25 + } 26 + ); 27 + 28 + test-tags-includes-with-adapter = denTest ( 29 + { den, ... }: 30 + { 31 + den.aspects.parent.includes = [ den.aspects.child ]; 32 + den.aspects.parent.meta.adapter = 33 + inherited: den.lib.aspects.adapters.filter (a: (a.name or null) != "baz") inherited; 34 + den.aspects.child.includes = [ den.aspects.baz ]; 35 + den.aspects.baz.nixos = { }; 36 + 37 + expr = 38 + den.lib.aspects.resolve.withAdapter (den.lib.aspects.adapters.filterIncludes traceName) "nixos" 39 + den.aspects.parent; 40 + expected.trace = [ 41 + "parent" 42 + [ "child" ] 43 + ]; 44 + } 45 + ); 46 + 47 + test-child-inherits-parent-adapter = denTest ( 48 + { den, ... }: 49 + { 50 + den.aspects.parent.includes = [ den.aspects.child ]; 51 + den.aspects.parent.meta.adapter = 52 + inherited: den.lib.aspects.adapters.filter (a: (a.name or null) != "excluded") inherited; 53 + den.aspects.child.includes = [ 54 + den.aspects.kept 55 + den.aspects.excluded 56 + ]; 57 + den.aspects.kept.nixos = { }; 58 + den.aspects.excluded.nixos = { }; 59 + 60 + expr = 61 + den.lib.aspects.resolve.withAdapter (den.lib.aspects.adapters.filterIncludes traceName) "nixos" 62 + den.aspects.parent; 63 + expected.trace = [ 64 + "parent" 65 + [ 66 + "child" 67 + [ "kept" ] 68 + ] 69 + ]; 70 + } 71 + ); 72 + 73 + test-deep-chain-a-excludes-c-through-b = denTest ( 74 + { den, ... }: 75 + { 76 + den.aspects.a.includes = [ den.aspects.b ]; 77 + den.aspects.a.meta.adapter = 78 + inherited: den.lib.aspects.adapters.filter (a: (a.name or null) != "c") inherited; 79 + den.aspects.b.includes = [ 80 + den.aspects.c 81 + den.aspects.d 82 + ]; 83 + den.aspects.c.nixos = { }; 84 + den.aspects.d.nixos = { }; 85 + 86 + expr = 87 + den.lib.aspects.resolve.withAdapter (den.lib.aspects.adapters.filterIncludes traceName) "nixos" 88 + den.aspects.a; 89 + expected.trace = [ 90 + "a" 91 + [ 92 + "b" 93 + [ "d" ] 94 + ] 95 + ]; 96 + } 97 + ); 98 + 99 + test-diamond-a-excludes-d-through-both-paths = denTest ( 100 + { den, ... }: 101 + { 102 + den.aspects.a.includes = [ 103 + den.aspects.b 104 + den.aspects.c 105 + ]; 106 + den.aspects.a.meta.adapter = 107 + inherited: den.lib.aspects.adapters.filter (a: (a.name or null) != "d") inherited; 108 + den.aspects.b.includes = [ den.aspects.d ]; 109 + den.aspects.c.includes = [ den.aspects.d ]; 110 + den.aspects.d.nixos = { }; 111 + 112 + expr = 113 + den.lib.aspects.resolve.withAdapter (den.lib.aspects.adapters.filterIncludes traceName) "nixos" 114 + den.aspects.a; 115 + expected.trace = [ 116 + "a" 117 + [ "b" ] 118 + [ "c" ] 119 + ]; 120 + } 121 + ); 122 + 123 + # --- Context-level meta.adapter --- 124 + 125 + test-ctx-carries-meta-adapter = denTest ( 126 + { den, ... }: 127 + { 128 + den.hosts.x86_64-linux.igloo = { }; 129 + 130 + den.ctx.host.meta.adapter = 131 + inherited: den.lib.aspects.adapters.filter (a: a.name != "foo") inherited; 132 + 133 + expr = (den.ctx.host { host = den.hosts.x86_64-linux.igloo; }).meta.adapter != null; 134 + expected = true; 135 + } 136 + ); 137 + 138 + test-ctx-meta-adapter-null-when-unset = denTest ( 139 + { den, ... }: 140 + { 141 + den.hosts.x86_64-linux.igloo = { }; 142 + 143 + expr = (den.ctx.host { host = den.hosts.x86_64-linux.igloo; }).meta.adapter; 144 + expected = null; 145 + } 146 + ); 147 + 148 + # --- Cross-stage: host adapter filters at user level --- 149 + 150 + # Host context adapter transitively filters nested aspects. 151 + # blocked-deep is two levels below igloo but still excluded. 152 + test-ctx-host-adapter-filters-transitively = denTest ( 153 + { den, igloo, ... }: 154 + { 155 + den.hosts.x86_64-linux.igloo.users.tux = { }; 156 + 157 + den.ctx.host.meta.adapter = 158 + inherited: den.lib.aspects.adapters.filter (a: (a.name or null) != "blocked") inherited; 159 + 160 + den.aspects.igloo.includes = [ den.aspects.parent ]; 161 + den.aspects.parent.includes = [ 162 + den.aspects.allowed 163 + den.aspects.blocked 164 + ]; 165 + den.aspects.allowed.nixos.environment.sessionVariables.ALLOWED = "yes"; 166 + den.aspects.blocked.nixos.environment.sessionVariables.BLOCKED = "yes"; 167 + 168 + expr = { 169 + hasAllowed = igloo.environment.sessionVariables ? ALLOWED; 170 + hasBlocked = igloo.environment.sessionVariables ? BLOCKED; 171 + }; 172 + expected = { 173 + hasAllowed = true; 174 + hasBlocked = false; 175 + }; 176 + } 177 + ); 178 + 179 + }; 180 + }
+280
templates/ci/modules/features/cross-context-forward.nix
··· 1 + { denTest, lib, ... }: 2 + { 3 + flake.tests.cross-context-forward = { 4 + 5 + test-resolve-other-host-context = denTest ( 6 + { den, ... }: 7 + { 8 + den.hosts.x86_64-linux.igloo = { }; 9 + den.hosts.x86_64-linux.iceberg = { }; 10 + 11 + den.aspects.igloo.nixos.environment.sessionVariables.FROM_IGLOO = "yes"; 12 + 13 + expr = 14 + let 15 + iglooCtx = den.ctx.host { host = den.hosts.x86_64-linux.igloo; }; 16 + resolved = den.lib.aspects.resolve "nixos" iglooCtx; 17 + in 18 + resolved ? imports; 19 + expected = true; 20 + } 21 + ); 22 + 23 + test-entities-have-resolved = denTest ( 24 + { den, ... }: 25 + { 26 + den.hosts.x86_64-linux.igloo.users.tux = { }; 27 + den.homes.x86_64-linux.cabin = { }; 28 + 29 + expr = { 30 + host = den.hosts.x86_64-linux.igloo ? resolved; 31 + user = den.hosts.x86_64-linux.igloo.users.tux ? resolved; 32 + home = den.homes.x86_64-linux.cabin ? resolved; 33 + }; 34 + expected = { 35 + host = true; 36 + user = true; 37 + home = true; 38 + }; 39 + } 40 + ); 41 + 42 + test-user-resolved-produces-aspect = denTest ( 43 + { den, ... }: 44 + { 45 + den.hosts.x86_64-linux.igloo.users.tux = { }; 46 + den.aspects.tux = 47 + { host, user }: 48 + { 49 + nixos.environment.sessionVariables.USER_HOST = "${user.userName}@${host.hostName}"; 50 + }; 51 + 52 + expr = 53 + let 54 + user = den.hosts.x86_64-linux.igloo.users.tux; 55 + resolved = den.lib.aspects.resolve "nixos" user.resolved; 56 + in 57 + resolved ? imports; 58 + expected = true; 59 + } 60 + ); 61 + 62 + test-cross-context-forward-with-ctx = denTest ( 63 + { den, iceberg, ... }: 64 + { 65 + den.hosts.x86_64-linux.igloo = { }; 66 + den.hosts.x86_64-linux.iceberg = { }; 67 + 68 + den.aspects.igloo.ssh-host-key.environment.sessionVariables.FROM_IGLOO = "yes"; 69 + 70 + den.aspects.iceberg.includes = [ 71 + ( 72 + { host }: 73 + den._.forward { 74 + each = lib.filter (h: h != host) (lib.attrValues den.hosts.${host.system}); 75 + fromClass = _: "ssh-host-key"; 76 + intoClass = _: host.class; 77 + intoPath = _: [ ]; 78 + } 79 + ) 80 + ]; 81 + 82 + expr = iceberg.environment.sessionVariables ? FROM_IGLOO; 83 + expected = true; 84 + } 85 + ); 86 + 87 + test-forward-each-filter-excludes-self = denTest ( 88 + { den, iceberg, ... }: 89 + { 90 + den.hosts.x86_64-linux.igloo = { }; 91 + den.hosts.x86_64-linux.iceberg = { }; 92 + 93 + den.aspects.igloo.test-class.environment.sessionVariables.FROM_IGLOO = "yes"; 94 + den.aspects.iceberg.test-class.environment.sessionVariables.FROM_ICEBERG = "yes"; 95 + 96 + den.aspects.iceberg.includes = [ 97 + ( 98 + { host }: 99 + den._.forward { 100 + each = lib.filter (h: h != host) (lib.attrValues den.hosts.${host.system}); 101 + fromClass = _: "test-class"; 102 + intoClass = _: host.class; 103 + intoPath = _: [ ]; 104 + } 105 + ) 106 + ]; 107 + 108 + expr = { 109 + hasIgloo = iceberg.environment.sessionVariables ? FROM_IGLOO; 110 + hasIceberg = iceberg.environment.sessionVariables ? FROM_ICEBERG; 111 + }; 112 + expected = { 113 + hasIgloo = true; 114 + hasIceberg = false; 115 + }; 116 + } 117 + ); 118 + 119 + test-cross-context-adapter-data-collection = denTest ( 120 + { den, iceberg, ... }: 121 + { 122 + den.hosts.x86_64-linux.igloo = { }; 123 + den.hosts.x86_64-linux.iceberg = { }; 124 + 125 + den.aspects.igloo.meta.sshKey = "ssh-ed25519 AAAA igloo"; 126 + 127 + den.aspects.iceberg.includes = [ 128 + ( 129 + { host }: 130 + let 131 + otherHosts = lib.filter (h: h != host) (lib.attrValues den.hosts.${host.system}); 132 + collectKeys = lib.concatMap ( 133 + srcHost: 134 + let 135 + traceMeta = 136 + { aspect, recurse, ... }: 137 + { 138 + keys = 139 + lib.optional (aspect.meta.sshKey or null != null) { 140 + host = aspect.name or "unknown"; 141 + key = aspect.meta.sshKey; 142 + } 143 + ++ lib.concatMap (i: (recurse i).keys or [ ]) (aspect.includes or [ ]); 144 + }; 145 + result = den.lib.aspects.resolve.withAdapter traceMeta srcHost.class srcHost.resolved; 146 + in 147 + result.keys or [ ] 148 + ) otherHosts; 149 + in 150 + { 151 + nixos.environment.sessionVariables.COLLECTED_KEYS = lib.concatStringsSep "," ( 152 + map (k: k.key) collectKeys 153 + ); 154 + } 155 + ) 156 + ]; 157 + 158 + expr = iceberg.environment.sessionVariables.COLLECTED_KEYS; 159 + expected = "ssh-ed25519 AAAA igloo"; 160 + } 161 + ); 162 + 163 + test-host-hm-aspects-forward-to-primary-user = denTest ( 164 + { den, igloo, ... }: 165 + { 166 + den.schema.user.classes = [ "homeManager" ]; 167 + den.hosts.x86_64-linux.igloo.users.tux = { }; 168 + 169 + # Host-level aspect defines homeManager config 170 + # tux does NOT explicitly include this 171 + den.aspects.shared-hm = { 172 + homeManager.programs.git.enable = true; 173 + }; 174 + 175 + # Forward: collect all homeManager content from host resolution, 176 + # inject into primary user's home-manager path 177 + den.aspects.igloo.includes = [ 178 + den.aspects.shared-hm 179 + ( 180 + { host }: 181 + let 182 + primaryUser = 183 + lib.findFirst (u: true) # first user as "primary" 184 + null 185 + (lib.attrValues host.users); 186 + in 187 + lib.optionalAttrs (primaryUser != null) ( 188 + den._.forward { 189 + each = lib.singleton host; 190 + fromAspect = h: den.lib.parametric.fixedTo { host = h; } den.aspects.${h.aspect}; 191 + fromClass = _: "homeManager"; 192 + intoClass = _: host.class; 193 + intoPath = _: [ 194 + "home-manager" 195 + "users" 196 + primaryUser.userName 197 + ]; 198 + } 199 + ) 200 + ) 201 + ]; 202 + 203 + expr = igloo.home-manager.users.tux.programs.git.enable; 204 + expected = true; 205 + } 206 + ); 207 + 208 + test-forward-hm-from-other-host-to-local-user = denTest ( 209 + { den, iceberg, ... }: 210 + { 211 + den.schema.user.classes = [ "homeManager" ]; 212 + den.hosts.x86_64-linux.igloo = { }; 213 + den.hosts.x86_64-linux.iceberg.users.pingu = { }; 214 + 215 + # igloo has homeManager aspects — pingu on iceberg doesn't include them 216 + den.aspects.igloo.includes = [ den.aspects.igloo-hm-stuff ]; 217 + den.aspects.igloo-hm-stuff.homeManager.programs.firefox.enable = true; 218 + 219 + # iceberg forwards igloo's homeManager content into pingu 220 + den.aspects.iceberg.includes = [ 221 + ( 222 + { host }: 223 + let 224 + igloo = den.hosts.x86_64-linux.igloo; 225 + user = lib.head (lib.attrValues host.users); 226 + in 227 + den._.forward { 228 + each = lib.singleton igloo; 229 + fromClass = _: "homeManager"; 230 + intoClass = _: host.class; 231 + intoPath = _: [ 232 + "home-manager" 233 + "users" 234 + user.userName 235 + ]; 236 + } 237 + ) 238 + ]; 239 + 240 + expr = iceberg.home-manager.users.pingu.programs.firefox.enable; 241 + expected = true; 242 + } 243 + ); 244 + 245 + test-forward-carries-source-context-data = denTest ( 246 + { den, iceberg, ... }: 247 + { 248 + den.schema.user.classes = [ "homeManager" ]; 249 + den.hosts.x86_64-linux.igloo.users.tux = { }; 250 + den.hosts.x86_64-linux.iceberg = { }; 251 + 252 + # igloo has an aspect with a custom class carrying host-specific data 253 + den.aspects.igloo.includes = [ den.aspects.igloo-identity ]; 254 + den.aspects.igloo-identity = 255 + { host }: 256 + { 257 + host-identity.environment.sessionVariables.SOURCE_HOST = host.hostName; 258 + }; 259 + 260 + # iceberg pulls igloo's host-identity class content into its own nixos 261 + den.aspects.iceberg.includes = [ 262 + ( 263 + { host }: 264 + den._.forward { 265 + each = lib.singleton den.hosts.x86_64-linux.igloo; 266 + fromClass = _: "host-identity"; 267 + intoClass = _: host.class; 268 + intoPath = _: [ ]; 269 + } 270 + ) 271 + ]; 272 + 273 + # Verify the forwarded value actually comes from igloo's context 274 + expr = iceberg.environment.sessionVariables.SOURCE_HOST; 275 + expected = "igloo"; 276 + } 277 + ); 278 + 279 + }; 280 + }