Merge pull request 'Inventory/constraints improve observability' (#2400) from hsjobeki/clan-core:hsjobeki-main into main

This commit is contained in:
clan-bot
2024-11-13 13:42:17 +00:00
13 changed files with 297 additions and 235 deletions

View File

@@ -2,8 +2,10 @@
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
[constraints]
roles.controller.min = 1
roles.controller.max = 1
roles.moon.max = 7
---
## Overview

View File

@@ -48,7 +48,7 @@ in
# 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}";
message = "The zerotier module currently only supports one instance per machine, but found ${builtins.toString instanceNames} on machine ${config.clan.core.machineName}";
}
];

View File

@@ -17,7 +17,7 @@
},
"services": {
"zerotier": {
"1": {
"one": {
"roles": {
"controller": {
"machines": ["test-inventory-machine"]

View File

@@ -2,53 +2,51 @@
lib,
config,
resolvedRoles,
instanceName,
moduleName,
...
}:
let
inherit (config) roles;
in
{
imports = [
./interface.nix
];
config.assertions = lib.foldl' (
ass: roleName:
# Role assertions
{
config.assertions = lib.foldlAttrs (
ass: roleName: roleConstraints:
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
[
{
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
[ ];
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
eqCheck ++ minCheck ++ maxCheck ++ ass
) [ ] (lib.attrNames config.roles);
ass // maxCheck // minCheck
) { } roles;
}
];
}

View File

@@ -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 = [
{

View File

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

View File

@@ -55,15 +55,16 @@ 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
frontmatter = clan-core.lib.modules.getFrontmatter moduleName;
roles =
if builtins.elem "inventory" frontmatter.features or [ ] then
assert lib.isPath module;
getRoles module;
clan-core.lib.modules.getRoles' moduleName
else
[ ];
in
lib.listToAttrs (
lib.map (role: {

View File

@@ -41,39 +41,60 @@ 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.
/*
Returns a NixOS configuration for every machine in the inventory.
machinesFromInventory :: Inventory -> { ${machine_name} :: NixOSConfiguration }
*/
buildInventory =
{ inventory, directory }:
# For every machine in the inventory, build a NixOS configuration
# For each machine generate config, forEach service, if the machine is used.
builtins.mapAttrs (
machineName: machineConfig:
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 }
acc: serviceName: serviceConfigs:
acc
initialServiceModules: serviceName: serviceConfigs:
initialServiceModules
# 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."
)
)
);
roles = clan-core.lib.modules.getRoles' serviceName;
resolvedRoles = lib.genAttrs roles (
roleName:
@@ -127,9 +148,11 @@ let
nonExistingRoles = builtins.filter (role: !(builtins.elem role roles)) (
builtins.attrNames (serviceConfig.roles or { })
);
constraintAssertions = clan-core.lib.modules.checkConstraints {
moduleName = serviceName;
inherit resolvedRoles;
inherit resolvedRoles instanceName;
instanceNames = builtins.attrNames serviceConfigs;
};
in
if (nonExistingRoles != [ ]) then
@@ -141,10 +164,17 @@ let
++ [
{
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 != [ ])
{
config.clan.${serviceName} = lib.mkMerge (
clan.${serviceName} = lib.mkMerge (
[
globalConfig
machineServiceConfig
@@ -153,51 +183,32 @@ let
);
}
)
({
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.
# Global extension for each machine
++ (extendMachine { inherit machineConfig inventory; });
/*
Returns a NixOS configuration for every machine in the inventory.
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.
'';
machinesFromInventory :: Inventory -> { ${machine_name} :: NixOSConfiguration }
*/
buildInventory =
{ inventory, directory }:
# For every machine in the inventory, build a NixOS configuration
# For each machine generate config, forEach service, if the machine is used.
builtins.mapAttrs (
machineName: machineConfig:
mapMachineConfigToNixOSConfig {
inherit
machineName
machineConfig
inventory
directory
;
}
]
) [ ] inventory.services;
}
]
) (inventory.machines or { });
in
{

View File

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

View File

@@ -3,7 +3,7 @@
imports = [
./backups.nix
./facts
./inventory/interface.nix
./inventory
./manual.nix
./meta/interface.nix
./metadata.nix

View File

@@ -0,0 +1,6 @@
{
imports = [
./interface.nix
./implementation.nix
];
}

View File

@@ -0,0 +1,6 @@
{ config, ... }:
{
config.assertions = builtins.attrValues (
builtins.mapAttrs (_id: value: value // { inherit _id; }) config.clan.inventory.assertions
);
}

View File

@@ -60,4 +60,22 @@ in
'';
type = lib.types.attrsOf (lib.types.attrsOf instanceOptions);
};
options.clan.inventory.assertions = lib.mkOption {
default = { };
internal = true;
visible = false;
type = lib.types.attrsOf (
# TODO: use NixOS upstream type
lib.types.submodule {
options = {
assertion = lib.mkOption {
type = lib.types.bool;
};
message = lib.mkOption {
type = lib.types.str;
};
};
}
);
};
}