feat(clan-services): enable recursive services

Using recursive services is potentially complex and requires carefully
designed services. Nested Services create nixos modules which must be
mergable as always.
This commit is contained in:
Johannes Kirschbauer
2025-06-14 20:03:25 +02:00
parent aa65e8e533
commit aa26d2ebf2
4 changed files with 205 additions and 49 deletions

View File

@@ -26,8 +26,6 @@ let
The caller is responsible to use .config or .extendModules The caller is responsible to use .config or .extendModules
*/ */
# TODO: evaluate against the role.settings statically and use extendModules to get the machineSettings
# Doing this might improve performance
evalMachineSettings = evalMachineSettings =
{ {
roleName, roleName,
@@ -91,15 +89,12 @@ let
instanceName: instance: instanceName: instance:
lib.mapAttrs (roleName: role: { lib.mapAttrs (roleName: role: {
machines = lib.mapAttrs (machineName: v: { machines = lib.mapAttrs (machineName: v: {
# TODO: evaluate the settings against the interface
# settings = (evalMachineSettings { inherit roleName instanceName; inherit (v) settings; }).config;
settings = settings =
(evalMachineSettings { (evalMachineSettings {
inherit roleName instanceName machineName; inherit roleName instanceName machineName;
inherit (v) settings; inherit (v) settings;
}).config; }).config;
}) role.machines; }) role.machines;
# TODO: evaluate the settings against the interface
settings = settings =
(evalMachineSettings { (evalMachineSettings {
inherit roleName instanceName; inherit roleName instanceName;
@@ -140,11 +135,6 @@ in
( (
{ name, ... }: { name, ... }:
{ {
# options.settings = mkOption {
# description = "settings of 'instance': ${name}";
# default = {};
# apply = v: lib.seq (checkInstanceSettings name v) v;
# };
options.roles = mkOption { options.roles = mkOption {
description = '' description = ''
Roles of the instance. Roles of the instance.
@@ -328,8 +318,6 @@ in
- *defaults* that depend on the *machine* or *instance* should be added to *settings* later in 'perInstance' or 'perMachine' - *defaults* that depend on the *machine* or *instance* should be added to *settings* later in 'perInstance' or 'perMachine'
''; '';
type = types.deferredModule; type = types.deferredModule;
# TODO: Default to an empty module
# need to test that an the empty module can be evaluated to empty settings
default = { }; default = { };
}; };
options.perInstance = mkOption { options.perInstance = mkOption {
@@ -424,7 +412,6 @@ in
``` ```
''; '';
}; };
# TODO: Recursive services
options.services = mkOption { options.services = mkOption {
visible = false; visible = false;
type = attrsWith { type = attrsWith {
@@ -444,7 +431,6 @@ in
]; ];
}; };
}; };
apply = _: throw "Not implemented yet";
default = { }; default = { };
}; };
}) })
@@ -551,7 +537,6 @@ in
``` ```
''; '';
}; };
# TODO: Recursive services
options.services = mkOption { options.services = mkOption {
visible = false; visible = false;
type = attrsWith { type = attrsWith {
@@ -569,7 +554,6 @@ in
]; ];
}; };
}; };
apply = _: throw "Not implemented yet";
default = { }; default = { };
}; };
}) })
@@ -603,7 +587,6 @@ in
in in
uniqueStrings (collectRoles machineScope.instances); uniqueStrings (collectRoles machineScope.instances);
}; };
# TODO: instances.<instanceName>.roles should contain all roles, even if nobody has the role
inherit (machineScope) instances; inherit (machineScope) instances;
# There are no machine settings. # There are no machine settings.
@@ -641,7 +624,7 @@ in
allMachines :: { allMachines :: {
<machineName> :: { <machineName> :: {
nixosModule :: NixOSModule; nixosModule :: NixOSModule;
services :: { }; # TODO: nested services services :: { };
}; };
}; };
}; };
@@ -680,6 +663,7 @@ in
type = types.attrsOf types.raw; type = types.attrsOf types.raw;
}; };
# The result collected from 'perMachine'
result.allMachines = mkOption { result.allMachines = mkOption {
visible = false; visible = false;
readOnly = true; readOnly = true;
@@ -734,13 +718,40 @@ in
default = lib.mapAttrs ( default = lib.mapAttrs (
machineName: machineResult: machineName: machineResult:
let let
instanceResults = lib.foldlAttrs ( instanceResults =
acc: roleName: role: lib.foldlAttrs
acc (
++ lib.foldlAttrs ( roleAcc: roleName: role:
acc: instanceName: instance: roleAcc
// lib.foldlAttrs (
instanceAcc: instanceName: instance:
instanceAcc
// {
nixosModules =
(
(lib.mapAttrsToList (
nestedServiceName: serviceModule:
let
unmatchedMachines = lib.attrNames (
lib.removeAttrs serviceModule.result.final (lib.attrNames config.result.allMachines)
);
in
if unmatchedMachines != [ ] then
throw ''
The following machines are not part of the parent service: ${builtins.toJSON unmatchedMachines}
Either remove the machines, or include them into the parent via a role.
(Added via roles.${roleName}.perInstance.services.${nestedServiceName})
${errorContext}
''
else
serviceModule.result.final.${machineName}.nixosModule
) instance.allMachines.${machineName}.services)
)
++ (
if instance.allMachines.${machineName}.nixosModule or { } != { } then if instance.allMachines.${machineName}.nixosModule or { } != { } then
acc instanceAcc.nixosModules
++ [ ++ [
(lib.setDefaultModuleLocation (lib.setDefaultModuleLocation
"Via instances.${instanceName}.roles.${roleName}.machines.${machineName}" "Via instances.${instanceName}.roles.${roleName}.machines.${machineName}"
@@ -748,14 +759,22 @@ in
) )
] ]
else else
acc instanceAcc.nixosModules
) [ ] role.allInstances );
) [ ] config.result.allRoles; }
) roleAcc role.allInstances
)
{
nixosModules = [ ];
# ...
}
config.result.allRoles;
in in
{ {
inherit instanceResults; inherit instanceResults machineResult;
nixosModule = { nixosModule = {
imports = [ imports =
[
# include service assertions: # include service assertions:
( (
let let
@@ -765,12 +784,27 @@ in
assertions = lib.attrValues failedAssertions; assertions = lib.attrValues failedAssertions;
} }
) )
(lib.setDefaultModuleLocation "Via ${config.manifest.name}.perMachine - machine='${machineName}';" machineResult.nixosModule)
]
++ (lib.mapAttrsToList (
nestedServiceName: serviceModule:
let
unmatchedMachines = lib.attrNames (
lib.removeAttrs serviceModule.result.final (lib.attrNames config.result.allMachines)
);
in
if unmatchedMachines != [ ] then
throw ''
The following machines are not part of the parent service: ${builtins.toJSON unmatchedMachines}
Either remove the machines, or include them into the parent via a role.
(Added via perMachine.services.${nestedServiceName})
# For error backtracing. This module was produced by the 'perMachine' function ${errorContext}
# TODO: check if we need this or if it leads to better errors if we pass the underlying module locations ''
# (lib.setDefaultModuleLocation "clan.service: ${config.manifest.name} - via perMachine" machineResult.nixosModule) else
(machineResult.nixosModule) serviceModule.result.final.${machineName}.nixosModule
] ++ instanceResults; ) machineResult.services)
++ instanceResults.nixosModules;
}; };
} }
) config.result.allMachines; ) config.result.allMachines;

