From f819933c8d9e1ff4fb85d52aca075b36bed6ad45 Mon Sep 17 00:00:00 2001 From: _cry64 Date: Sun, 15 Mar 2026 01:10:36 +1000 Subject: [PATCH] node inheritance + group parsing --- nix/snow/default.nix | 5 +- nix/snow/flake/modules/outputs.nix | 1 - nix/snow/flake/nodes/default.nix | 35 ++--- nix/snow/flake/nodes/groups.nix | 73 ++++++++++ .../flake/nodes/{submodule.nix => node.nix} | 103 +++++++++++++- .../flake/nodes/{shared.nix => nodes.nix} | 51 +++++-- nix/snow/flake/outputs/checks.nix | 15 +- nix/snow/flake/outputs/deploy.nix | 133 +++++++++++------- .../flake/outputs/nixosConfigurations.nix | 126 ++++++++++------- nix/snow/lib/default.nix | 27 ++++ nix/snow/{flake/lib.nix => lib/nixpkgs.nix} | 10 +- nix/snow/lib/nodes.nix | 96 ------------- 12 files changed, 422 insertions(+), 253 deletions(-) create mode 100644 nix/snow/flake/nodes/groups.nix rename nix/snow/flake/nodes/{submodule.nix => node.nix} (62%) rename nix/snow/flake/nodes/{shared.nix => nodes.nix} (81%) create mode 100644 nix/snow/lib/default.nix rename nix/snow/{flake/lib.nix => lib/nixpkgs.nix} (83%) delete mode 100644 nix/snow/lib/nodes.nix diff --git a/nix/snow/default.nix b/nix/snow/default.nix index 15c67da..c90685e 100644 --- a/nix/snow/default.nix +++ b/nix/snow/default.nix @@ -18,15 +18,14 @@ } @ args: let inherit (nt) findImport; in - mix.newMixture args (mixture: let - inherit (mixture) mapNodes; - in { + mix.newMixture args (mixture: { includes = { private = [ ./lib/nodes.nix ]; public = [ ./flake + ./lib.nix ]; }; diff --git a/nix/snow/flake/modules/outputs.nix b/nix/snow/flake/modules/outputs.nix index 3539f5c..551186a 100644 --- a/nix/snow/flake/modules/outputs.nix +++ b/nix/snow/flake/modules/outputs.nix @@ -21,7 +21,6 @@ No option has been declared for this flake output attribute, so its definitions can't be merged automatically. Possible solutions: - Load a module that defines this flake output attribute - Many modules are listed at https://flake.parts - Declare an option for this flake output attribute - Make sure the output attribute is spelled correctly - Define the value only once, with a single definition in a single module diff --git a/nix/snow/flake/nodes/default.nix b/nix/snow/flake/nodes/default.nix index d3bc9b7..d7b6a82 100644 --- a/nix/snow/flake/nodes/default.nix +++ b/nix/snow/flake/nodes/default.nix @@ -12,6 +12,7 @@ # See the License for the specific language governing permissions and # limitations under the License. { + _snow, lib, specialArgs, ... @@ -25,38 +26,20 @@ in mkOption { description = '' - Cerulean node declarations. + Snowflake node declarations. ''; type = types.submoduleWith { inherit specialArgs; modules = [ - { - imports = [./shared.nix]; - - options = { - groups = mkOption { - type = types.attrs; - default = {}; - example = lib.literalExpression "{ servers = { staging = {}; production = {}; }; }"; - description = '' - Hierarchical groups that nodes can be a member of. - ''; - }; - - nodes = mkOption { - type = types.attrsOf (types.submoduleWith { - inherit specialArgs; - modules = [(import ./submodule.nix)]; - }); - # example = { ... }; # TODO - description = '' - Node (host systems) declarations. - ''; - }; - }; - } + ./nodes.nix ]; }; }; + + config = { + nodes = { + base = _snow.inputs.nixpkgs; + }; + }; } diff --git a/nix/snow/flake/nodes/groups.nix b/nix/snow/flake/nodes/groups.nix new file mode 100644 index 0000000..b22cac0 --- /dev/null +++ b/nix/snow/flake/nodes/groups.nix @@ -0,0 +1,73 @@ +# # Copyright 2025-2026 _cry64 (Emile Clark-Boman) +# # +# # Licensed under the Apache License, Version 2.0 (the "License"); +# # you may not use this file except in compliance with the License. +# # You may obtain a copy of the License at +# # +# # http://www.apache.org/licenses/LICENSE-2.0 +# # +# # Unless required by applicable law or agreed to in writing, software +# # distributed under the License is distributed on an "AS IS" BASIS, +# # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# # See the License for the specific language governing permissions and +# # limitations under the License. +{nt, ...}: let + inherit + (builtins) + concatLists + elem + filter + isAttrs + mapAttrs + pathExists + typeOf + ; + + rootGroupName = "all"; +in { + parseGroupsDecl = groups: let + validGroup = g: + isAttrs g + || throw '' + Snow node groups must be provided as attribute sets, got "${typeOf g}" instead! + Ensure all the group definitions are attribute sets under your call to `snow.flake`. + NOTE: Groups can be accessed via `self.groups.PATH.TO.YOUR.GROUP` + ''; + delegate = parent: gName: g: let + result = + (g + // { + _name = gName; + _parent = parent; + }) + |> mapAttrs (name: value: + if elem name ["_name" "_parent"] + # ignore metadata fields + then value + else assert validGroup value; (delegate result name value)); + in + result; + in + assert validGroup groups; + delegate null rootGroupName groups; + + getGroupModules = root: groups: + # ensure root group is always added + groups + # add all inherited groups via _parent + |> map (let + delegate = g: + if g._parent == null + then [g] + else [g] ++ delegate (g._parent); + in + delegate) + # flatten recursion result + |> concatLists + # find import location + |> map (group: nt.findImport /${root}/groups/${group._name}) + # filter by uniqueness + |> nt.prim.unique + # ignore missing groups + |> filter pathExists; +} diff --git a/nix/snow/flake/nodes/submodule.nix b/nix/snow/flake/nodes/node.nix similarity index 62% rename from nix/snow/flake/nodes/submodule.nix rename to nix/snow/flake/nodes/node.nix index 6b4ae05..a368d24 100644 --- a/nix/snow/flake/nodes/submodule.nix +++ b/nix/snow/flake/nodes/node.nix @@ -14,16 +14,18 @@ { lib, systems, + config, + nodesConfig, ... }: { - imports = [./shared.nix]; - options = let inherit (lib) mkOption types ; + + flakeRef = types.either types.str types.path; in { enabled = lib.mkOption { type = types.bool; @@ -43,6 +45,65 @@ ''; }; + base = lib.mkOption { + # In newer Nix versions, particularly with lazy trees, outPath of + # flakes becomes a Nix-language path object. We deliberately allow this + # to gracefully come through the interface in discussion with @roberth. + # + # See: https://github.com/NixOS/nixpkgs/pull/278522#discussion_r1460292639 + type = types.nullOr flakeRef; + + default = nodesConfig.base; + defaultText = "nodes.base"; + + example = lib.literalExpression "inputs.nixpkgs"; + + description = '' + The path to the nixpkgs source used to build a system. A `base` package set + is required to be set, and can be specified via either: + 1. `options.nodes.base` (default `base` used for all systems) + 2. `options.nodes.nodes..base` (takes prescedence over `options.nodes.base`) + + This can also be optionally set if the NixOS system is not built with a flake but still uses + pinned sources: set this to the store path for the nixpkgs sources used to build the system, + as may be obtained by `fetchTarball`, for example. + + Note: the name of the store path must be "source" due to + . + ''; + }; + + homeManager = mkOption { + type = types.nullOr flakeRef; + default = nodesConfig.homeManager; + defaultText = "nodes.homeManager"; + example = lib.literalExpression "inputs.home-manager"; + description = '' + The path to the home-manager source. A `homeManager` flake reference + is required to be set for `homes/` to be evaluated, and can be specified via either: + 1. `options.nodes.homeManager` (default `homManager` used for all systems) + 2. `options.nodes.nodes..homeManager` (takes prescedence over `options.nodes.homeManager`) + ''; + }; + + modules = mkOption { + type = types.listOf types.raw; + default = []; + example = lib.literalExpression "[ { environment.systemPackages = [ pkgs.git ]; } ]"; + description = '' + Shared modules to import; equivalent to the NixOS module system's `extraModules`. + ''; + }; + + args = mkOption { + type = types.attrs; + default = {}; + example = lib.literalExpression "{ inherit inputs; }"; + description = '' + Shared args to provided for each node; equivalent to the NixOS module system's `specialArgs`. + ''; + }; + groups = mkOption { # TODO: write a custom group type that validates better than types.attrs lol type = types.functionTo (types.listOf types.attrs); @@ -51,6 +112,9 @@ description = '' A function from the `groups` hierarchy to a list of groups this node inherits from. ''; + + apply = groupsFn: + groupsFn nodesConfig.groups; }; deploy = { @@ -91,7 +155,7 @@ example = false; description = '' Whether to enable interactive sudo (password based sudo). - NOT RECOMMENDED. Use one of Cerulean's recommended auth methods instead. + NOT RECOMMENDED. Use one of Snowflake's recommended auth methods instead. ''; }; @@ -164,7 +228,7 @@ user = mkOption { type = types.str; - default = "cerubld"; + default = "snowbld"; example = "custom-user"; description = '' The user to connect to over ssh during deployment. @@ -183,7 +247,7 @@ publicKeys = mkOption { type = types.listOf types.str; default = []; - example = ["ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIIeyZuUUmyUYrYaEJwEMvcXqZFYm1NaZab8klOyK6Imr me@puter"]; + example = ["ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIIeyZuUUmyUYrYaEJwEMvcXqZFYm1NaZab8klOyK6Imr me@myputer"]; description = '' SSH public keys that will be authorized to the deployment user. This key is intended solely for deployment, allowing for fine-grained permission control. @@ -201,4 +265,33 @@ }; }; }; + + config = let + throwGotNull = name: + throw '' + [snow] `nodes..${name}` must be set for all nodes! (got: ) + ''; + givenSystem = + (config.system != null) + || throwGotNull "system"; + + givenBase = + (config.base != null) + || throwGotNull "base"; + + givenHomeManager = + (config.homeManager != null) + || throwGotNull "homeManager"; + + givenDeployHost = + (config.deploy.ssh.host != null) + || throwGotNull "deploy.ssh.host"; + in + assert givenSystem + && givenBase + && givenHomeManager + && givenDeployHost; { + # extend these from the nodes configuration + inherit (nodesConfig) modules args; + }; } diff --git a/nix/snow/flake/nodes/shared.nix b/nix/snow/flake/nodes/nodes.nix similarity index 81% rename from nix/snow/flake/nodes/shared.nix rename to nix/snow/flake/nodes/nodes.nix index c840d22..58a9e1a 100644 --- a/nix/snow/flake/nodes/shared.nix +++ b/nix/snow/flake/nodes/nodes.nix @@ -11,7 +11,13 @@ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # See the License for the specific language governing permissions and # limitations under the License. -{lib, ...}: let +{ + _snow, + lib, + config, + specialArgs, + ... +}: let inherit (lib) mkOption @@ -19,6 +25,8 @@ ; flakeRef = types.either types.str types.path; + + groupLibs = import ./groups.nix {inherit (_snow.inputs) nt;}; in { options = { base = lib.mkOption { @@ -49,6 +57,18 @@ in { ''; }; + homeManager = mkOption { + type = types.nullOr flakeRef; + default = null; + example = lib.literalExpression "inputs.home-manager"; + description = '' + The path to the home-manager source. A `homeManager` flake reference + is required to be set for `homes/` to be evaluated, and can be specified via either: + 1. `options.nodes.homeManager` (default `homManager` used for all systems) + 2. `options.nodes.nodes..homeManager` (takes prescedence over `options.nodes.homeManager`) + ''; + }; + modules = mkOption { type = types.listOf types.raw; default = []; @@ -67,15 +87,28 @@ in { ''; }; - homeManager = mkOption { - type = types.nullOr flakeRef; - default = null; - example = lib.literalExpression "inputs.home-manager"; + groups = mkOption { + type = types.attrs; + default = {}; + example = lib.literalExpression "{ servers = { staging = {}; production = {}; }; }"; description = '' - The path to the home-manager source. A `homeManager` flake reference - is required to be set for `homes/` to be evaluated, and can be specified via either: - 1. `options.nodes.homeManager` (default `homManager` used for all systems) - 2. `options.nodes.nodes..homeManager` (takes prescedence over `options.nodes.homeManager`) + Hierarchical groups that nodes can be a member of. + ''; + + apply = groupLibs.parseGroupsDecl; + }; + + nodes = mkOption { + type = types.attrsOf (types.submoduleWith { + specialArgs = + specialArgs + // { + nodeConfig = config; + }; + modules = [./node.nix]; + }); + description = '' + Node (host systems) declarations. ''; }; }; diff --git a/nix/snow/flake/outputs/checks.nix b/nix/snow/flake/outputs/checks.nix index 21fd677..a25ba32 100644 --- a/nix/snow/flake/outputs/checks.nix +++ b/nix/snow/flake/outputs/checks.nix @@ -1,5 +1,10 @@ - checks = - inputs.deploy-rs.lib - |> mapAttrs (system: deployLib: - deployLib.deployChecks deploy); - +{ + config, + _snow, + ... +}: { + outputs.checks = + _snow.inputs.deploy-rs.lib + |> builtins.mapAttrs (system: deployLib: + deployLib.deployChecks config.outputs.deploy); +} diff --git a/nix/snow/flake/outputs/deploy.nix b/nix/snow/flake/outputs/deploy.nix index 08caa8f..c8d39a9 100644 --- a/nix/snow/flake/outputs/deploy.nix +++ b/nix/snow/flake/outputs/deploy.nix @@ -1,57 +1,90 @@ - deploy.nodes = mapNodes nodes ({ - name, - node, - ... - }: let - inherit - (node.deploy) - ssh - user - interactiveSudo - remoteBuild - rollback - autoRollback - magicRollback - activationTimeout - confirmTimeout - ; +{ + _snow, + config, + ... +}: let + inherit + (builtins) + mapAttrs + ; - nixosFor = system: inputs.deploy-rs.lib.${system}.activate.nixos; - in { - hostname = - if ssh.host != null - then ssh.host - else ""; + mapNodes = nodes: f: + nodes.nodes + |> mapAttrs (name: node: let + # use per-node base or default to nodes' base + base = + if node.base != null + then node.base + else if nodes.base != null + then nodes.base + else + abort '' + snow cannot construct nodes node "${name}" without a base package source. + Ensure `nodes.nodes.*.base` or `nodes.base` is a flake reference to the github:NixOS/nixpkgs repository. + ''; + in + f rec { + inherit name node base; + inherit (base) lib; - profilesOrder = ["default"]; # profiles priority - profiles.default = { - path = nixosFor node.system nixosConfigurations.${name}; + groups = node.groups (parseGroupsDecl nodes.groups); + groupModules = root: getGroupModules root groups; + }); +in { + outputs.deploy.nodes = mapNodes config.nodes ({ + name, + node, + ... + }: let + inherit + (node.deploy) + ssh + user + interactiveSudo + remoteBuild + rollback + autoRollback + magicRollback + activationTimeout + confirmTimeout + ; - user = user; - sudo = "sudo -u"; - interactiveSudo = interactiveSudo; + nixosFor = system: _snow.inputs.deploy-rs.lib.${system}.activate.nixos; + in { + hostname = + if ssh.host != null + then ssh.host + else ""; - fastConnection = false; + profilesOrder = ["default"]; # profiles priority + profiles.default = { + path = nixosFor node.system config.outputs.nixosConfigurations.${name}; - autoRollback = autoRollback -> rollback; - magicRollback = magicRollback -> rollback; - activationTimeout = activationTimeout; - confirmTimeout = confirmTimeout; + user = user; + sudo = "sudo -u"; + interactiveSudo = interactiveSudo; - remoteBuild = remoteBuild; - sshUser = ssh.user; - sshOpts = - ssh.opts - ++ ( - if elem "-p" ssh.opts - then [] - else ["-p" (toString ssh.port)] - ) - ++ ( - if elem "-A" ssh.opts - then [] - else ["-A"] - ); - }; - }); + fastConnection = false; + autoRollback = autoRollback -> rollback; + magicRollback = magicRollback -> rollback; + activationTimeout = activationTimeout; + confirmTimeout = confirmTimeout; + + remoteBuild = remoteBuild; + sshUser = ssh.user; + sshOpts = + ssh.opts + ++ ( + if builtins.elem "-p" ssh.opts + then [] + else ["-p" (toString ssh.port)] + ) + ++ ( + if builtins.elem "-A" ssh.opts + then [] + else ["-A"] + ); + }; + }); +} diff --git a/nix/snow/flake/outputs/nixosConfigurations.nix b/nix/snow/flake/outputs/nixosConfigurations.nix index 799758d..2ca88b6 100644 --- a/nix/snow/flake/outputs/nixosConfigurations.nix +++ b/nix/snow/flake/outputs/nixosConfigurations.nix @@ -8,60 +8,78 @@ # options = { ... }; # type = { ... }; # } - nixosConfigurations = mapNodes nodes ( - { - base, - lib, - name, - node, - groupModules, - ... - }: let - homeManager = - if node.homeManager != null - then node.homeManager - else if nodes.homeManager != null - then nodes.homeManager - else - warn '' - [snowflake] Neither `nodes.homeManager` nor `nodes.nodes.${name}.homeManager` were specified! - [snowflake] home-manager will NOT be used! User configuration will be ignored! - '' - null; +{ + snow, + config, + systems, + root, + ... +}: let + inherit + (builtins) + all + attrNames + warn + ; - userArgs = nodes.args // node.args; - ceruleanArgs = { - inherit systems root base nodes node; - inherit (node) system; - inherit (this) snow; - hostname = name; + inherit + (config) + nodes + ; +in { + outputs.nixosConfigurations = mapNodes nodes ( + { + base, + lib, + name, + node, + groupModules, + ... + }: let + homeManager = + if node.homeManager != null + then node.homeManager + else if nodes.homeManager != null + then nodes.homeManager + else + warn '' + [snowflake] Neither `nodes.homeManager` nor `nodes.nodes.${name}.homeManager` were specified! + [snowflake] home-manager will NOT be used! User configuration will be ignored! + '' + null; - _cerulean = { - inherit inputs userArgs ceruleanArgs homeManager; - specialArgs = userArgs // ceruleanArgs; - }; + userArgs = nodes.args // node.args; + snowArgs = { + inherit systems snow root base nodes node; + inherit (node) system; + hostname = name; + + _snow = { + inherit inputs userArgs snowArgs homeManager; + specialArgs = userArgs // snowArgs; }; - specialArgs = assert (userArgs - |> attrNames - |> all (argName: - ! ceruleanArgs ? argName - || abort '' - `specialArgs` are like super important to Cerulean my love... attrNames + |> all (argName: + ! snowArgs ? argName + || abort '' + `specialArgs` are like super important to Snow my love... mapAttrs (name: value: - if elem name ["_name" "_parent"] - # ignore metadata fields - then value - else assert validGroup value; (delegate result name value)); - in - result; - in - assert validGroup groups; - delegate null rootGroupName groups; - - getGroupModules = root: groups: - # ensure root group is always added - groups - # add all inherited groups via _parent - |> map (let - delegate = g: - if g._parent == null - then [g] - else [g] ++ delegate (g._parent); - in - delegate) - # flatten recursion result - |> concatLists - # find import location - |> map (group: nt.findImport /${root}/groups/${group._name}) - # filter by uniqueness - |> nt.prim.unique - # ignore missing groups - |> filter pathExists; -in { - mapNodes = nodes: f: - nodes.nodes - |> mapAttrs (name: node: let - # use per-node base or default to nodes' base - base = - if node.base != null - then node.base - else if nodes.base != null - then nodes.base - else - abort '' - Cerulean cannot construct nodes node "${name}" without a base package source. - Ensure `nodes.nodes.*.base` or `nodes.base` is a flake reference to the github:NixOS/nixpkgs repository. - ''; - in - f rec { - inherit name node base; - inherit (base) lib; - - groups = node.groups (parseGroupsDecl nodes.groups); - groupModules = root: getGroupModules root groups; - }); -}