node inheritance + group parsing

This commit is contained in:
do butterflies cry? 2026-03-15 01:10:36 +10:00
parent d891a92357
commit f819933c8d
Signed by: cry
GPG key ID: F68745A836CA0412
12 changed files with 422 additions and 253 deletions

View file

@ -18,15 +18,14 @@
} @ args: let } @ args: let
inherit (nt) findImport; inherit (nt) findImport;
in in
mix.newMixture args (mixture: let mix.newMixture args (mixture: {
inherit (mixture) mapNodes;
in {
includes = { includes = {
private = [ private = [
./lib/nodes.nix ./lib/nodes.nix
]; ];
public = [ public = [
./flake ./flake
./lib.nix
]; ];
}; };

View file

@ -21,7 +21,6 @@
No option has been declared for this flake output attribute, so its definitions can't be merged automatically. No option has been declared for this flake output attribute, so its definitions can't be merged automatically.
Possible solutions: Possible solutions:
- Load a module that defines this flake output attribute - 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 - Declare an option for this flake output attribute
- Make sure the output attribute is spelled correctly - Make sure the output attribute is spelled correctly
- Define the value only once, with a single definition in a single module - Define the value only once, with a single definition in a single module

View file

@ -12,6 +12,7 @@
# See the License for the specific language governing permissions and # See the License for the specific language governing permissions and
# limitations under the License. # limitations under the License.
{ {
_snow,
lib, lib,
specialArgs, specialArgs,
... ...
@ -25,38 +26,20 @@
in in
mkOption { mkOption {
description = '' description = ''
Cerulean node declarations. Snowflake node declarations.
''; '';
type = types.submoduleWith { type = types.submoduleWith {
inherit specialArgs; inherit specialArgs;
modules = [ modules = [
{ ./nodes.nix
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.
'';
};
};
}
]; ];
}; };
}; };
config = {
nodes = {
base = _snow.inputs.nixpkgs;
};
};
} }

View file

@ -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;
}

View file

@ -14,16 +14,18 @@
{ {
lib, lib,
systems, systems,
config,
nodesConfig,
... ...
}: { }: {
imports = [./shared.nix];
options = let options = let
inherit inherit
(lib) (lib)
mkOption mkOption
types types
; ;
flakeRef = types.either types.str types.path;
in { in {
enabled = lib.mkOption { enabled = lib.mkOption {
type = types.bool; 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.<name>.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
<https://github.com/NixOS/nix/issues/7075>.
'';
};
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.<name>.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 { groups = mkOption {
# TODO: write a custom group type that validates better than types.attrs lol # TODO: write a custom group type that validates better than types.attrs lol
type = types.functionTo (types.listOf types.attrs); type = types.functionTo (types.listOf types.attrs);
@ -51,6 +112,9 @@
description = '' description = ''
A function from the `groups` hierarchy to a list of groups this node inherits from. A function from the `groups` hierarchy to a list of groups this node inherits from.
''; '';
apply = groupsFn:
groupsFn nodesConfig.groups;
}; };
deploy = { deploy = {
@ -91,7 +155,7 @@
example = false; example = false;
description = '' description = ''
Whether to enable interactive sudo (password based sudo). 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 { user = mkOption {
type = types.str; type = types.str;
default = "cerubld"; default = "snowbld";
example = "custom-user"; example = "custom-user";
description = '' description = ''
The user to connect to over ssh during deployment. The user to connect to over ssh during deployment.
@ -183,7 +247,7 @@
publicKeys = mkOption { publicKeys = mkOption {
type = types.listOf types.str; type = types.listOf types.str;
default = []; default = [];
example = ["ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIIeyZuUUmyUYrYaEJwEMvcXqZFYm1NaZab8klOyK6Imr me@puter"]; example = ["ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIIeyZuUUmyUYrYaEJwEMvcXqZFYm1NaZab8klOyK6Imr me@myputer"];
description = '' description = ''
SSH public keys that will be authorized to the deployment user. SSH public keys that will be authorized to the deployment user.
This key is intended solely for deployment, allowing for fine-grained permission control. 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>.${name}` must be set for all nodes! (got: <null>)
'';
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;
};
} }

View file

@ -11,7 +11,13 @@
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and # See the License for the specific language governing permissions and
# limitations under the License. # limitations under the License.
{lib, ...}: let {
_snow,
lib,
config,
specialArgs,
...
}: let
inherit inherit
(lib) (lib)
mkOption mkOption
@ -19,6 +25,8 @@
; ;
flakeRef = types.either types.str types.path; flakeRef = types.either types.str types.path;
groupLibs = import ./groups.nix {inherit (_snow.inputs) nt;};
in { in {
options = { options = {
base = lib.mkOption { 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.<name>.homeManager` (takes prescedence over `options.nodes.homeManager`)
'';
};
modules = mkOption { modules = mkOption {
type = types.listOf types.raw; type = types.listOf types.raw;
default = []; default = [];
@ -67,15 +87,28 @@ in {
''; '';
}; };
homeManager = mkOption { groups = mkOption {
type = types.nullOr flakeRef; type = types.attrs;
default = null; default = {};
example = lib.literalExpression "inputs.home-manager"; example = lib.literalExpression "{ servers = { staging = {}; production = {}; }; }";
description = '' description = ''
The path to the home-manager source. A `homeManager` flake reference Hierarchical groups that nodes can be a member of.
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.<name>.homeManager` (takes prescedence over `options.nodes.homeManager`) apply = groupLibs.parseGroupsDecl;
};
nodes = mkOption {
type = types.attrsOf (types.submoduleWith {
specialArgs =
specialArgs
// {
nodeConfig = config;
};
modules = [./node.nix];
});
description = ''
Node (host systems) declarations.
''; '';
}; };
}; };

View file

@ -1,5 +1,10 @@
checks = {
inputs.deploy-rs.lib config,
|> mapAttrs (system: deployLib: _snow,
deployLib.deployChecks deploy); ...
}: {
outputs.checks =
_snow.inputs.deploy-rs.lib
|> builtins.mapAttrs (system: deployLib:
deployLib.deployChecks config.outputs.deploy);
}

View file

@ -1,57 +1,90 @@
deploy.nodes = mapNodes nodes ({ {
name, _snow,
node, config,
... ...
}: let }: let
inherit inherit
(node.deploy) (builtins)
ssh mapAttrs
user ;
interactiveSudo
remoteBuild
rollback
autoRollback
magicRollback
activationTimeout
confirmTimeout
;
nixosFor = system: inputs.deploy-rs.lib.${system}.activate.nixos; mapNodes = nodes: f:
in { nodes.nodes
hostname = |> mapAttrs (name: node: let
if ssh.host != null # use per-node base or default to nodes' base
then ssh.host base =
else ""; 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 groups = node.groups (parseGroupsDecl nodes.groups);
profiles.default = { groupModules = root: getGroupModules root groups;
path = nixosFor node.system nixosConfigurations.${name}; });
in {
outputs.deploy.nodes = mapNodes config.nodes ({
name,
node,
...
}: let
inherit
(node.deploy)
ssh
user
interactiveSudo
remoteBuild
rollback
autoRollback
magicRollback
activationTimeout
confirmTimeout
;
user = user; nixosFor = system: _snow.inputs.deploy-rs.lib.${system}.activate.nixos;
sudo = "sudo -u"; in {
interactiveSudo = interactiveSudo; 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; user = user;
magicRollback = magicRollback -> rollback; sudo = "sudo -u";
activationTimeout = activationTimeout; interactiveSudo = interactiveSudo;
confirmTimeout = confirmTimeout;
remoteBuild = remoteBuild; fastConnection = false;
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"]
);
};
});
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"]
);
};
});
}

View file

@ -8,60 +8,78 @@
# options = { ... }; # options = { ... };
# type = { ... }; # type = { ... };
# } # }
nixosConfigurations = mapNodes nodes ( {
{ snow,
base, config,
lib, systems,
name, root,
node, ...
groupModules, }: let
... inherit
}: let (builtins)
homeManager = all
if node.homeManager != null attrNames
then node.homeManager warn
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;
userArgs = nodes.args // node.args; inherit
ceruleanArgs = { (config)
inherit systems root base nodes node; nodes
inherit (node) system; ;
inherit (this) snow; in {
hostname = name; 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 = { userArgs = nodes.args // node.args;
inherit inputs userArgs ceruleanArgs homeManager; snowArgs = {
specialArgs = userArgs // ceruleanArgs; 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 specialArgs = assert (userArgs
|> all (argName: |> attrNames
! ceruleanArgs ? argName |> all (argName:
|| abort '' ! snowArgs ? argName
`specialArgs` are like super important to Cerulean my love... </3 || abort ''
But `args.${argName}` is a reserved argument name :( `specialArgs` are like super important to Snow my love... </3
'')); But `args.${argName}` is a reserved argument name :(
ceruleanArgs._cerulean.specialArgs; ''));
in snowArgs._snow.specialArgs;
lib.nixosSystem { in
inherit (node) system; lib.nixosSystem {
inherit specialArgs; inherit (node) system;
modules = inherit specialArgs;
[ modules =
self.nixosModules.default [
(this.findImport /${root}/hosts/${name}) snow.nixosModules.default
] (snow.findImport /${root}/hosts/${name})
++ (groupModules root) ]
++ node.modules ++ (groupModules root)
++ nodes.modules; ++ node.modules
} ++ nodes.modules;
); }
);
}

27
nix/snow/lib/default.nix Normal file
View file

@ -0,0 +1,27 @@
# 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,
mix,
...
} @ args: let
inherit (nt) findImport;
in
mix.newMixture args (mixture: {
includes.public = [
./nixpkgs.nix
];
inherit findImport;
})

View file

@ -1,6 +1,7 @@
{ {
inputs,
lib, lib,
revInfo ? "", ...
}: let }: let
inherit inherit
(lib) (lib)
@ -12,11 +13,12 @@
# override it at all. # override it at all.
minVersion = "23.05pre-git"; minVersion = "23.05pre-git";
isNixpkgsValidVersion = isNixpkgsValidVersion = let
revInfo = lib.optional (inputs.nixpkgs?rev) " (nixpkgs-lib.rev: ${inputs.nixpkgs.rev})";
in
(builtins.compareVersions lib.version minVersion < 0) (builtins.compareVersions lib.version minVersion < 0)
# XXX: TODO: make this message snow specific
|| abort '' || abort ''
The nixpkgs-lib dependency of flake-parts was overridden but is too old. The nixpkgs dependency of snow was overridden but is too old.
The minimum supported version of nixpkgs-lib is ${minVersion}, The minimum supported version of nixpkgs-lib is ${minVersion},
but the actual version is ${lib.version}${revInfo}. but the actual version is ${lib.version}${revInfo}.
''; '';

View file

@ -1,96 +0,0 @@
# 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";
parseGroupsDecl = groups: let
validGroup = g:
isAttrs g
|| throw ''
Cerulean Nexus groups must be provided as attribute sets, got "${typeOf g}" instead!
Ensure all the group definitions are attribute sets under your call to `cerulean.mkNexus`.
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;
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;
});
}