View File

@@ -278,4 +278,5 @@ in
per_machine_args = import ./per_machine_args.nix { inherit lib callInventoryAdapter; }; per_machine_args = import ./per_machine_args.nix { inherit lib callInventoryAdapter; };
per_instance_args = import ./per_instance_args.nix { inherit lib callInventoryAdapter; }; per_instance_args = import ./per_instance_args.nix { inherit lib callInventoryAdapter; };
nested = import ./nested_services { inherit lib clanLib; };
} }

View File

@@ -0,0 +1,4 @@
{ clanLib, lib, ... }:
{
test_simple = import ./simple.nix { inherit clanLib lib; };
}

View File

@@ -0,0 +1,117 @@
/*
service-B :: Service
exports a nixosModule which set "address" and "hostname"
Note: How we use null together with mkIf to create optional values.
This is a method, to create mergable modules
service-A :: Service
service-A.roles.server.perInstance.services."B"
imports service-B
configures a client with hostname = "johnny"
service-A.perMachine.services."B"
imports service-B
configures a client with address = "root"
*/
{ clanLib, lib, ... }:
let
service-B = (
{ lib, ... }:
{
manifest.name = "service-B";
roles.client.interface = {
options.hostname = lib.mkOption { default = null; };
options.address = lib.mkOption { default = null; };
};
roles.client.perInstance =
{ settings, ... }:
{
nixosModule = {
imports = [
# Only export the value that is actually set.
(lib.mkIf (settings.hostname != null) {
hostname = settings.hostname;
})
(lib.mkIf (settings.address != null) {
address = settings.address;
})
];
};
};
}
);
service-A =
{ ... }:
{
manifest.name = "service-A";
instances.foo = {
roles.server.machines."jon" = { };
};
instances.bar = {
roles.server.machines."jon" = { };
};
roles.server = {
perInstance =
{ machine, instanceName, ... }:
{
services."B" = {
imports = [
service-B
];
instances."B-for-A" = {
roles.client.machines.${machine.name} = {
settings.hostname = instanceName + "+johnny";
};
};
};
};
};
perMachine =
{ machine, ... }:
{
services."B" = {
imports = [
service-B
];
instances."B-for-A" = {
roles.client.machines.${machine.name} = {
settings.address = "root";
};
};
};
};
};
eval = clanLib.inventory.evalClanService {
modules = [
(service-A)
];
prefix = [ ];
};
evalNixos = lib.evalModules {
modules = [
{
options.assertions = lib.mkOption { };
options.hostname = lib.mkOption { type = lib.types.separatedString " "; };
options.address = lib.mkOption { type = lib.types.str; };
}
eval.config.result.final."jon".nixosModule
];
};
in
{
# Check that the nixos system has the settings from the nested module, as well as those from the "perMachine" and "perInstance"
inherit eval;
expr = evalNixos.config;
expected = {
address = "root";
assertions = [ ];
# Concatenates hostnames from both instances
hostname = "bar+johnny foo+johnny";
};
}