Modules/constraints: init constraints checking for inventory compatible modules

This commit is contained in:
Johannes Kirschbauer
2024-11-12 18:35:01 +01:00
parent 53a8771c18
commit 241db1cade
9 changed files with 203 additions and 25 deletions

View File

@@ -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

View File

@@ -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;

View File

@@ -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.
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.<roleName>.<constraintType>` (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
---
```

View File

@@ -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);
}

View File

@@ -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;
};
};
}
];
}
);
};
}

View File

@@ -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
;
}

View File

@@ -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

View File

@@ -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

View File

@@ -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]: