From 280878e30a1e5b1a1afd41a51abfb81e1f7310d0 Mon Sep 17 00:00:00 2001 From: Johannes Kirschbauer Date: Wed, 13 Nov 2024 13:58:43 +0100 Subject: [PATCH] Inventory/constraints: add id's to inventory constraints to make them more observable --- lib/constraints/default.nix | 78 +++--- lib/constraints/interface.nix | 36 ++- lib/description.nix | 60 +++-- lib/eval-clan-modules/default.nix | 5 +- lib/inventory/build-inventory/default.nix | 301 +++++++++++----------- lib/inventory/tests/default.nix | 4 +- 6 files changed, 255 insertions(+), 229 deletions(-) diff --git a/lib/constraints/default.nix b/lib/constraints/default.nix index 3b1b4f48c..7fc080624 100644 --- a/lib/constraints/default.nix +++ b/lib/constraints/default.nix @@ -2,53 +2,51 @@ lib, config, resolvedRoles, + instanceName, moduleName, ... }: +let + inherit (config) roles; +in { imports = [ ./interface.nix - ]; - config.assertions = lib.foldl' ( - ass: roleName: - let - roleConstraints = config.roles.${roleName}; - members = resolvedRoles.${roleName}.machines; - memberCount = builtins.length members; - # Checks - eqCheck = - if roleConstraints.eq != null then - [ - { - assertion = memberCount == roleConstraints.eq; - message = "The ${moduleName} module requires exactly ${builtins.toString roleConstraints.eq} '${roleName}', but found ${builtins.toString memberCount}: ${builtins.toString members}"; - } - ] - else - [ ]; - - minCheck = - if roleConstraints.min > 0 then - [ - { + # Role assertions + { + config.assertions = lib.foldlAttrs ( + ass: roleName: roleConstraints: + let + members = resolvedRoles.${roleName}.machines; + memberCount = builtins.length members; + # Checks + minCheck = lib.optionalAttrs (roleConstraints.min > 0) { + "${moduleName}.${instanceName}.roles.${roleName}.min" = { assertion = memberCount >= roleConstraints.min; - message = "The ${moduleName} module requires at least ${builtins.toString roleConstraints.min} '${roleName}'s, but found ${builtins.toString memberCount}: ${builtins.toString members}"; - } - ] - else - [ ]; + message = '' + The ${moduleName} module requires at least ${builtins.toString roleConstraints.min} '${roleName}'s + but found '${builtins.toString memberCount}' within instance '${instanceName}': - maxCheck = - if roleConstraints.max != null then - [ - { + ${lib.concatLines members} + ''; + }; + }; + + maxCheck = lib.optionalAttrs (roleConstraints.max != null) { + "${moduleName}.${instanceName}.roles.${roleName}.max" = { assertion = memberCount <= roleConstraints.max; - message = "The ${moduleName} module allows at most for ${builtins.toString roleConstraints.max} '${roleName}'s, but found ${builtins.toString memberCount}: ${builtins.toString members}"; - } - ] - else - [ ]; - in - eqCheck ++ minCheck ++ maxCheck ++ ass - ) [ ] (lib.attrNames config.roles); + message = '' + The ${moduleName} module allows at most for ${builtins.toString roleConstraints.max} '${roleName}'s + but found '${builtins.toString memberCount}' within instance '${instanceName}': + + ${lib.concatLines members} + ''; + }; + }; + + in + ass // maxCheck // minCheck + ) { } roles; + } + ]; } diff --git a/lib/constraints/interface.nix b/lib/constraints/interface.nix index 58f3c5ede..006772fa2 100644 --- a/lib/constraints/interface.nix +++ b/lib/constraints/interface.nix @@ -1,9 +1,19 @@ -{ lib, allRoles, ... }: +{ + lib, + allRoles, + moduleName, + ... +}: let inherit (lib) mkOption types; rolesAttrs = builtins.groupBy lib.id allRoles; in { + options.serviceName = mkOption { + type = types.str; + default = moduleName; + readOnly = true; + }; options.roles = lib.mapAttrs ( _name: _: mkOption { @@ -20,10 +30,6 @@ in type = types.int; default = 0; }; - eq = mkOption { - type = types.nullOr types.int; - default = null; - }; }; } ]; @@ -31,10 +37,26 @@ in } ) rolesAttrs; + options.instances = mkOption { + default = { }; + type = types.submoduleWith { + modules = [ + { + options = { + max = mkOption { + type = types.nullOr types.int; + default = null; + }; + }; + } + ]; + }; + }; + # The resulting assertions options.assertions = mkOption { - default = [ ]; - type = types.listOf ( + default = { }; + type = types.attrsOf ( types.submoduleWith { modules = [ { diff --git a/lib/description.nix b/lib/description.nix index 58ec5f74d..68a114d81 100644 --- a/lib/description.nix +++ b/lib/description.nix @@ -1,27 +1,26 @@ { clan-core, lib }: let - getRoles = - modulePath: - let - rolesDir = modulePath + "/roles"; - in - if builtins.pathExists rolesDir then - lib.pipe rolesDir [ - builtins.readDir - (lib.filterAttrs (_n: v: v == "regular")) - lib.attrNames - (lib.filter (fileName: lib.hasSuffix ".nix" fileName)) - (map (fileName: lib.removeSuffix ".nix" fileName)) - ] - else - [ ]; + trimExtension = name: builtins.substring 0 (builtins.stringLength name - 4) name; + + getRoles' = + serviceName: + lib.mapAttrsToList (name: _value: trimExtension name) ( + lib.filterAttrs (name: type: type == "regular" && lib.hasSuffix ".nix" name) ( + builtins.readDir ( + if clan-core.clanModules ? ${serviceName} then + clan-core.clanModules.${serviceName} + "/roles" + else + throw "ClanModule not found: '${serviceName}'. Make sure the module is added in the 'clanModules' attribute of clan-core." + ) + ) + ); getConstraints = modulename: let eval = lib.evalModules { specialArgs = { - allRoles = getRoles clan-core.clanModules.${modulename}; + allRoles = getRoles' modulename; }; modules = [ ./constraints/interface.nix @@ -32,23 +31,22 @@ let eval.config.roles; checkConstraints = - { moduleName, resolvedRoles }: + { + moduleName, + resolvedRoles, + instanceNames, + instanceName, + }: let eval = lib.evalModules { specialArgs = { - inherit moduleName; - allRoles = getRoles clan-core.clanModules.${moduleName}; - resolvedRoles = { - controller = { - machines = [ "test-inventory-machine" ]; - }; - moon = { - machines = [ ]; - }; - peer = { - machines = [ ]; - }; - }; + inherit + moduleName + instanceNames + instanceName + resolvedRoles + ; + allRoles = getRoles' moduleName; }; modules = [ ./constraints/default.nix @@ -101,7 +99,7 @@ in inherit getFrontmatter getReadme - getRoles + getRoles' getConstraints checkConstraints ; diff --git a/lib/eval-clan-modules/default.nix b/lib/eval-clan-modules/default.nix index e2f2f70d2..826dca6be 100644 --- a/lib/eval-clan-modules/default.nix +++ b/lib/eval-clan-modules/default.nix @@ -55,15 +55,12 @@ let evalClanModulesWithRoles = clanModules: let - getRoles = clan-core.lib.modules.getRoles; res = builtins.mapAttrs ( moduleName: module: let - # module must be a path to the clanModule root by convention - # See: clanModules/flake-module.nix roles = assert lib.isPath module; - getRoles module; + clan-core.lib.modules.getRoles' moduleName; in lib.listToAttrs ( lib.map (role: { diff --git a/lib/inventory/build-inventory/default.nix b/lib/inventory/build-inventory/default.nix index 17f5fff96..fb0d4f60a 100644 --- a/lib/inventory/build-inventory/default.nix +++ b/lib/inventory/build-inventory/default.nix @@ -41,8 +41,155 @@ let serviceName: builtins.elem "inventory" (clan-core.lib.modules.getFrontmatter serviceName).features or [ ]; - trimExtension = name: builtins.substring 0 (builtins.stringLength name - 4) name; + extendMachine = + { machineConfig, inventory }: + [ + (lib.optionalAttrs (machineConfig.deploy.targetHost or null != null) { + config.clan.core.networking.targetHost = machineConfig.deploy.targetHost; + }) + { + assertions = lib.foldlAttrs ( + acc: serviceName: _serviceConfigs: + acc + ++ [ + { + assertion = checkService serviceName; + message = '' + Service ${serviceName} cannot be used in inventory. It does not declare the 'inventory' feature. + + To allow it add the following to the beginning of the README.md of the module: + + --- + ... + + features = [ "inventory" ] + --- + + Also make sure to test the module with the 'inventory' feature enabled. + + ''; + } + ] + ) [ ] inventory.services; + } + ]; + + mapMachineConfigToNixOSConfig = + # Returns a NixOS configuration for the machine 'machineName'. + # Return Format: { imports = [ ... ]; config = { ... }; options = { ... } } + { + machineName, + machineConfig, + inventory, + directory, + }: + lib.foldlAttrs ( + # [ Modules ], String, { ${instance_name} :: ServiceConfig } + initialServiceModules: serviceName: serviceConfigs: + initialServiceModules + # Collect service config + ++ (lib.foldlAttrs ( + # [ Modules ], String, ServiceConfig + acc2: instanceName: serviceConfig: + + let + roles = clan-core.lib.modules.getRoles' serviceName; + + resolvedRoles = lib.genAttrs roles ( + roleName: + resolveTags { + members = serviceConfig.roles.${roleName} or { }; + inherit + serviceName + instanceName + roleName + inventory + ; + } + ); + + isInService = builtins.any (members: builtins.elem machineName members.machines) ( + builtins.attrValues resolvedRoles + ); + + # all roles where the machine is present + machineRoles = builtins.attrNames ( + lib.filterAttrs (_role: roleConfig: builtins.elem machineName roleConfig.machines) resolvedRoles + ); + machineServiceConfig = (serviceConfig.machines.${machineName} or { }).config or { }; + globalConfig = serviceConfig.config or { }; + + globalExtraModules = serviceConfig.extraModules or [ ]; + machineExtraModules = serviceConfig.machines.${machineName}.extraModules or [ ]; + roleServiceExtraModules = builtins.foldl' ( + acc: role: acc ++ serviceConfig.roles.${role}.extraModules or [ ] + ) [ ] machineRoles; + + # TODO: maybe optimize this dont lookup the role in inverse roles. Imports are not lazy + roleModules = builtins.map ( + role: + if builtins.elem role roles && clan-core.clanModules ? ${serviceName} then + clan-core.clanModules.${serviceName} + "/roles/${role}.nix" + else + throw "Module ${serviceName} doesn't have role: '${role}'. Role: ${ + clan-core.clanModules.${serviceName} + }/roles/${role}.nix not found." + ) machineRoles; + + roleServiceConfigs = builtins.filter (m: m != { }) ( + builtins.map (role: serviceConfig.roles.${role}.config or { }) machineRoles + ); + + extraModules = map (s: if builtins.typeOf s == "string" then "${directory}/${s}" else s) ( + globalExtraModules ++ machineExtraModules ++ roleServiceExtraModules + ); + + nonExistingRoles = builtins.filter (role: !(builtins.elem role roles)) ( + builtins.attrNames (serviceConfig.roles or { }) + ); + + constraintAssertions = clan-core.lib.modules.checkConstraints { + moduleName = serviceName; + inherit resolvedRoles instanceName; + instanceNames = builtins.attrNames serviceConfigs; + }; + in + if (nonExistingRoles != [ ]) then + throw "Roles ${builtins.toString nonExistingRoles} are not defined in the service ${serviceName}." + else if !(serviceConfig.enabled or true) then + acc2 + else if isInService then + acc2 + ++ [ + { + imports = roleModules ++ extraModules; + + clan.inventory.assertions = constraintAssertions; + clan.inventory.services.${serviceName}.${instanceName} = { + roles = resolvedRoles; + # TODO: Add inverseRoles to the service config if needed + # inherit inverseRoles; + }; + } + (lib.optionalAttrs (globalConfig != { } || machineServiceConfig != { } || roleServiceConfigs != [ ]) + { + clan.${serviceName} = lib.mkMerge ( + [ + globalConfig + machineServiceConfig + ] + ++ roleServiceConfigs + ); + } + ) + ] + else + acc2 + ) [ ] (serviceConfigs)) + ) [ ] inventory.services + # Global extension for each machine + ++ (extendMachine { inherit machineConfig inventory; }); /* Returns a NixOS configuration for every machine in the inventory. @@ -54,150 +201,14 @@ let # For each machine generate config, forEach service, if the machine is used. builtins.mapAttrs ( machineName: machineConfig: - lib.foldlAttrs ( - # [ Modules ], String, { ${instance_name} :: ServiceConfig } - acc: serviceName: serviceConfigs: - acc - # Collect service config - ++ (lib.foldlAttrs ( - # [ Modules ], String, ServiceConfig - acc2: instanceName: serviceConfig: - - let - roles = lib.mapAttrsToList (name: _value: trimExtension name) ( - lib.filterAttrs (name: type: type == "regular" && lib.hasSuffix ".nix" name) ( - builtins.readDir ( - if clan-core.clanModules ? ${serviceName} then - clan-core.clanModules.${serviceName} + "/roles" - else - throw "ClanModule not found: '${serviceName}'. Make sure the module is added in the 'clanModules' attribute of clan-core." - ) - ) - ); - - resolvedRoles = lib.genAttrs roles ( - roleName: - resolveTags { - members = serviceConfig.roles.${roleName} or { }; - inherit - serviceName - instanceName - roleName - inventory - ; - } - ); - - isInService = builtins.any (members: builtins.elem machineName members.machines) ( - builtins.attrValues resolvedRoles - ); - - # all roles where the machine is present - machineRoles = builtins.attrNames ( - lib.filterAttrs (_role: roleConfig: builtins.elem machineName roleConfig.machines) resolvedRoles - ); - machineServiceConfig = (serviceConfig.machines.${machineName} or { }).config or { }; - globalConfig = serviceConfig.config or { }; - - globalExtraModules = serviceConfig.extraModules or [ ]; - machineExtraModules = serviceConfig.machines.${machineName}.extraModules or [ ]; - roleServiceExtraModules = builtins.foldl' ( - acc: role: acc ++ serviceConfig.roles.${role}.extraModules or [ ] - ) [ ] machineRoles; - - # TODO: maybe optimize this dont lookup the role in inverse roles. Imports are not lazy - roleModules = builtins.map ( - role: - if builtins.elem role roles && clan-core.clanModules ? ${serviceName} then - clan-core.clanModules.${serviceName} + "/roles/${role}.nix" - else - throw "Module ${serviceName} doesn't have role: '${role}'. Role: ${ - clan-core.clanModules.${serviceName} - }/roles/${role}.nix not found." - ) machineRoles; - - roleServiceConfigs = builtins.filter (m: m != { }) ( - builtins.map (role: serviceConfig.roles.${role}.config or { }) machineRoles - ); - - extraModules = map (s: if builtins.typeOf s == "string" then "${directory}/${s}" else s) ( - globalExtraModules ++ machineExtraModules ++ roleServiceExtraModules - ); - - nonExistingRoles = builtins.filter (role: !(builtins.elem role roles)) ( - builtins.attrNames (serviceConfig.roles or { }) - ); - constraintAssertions = clan-core.lib.modules.checkConstraints { - moduleName = serviceName; - inherit resolvedRoles; - }; - in - if (nonExistingRoles != [ ]) then - throw "Roles ${builtins.toString nonExistingRoles} are not defined in the service ${serviceName}." - else if !(serviceConfig.enabled or true) then - acc2 - else if isInService then - acc2 - ++ [ - { - imports = roleModules ++ extraModules; - } - (lib.optionalAttrs (globalConfig != { } || machineServiceConfig != { } || roleServiceConfigs != [ ]) - { - config.clan.${serviceName} = lib.mkMerge ( - [ - globalConfig - machineServiceConfig - ] - ++ roleServiceConfigs - ); - } - ) - ({ - assertions = constraintAssertions; - clan.inventory.services.${serviceName}.${instanceName} = { - roles = resolvedRoles; - # TODO: Add inverseRoles to the service config if needed - # inherit inverseRoles; - }; - }) - ] - else - acc2 - ) [ ] (serviceConfigs)) - ) [ ] inventory.services - # Append each machine config - ++ [ - (lib.optionalAttrs (machineConfig.deploy.targetHost or null != null) { - config.clan.core.networking.targetHost = machineConfig.deploy.targetHost; - }) - { - assertions = lib.foldlAttrs ( - acc: serviceName: _: - acc - ++ [ - { - assertion = checkService serviceName; - message = '' - Service ${serviceName} cannot be used in inventory. It does not declare the 'inventory' feature. - - - To allow it add the following to the beginning of the README.md of the module: - - --- - ... - - features = [ "inventory" ] - --- - - Also make sure to test the module with the 'inventory' feature enabled. - - ''; - } - ] - ) [ ] inventory.services; - } - ] + mapMachineConfigToNixOSConfig { + inherit + machineName + machineConfig + inventory + directory + ; + } ) (inventory.machines or { }); in { diff --git a/lib/inventory/tests/default.nix b/lib/inventory/tests/default.nix index 8fdffdcf0..7db165963 100644 --- a/lib/inventory/tests/default.nix +++ b/lib/inventory/tests/default.nix @@ -92,8 +92,8 @@ in not_used_machine = builtins.length configs.not_used_machine; }; expected = { - client_1_machine = 5; - client_2_machine = 5; + client_1_machine = 4; + client_2_machine = 4; not_used_machine = 2; }; };