From aa26d2ebf2cc27d4317ea08c6f140e80186a57bf Mon Sep 17 00:00:00 2001 From: Johannes Kirschbauer Date: Sat, 14 Jun 2025 20:03:25 +0200 Subject: [PATCH] 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. --- .../distributed-service/service-module.nix | 132 +++++++++++------- .../distributed-service/tests/default.nix | 1 + .../tests/nested_services/default.nix | 4 + .../tests/nested_services/simple.nix | 117 ++++++++++++++++ 4 files changed, 205 insertions(+), 49 deletions(-) create mode 100644 lib/inventory/distributed-service/tests/nested_services/default.nix create mode 100644 lib/inventory/distributed-service/tests/nested_services/simple.nix diff --git a/lib/inventory/distributed-service/service-module.nix b/lib/inventory/distributed-service/service-module.nix index 99704bc6a..1dcf61719 100644 --- a/lib/inventory/distributed-service/service-module.nix +++ b/lib/inventory/distributed-service/service-module.nix @@ -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..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 :: { :: { 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,43 +718,93 @@ in default = lib.mapAttrs ( machineName: machineResult: let - instanceResults = lib.foldlAttrs ( - acc: roleName: role: - acc - ++ lib.foldlAttrs ( - acc: instanceName: instance: - if instance.allMachines.${machineName}.nixosModule or { } != { } then - acc - ++ [ - (lib.setDefaultModuleLocation - "Via instances.${instanceName}.roles.${roleName}.machines.${machineName}" - instance.allMachines.${machineName}.nixosModule - ) - ] - else - acc - ) [ ] role.allInstances - ) [ ] config.result.allRoles; + 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 + instanceAcc.nixosModules + ++ [ + (lib.setDefaultModuleLocation + "Via instances.${instanceName}.roles.${roleName}.machines.${machineName}" + instance.allMachines.${machineName}.nixosModule + ) + ] + else + instanceAcc.nixosModules + ); + } + ) roleAcc role.allInstances + ) + { + nixosModules = [ ]; + # ... + } + config.result.allRoles; in { - inherit instanceResults; + inherit instanceResults machineResult; nixosModule = { - imports = [ - # include service assertions: - ( + imports = + [ + # include service assertions: + ( + let + failedAssertions = (lib.filterAttrs (_: v: !v.assertion) config.result.assertions); + in + { + assertions = lib.attrValues failedAssertions; + } + ) + (lib.setDefaultModuleLocation "Via ${config.manifest.name}.perMachine - machine='${machineName}';" machineResult.nixosModule) + ] + ++ (lib.mapAttrsToList ( + nestedServiceName: serviceModule: let - failedAssertions = (lib.filterAttrs (_: v: !v.assertion) config.result.assertions); + unmatchedMachines = lib.attrNames ( + lib.removeAttrs serviceModule.result.final (lib.attrNames config.result.allMachines) + ); in - { - assertions = lib.attrValues failedAssertions; - } - ) + 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; diff --git a/lib/inventory/distributed-service/tests/default.nix b/lib/inventory/distributed-service/tests/default.nix index b8bb70c59..b4fb4221b 100644 --- a/lib/inventory/distributed-service/tests/default.nix +++ b/lib/inventory/distributed-service/tests/default.nix @@ -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; }; } diff --git a/lib/inventory/distributed-service/tests/nested_services/default.nix b/lib/inventory/distributed-service/tests/nested_services/default.nix new file mode 100644 index 000000000..509276c9b --- /dev/null +++ b/lib/inventory/distributed-service/tests/nested_services/default.nix @@ -0,0 +1,4 @@ +{ clanLib, lib, ... }: +{ + test_simple = import ./simple.nix { inherit clanLib lib; }; +} diff --git a/lib/inventory/distributed-service/tests/nested_services/simple.nix b/lib/inventory/distributed-service/tests/nested_services/simple.nix new file mode 100644 index 000000000..2e1e58492 --- /dev/null +++ b/lib/inventory/distributed-service/tests/nested_services/simple.nix @@ -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"; + }; +}