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
*/
# TODO: evaluate against the role.settings statically and use extendModules to get the machineSettings
# Doing this might improve performance
evalMachineSettings =
{
roleName,
@@ -91,15 +89,12 @@ let
instanceName: instance:
lib.mapAttrs (roleName: role: {
machines = lib.mapAttrs (machineName: v: {
# TODO: evaluate the settings against the interface
# settings = (evalMachineSettings { inherit roleName instanceName; inherit (v) settings; }).config;
settings =
(evalMachineSettings {
inherit roleName instanceName machineName;
inherit (v) settings;
}).config;
}) role.machines;
# TODO: evaluate the settings against the interface
settings =
(evalMachineSettings {
inherit roleName instanceName;
@@ -140,11 +135,6 @@ in
(
{ name, ... }:
{
# options.settings = mkOption {
# description = "settings of 'instance': ${name}";
# default = {};
# apply = v: lib.seq (checkInstanceSettings name v) v;
# };
options.roles = mkOption {
description = ''
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'
'';
type = types.deferredModule;
# TODO: Default to an empty module
# need to test that an the empty module can be evaluated to empty settings
default = { };
};
options.perInstance = mkOption {
@@ -424,7 +412,6 @@ in
```
'';
};
# TODO: Recursive services
options.services = mkOption {
visible = false;
type = attrsWith {
@@ -444,7 +431,6 @@ in
];
};
};
apply = _: throw "Not implemented yet";
default = { };
};
})
@@ -551,7 +537,6 @@ in
```
'';
};
# TODO: Recursive services
options.services = mkOption {
visible = false;
type = attrsWith {
@@ -569,7 +554,6 @@ in
];
};
};
apply = _: throw "Not implemented yet";
default = { };
};
})
@@ -603,7 +587,6 @@ in
in
uniqueStrings (collectRoles machineScope.instances);
};
# TODO: instances.<instanceName>.roles should contain all roles, even if nobody has the role
inherit (machineScope) instances;
# There are no machine settings.
@@ -641,7 +624,7 @@ in
allMachines :: {
<machineName> :: {
nixosModule :: NixOSModule;
services :: { }; # TODO: nested services
services :: { };
};
};
};
@@ -680,6 +663,7 @@ in
type = types.attrsOf types.raw;
};
# The result collected from 'perMachine'
result.allMachines = mkOption {
visible = false;
readOnly = true;
@@ -734,13 +718,40 @@ in
default = lib.mapAttrs (
machineName: machineResult:
let
instanceResults = lib.foldlAttrs (
acc: roleName: role:
acc
++ lib.foldlAttrs (
acc: instanceName: instance:
instanceResults =
lib.foldlAttrs
(
roleAcc: roleName: role:
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
acc
instanceAcc.nixosModules
++ [
(lib.setDefaultModuleLocation
"Via instances.${instanceName}.roles.${roleName}.machines.${machineName}"
@@ -748,14 +759,22 @@ in
)
]
else
acc
) [ ] role.allInstances
) [ ] config.result.allRoles;
instanceAcc.nixosModules
);
}
) roleAcc role.allInstances
)
{
nixosModules = [ ];
# ...
}
config.result.allRoles;
in
{
inherit instanceResults;
inherit instanceResults machineResult;
nixosModule = {
imports = [
imports =
[
# include service assertions:
(
let
@@ -765,12 +784,27 @@ in
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
# 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)
(machineResult.nixosModule)
] ++ instanceResults;
${errorContext}
''
else
serviceModule.result.final.${machineName}.nixosModule
) machineResult.services)
++ instanceResults.nixosModules;
};
}
) config.result.allMachines;

View File

@@ -278,4 +278,5 @@ in
per_machine_args = import ./per_machine_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";
};
}