From 241db1cade8dec27aa708d84de3271683038f7d0 Mon Sep 17 00:00:00 2001 From: Johannes Kirschbauer Date: Tue, 12 Nov 2024 18:35:01 +0100 Subject: [PATCH] Modules/constraints: init constraints checking for inventory compatible modules --- clanModules/zerotier/README.md | 3 + clanModules/zerotier/shared.nix | 9 +-- docs/site/clanmodules/index.md | 16 +++++- lib/constraints/default.nix | 54 ++++++++++++++++++ lib/constraints/interface.nix | 54 ++++++++++++++++++ lib/description.nix | 69 ++++++++++++++++++++++- lib/eval-clan-modules/default.nix | 16 +----- lib/inventory/build-inventory/default.nix | 6 ++ pkgs/clan-cli/clan_cli/api/modules.py | 1 + 9 files changed, 203 insertions(+), 25 deletions(-) create mode 100644 lib/constraints/default.nix create mode 100644 lib/constraints/interface.nix diff --git a/clanModules/zerotier/README.md b/clanModules/zerotier/README.md index 0572addb4..c82f8c1f1 100644 --- a/clanModules/zerotier/README.md +++ b/clanModules/zerotier/README.md @@ -1,6 +1,9 @@ --- description = "Configures [Zerotier VPN](https://zerotier.com) secure and efficient networking within a Clan.." features = [ "inventory" ] + +constraints.roles.controller.eq = 1 +constraints.roles.moon.max = 7 --- ## Overview diff --git a/clanModules/zerotier/shared.nix b/clanModules/zerotier/shared.nix index 72e8ccca8..2b5de214e 100644 --- a/clanModules/zerotier/shared.nix +++ b/clanModules/zerotier/shared.nix @@ -45,18 +45,11 @@ in config = { assertions = [ + # TODO: This should also be checked via frontmatter constraints { assertion = builtins.length instanceNames == 1; message = "The zerotier module currently only supports one instance per machine, but found ${builtins.toString instanceNames}"; } - { - assertion = builtins.length roles.controller.machines == 1; - message = "The zerotier module requires exactly one controller, but found ${builtins.toString roles.controller.machines}"; - } - { - assertion = builtins.length roles.moons.machines <= 7; - message = "The zerotier module allows at most for seven moons , but found ${builtins.toString roles.moons.machines}"; - } ]; clan.core.networking.zerotier.networkId = networkId; diff --git a/docs/site/clanmodules/index.md b/docs/site/clanmodules/index.md index 16e8a6355..670995314 100644 --- a/docs/site/clanmodules/index.md +++ b/docs/site/clanmodules/index.md @@ -191,4 +191,18 @@ Assuming that there is a common code path or a common interface between `server` Every ClanModule, that specifies `features = [ "inventory" ]` MUST have at least one role. Many modules use `roles/default.nix` which registers the role `default`. - If you are a clan module author and your module has only one role where you cannot determine the name, then we would like you to follow the convention. \ No newline at end of file + If you are a clan module author and your module has only one role where you cannot determine the name, then we would like you to follow the convention. + + +`constraints.roles..` (Optional `int`) (Experimental) +: Contraints for the module + + The following example requires exactly one `server` + and supports up to `7` clients + + ```md + --- + constraints.roles.server.eq = 1 + constraints.roles.client.max = 7 + --- + ``` diff --git a/lib/constraints/default.nix b/lib/constraints/default.nix new file mode 100644 index 000000000..3b1b4f48c --- /dev/null +++ b/lib/constraints/default.nix @@ -0,0 +1,54 @@ +{ + lib, + config, + resolvedRoles, + moduleName, + ... +}: +{ + 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 + [ + { + 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 + [ ]; + + maxCheck = + if roleConstraints.max != null then + [ + { + 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); +} diff --git a/lib/constraints/interface.nix b/lib/constraints/interface.nix new file mode 100644 index 000000000..58f3c5ede --- /dev/null +++ b/lib/constraints/interface.nix @@ -0,0 +1,54 @@ +{ lib, allRoles, ... }: +let + inherit (lib) mkOption types; + rolesAttrs = builtins.groupBy lib.id allRoles; +in +{ + options.roles = lib.mapAttrs ( + _name: _: + mkOption { + default = { }; + type = types.submoduleWith { + modules = [ + { + options = { + max = mkOption { + type = types.nullOr types.int; + default = null; + }; + min = mkOption { + type = types.int; + default = 0; + }; + eq = mkOption { + type = types.nullOr types.int; + default = null; + }; + }; + } + ]; + }; + } + ) rolesAttrs; + + # The resulting assertions + options.assertions = mkOption { + default = [ ]; + type = types.listOf ( + types.submoduleWith { + modules = [ + { + options = { + assertion = mkOption { + type = types.bool; + }; + message = mkOption { + type = types.str; + }; + }; + } + ]; + } + ); + }; +} diff --git a/lib/description.nix b/lib/description.nix index e18bcded4..58ec5f74d 100644 --- a/lib/description.nix +++ b/lib/description.nix @@ -1,5 +1,63 @@ { clan-core, lib }: -rec { +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 + [ ]; + + getConstraints = + modulename: + let + eval = lib.evalModules { + specialArgs = { + allRoles = getRoles clan-core.clanModules.${modulename}; + }; + modules = [ + ./constraints/interface.nix + (getFrontmatter modulename).constraints + ]; + }; + in + eval.config.roles; + + checkConstraints = + { moduleName, resolvedRoles }: + let + eval = lib.evalModules { + specialArgs = { + inherit moduleName; + allRoles = getRoles clan-core.clanModules.${moduleName}; + resolvedRoles = { + controller = { + machines = [ "test-inventory-machine" ]; + }; + moon = { + machines = [ ]; + }; + peer = { + machines = [ ]; + }; + }; + }; + modules = [ + ./constraints/default.nix + ((getFrontmatter moduleName).constraints or { }) + ]; + }; + in + eval.config.assertions; + getReadme = modulename: let @@ -38,4 +96,13 @@ rec { --- ...rest of your README.md... ''; +in +{ + inherit + getFrontmatter + getReadme + getRoles + getConstraints + checkConstraints + ; } diff --git a/lib/eval-clan-modules/default.nix b/lib/eval-clan-modules/default.nix index a5d4f5995..e2f2f70d2 100644 --- a/lib/eval-clan-modules/default.nix +++ b/lib/eval-clan-modules/default.nix @@ -55,21 +55,7 @@ let evalClanModulesWithRoles = clanModules: 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 - [ ]; + getRoles = clan-core.lib.modules.getRoles; res = builtins.mapAttrs ( moduleName: module: let diff --git a/lib/inventory/build-inventory/default.nix b/lib/inventory/build-inventory/default.nix index 9d6fe2439..17f5fff96 100644 --- a/lib/inventory/build-inventory/default.nix +++ b/lib/inventory/build-inventory/default.nix @@ -42,6 +42,7 @@ let builtins.elem "inventory" (clan-core.lib.modules.getFrontmatter serviceName).features or [ ]; trimExtension = name: builtins.substring 0 (builtins.stringLength name - 4) name; + /* Returns a NixOS configuration for every machine in the inventory. @@ -126,6 +127,10 @@ let 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}." @@ -149,6 +154,7 @@ let } ) ({ + assertions = constraintAssertions; clan.inventory.services.${serviceName}.${instanceName} = { roles = resolvedRoles; # TODO: Add inverseRoles to the service config if needed diff --git a/pkgs/clan-cli/clan_cli/api/modules.py b/pkgs/clan-cli/clan_cli/api/modules.py index ac25b9b59..fd7e02415 100644 --- a/pkgs/clan-cli/clan_cli/api/modules.py +++ b/pkgs/clan-cli/clan_cli/api/modules.py @@ -25,6 +25,7 @@ class Frontmatter: description: str categories: list[str] = field(default_factory=lambda: ["Uncategorized"]) features: list[str] = field(default_factory=list) + constraints: dict[str, Any] = field(default_factory=dict) @property def categories_info(self) -> dict[str, CategoryInfo]: