Inventory: init module merge & validation logic for inventory

This commit is contained in:
Johannes Kirschbauer 2024-06-22 21:31:01 +02:00 committed by hsjobeki
parent c89080deb4
commit 2f8b782a1f
9 changed files with 198 additions and 168 deletions

View File

@ -1,6 +1,6 @@
{ config, lib, ... }:
let
instances = config.clan.services.borgbackup;
instances = config.clan.inventory.services.borgbackup;
# roles = { ${role_name} :: { machines :: [string] } }
allServers = lib.foldlAttrs (
acc: _instanceName: instanceConfig:

View File

@ -4,7 +4,7 @@ let
machineDir = clanDir + "/machines/";
inherit (config.clan.core) machineName;
instances = config.clan.services.borgbackup;
instances = config.clan.inventory.services.borgbackup;
# roles = { ${role_name} :: { machines :: [string] } }

View File

@ -13,7 +13,6 @@ let
inherit lib clan-core;
inherit (inputs) nixpkgs;
};
cfg = config.clan;
in
{
@ -91,6 +90,7 @@ in
clanInternals = lib.mkOption {
type = lib.types.submodule {
options = {
inventory = lib.mkOption { type = lib.types.attrsOf lib.types.unspecified; };
meta = lib.mkOption { type = lib.types.attrsOf lib.types.unspecified; };
all-machines-json = lib.mkOption { type = lib.types.attrsOf lib.types.unspecified; };
machines = lib.mkOption { type = lib.types.attrsOf (lib.types.attrsOf lib.types.unspecified); };

View File

@ -7,40 +7,18 @@ The inventory is our concept for distributed services. Users can configure multi
- Ensures that all machines and services are configured consistently, across multiple nixosConfigs.
- Defaults and predefined roles in our modules minimizes the need for manual configuration.
Design questions:
Open questions:
- [ ] How do we set default role, description and other metadata?
- It must be accessible from Python.
- It must set the value in the module system.
- [ ] Inventory might use assertions. Should each machine inherit the inventory assertions ?
- [ ] Is the service config interface the same as the module config interface ?
- [ ] As a user i dont want to see borgbackup as the high level category ?
- [ ] As a user do I want to see borgbackup as the high level category?
- [x] Must roles be a list ?
-> Yes. In zerotier a machine can be "moon" and "controller" at the same time.
- [x] Is role client different from peer ? Do we have one example where we use client and peer together and they are different?
-> There are many roles. And they depend on the service.
- [x] Should we use the module name in the path of the service?
-> YES
```json
// ${module_name}.${instance_name}
services.borgbackup-static.backup1 = {
}
```
Pro:
Easier to handle.
Better groups the module specific instances.
Contra:
More nesting in json
Neutral: Module name is hard to change. Exists anyways.
- [x] Should the machine specific service config be part of the service?
-> NO. because ...
- The config implements the schema of the module, which is declared in the service.
- If the config is placed in the machine, it becomes unclear that the scope is ONLY the service and NOT the global nixos config.
- If the config is placed in the machine it is de-located into another top-level field. In the module this complicates access.
Architecture
@ -55,27 +33,7 @@ nixos < borgbackup <- inventory <-> UI
Defining Users is out of scope for the first prototype.
```
- [ ] Why do we need 2 modules?
-> It is technically possible to have only 1 module.
Pros:
Simple to use/Easy to understand.
Less modules
Cons:
Harder to write a module. Because it must do 2 things.
One module should do only 1 thing.
```nix
clan.machines.${machine_name} = {
# "borgbackup.ssh.pub" = machineDir + machines + "/facts/borgbackup.ssh.pub";
facts = ...
};
clan.services.${instance} = {
# roles.server = [ "jon_machine" ]
# roles.${role_name} = [ ${machine_name} ];
};
```
This part provides a specification for the inventory.
## Provides a specification for the inventory
It is used for design phase and as validation helper.

View File

@ -3,76 +3,25 @@ let
clan-core = self;
in
{
# Extension of the build clan interface
# new_clan = clan-core.lib.buildClan {
# # High level services.
# # If you need multiple instances of a service configure them via:
# # inventory.services.[serviceName].[instanceName] = ...
# services = {
# borbackup = {
# roles.server.machines = [ "vyr" ];
# roles.client.tags = [ "laptop" ];
# machines.vyr = {
# config = {
# };
# };
# config = {
# };
# };
# };
# # Low level inventory i.e. if you need multiple instances of a service
# # Or if you want to manipulate the created inventory directly.
# inventory.services.borbackup.default = { };
# # Machines. each machine can be referenced by its attribute name under services.
# machines = {
# camina = {
# # This is added to machine tags
# clan.tags = [ "laptop" ];
# # These are the inventory machine fields
# clan.meta.description = "";
# clan.meta.name = "";
# clan.meta.icon = "";
# # Config ...
# };
# vyr = {
# # Config ...
# };
# vi = {
# clan.networking.targetHost = "root@78.47.164.46";
# # Config ...
# };
# aya = {
# clan.networking.targetHost = "root@78.47.164.46";
# # Config ...
# };
# ezra = {
# # Config ...
# };
# rianon = {
# # Config ...
# };
# };
# };
clan = clan-core.lib.buildClan {
meta.name = "sams's clans";
meta.name = "kenjis clan";
# Should usually point to the directory of flake.nix
directory = self;
# services = {
# borgbackup = {
# roles.server.machines = [ "vyr_machine" ];
# roles.client.tags = [ "laptop" ];
# };
# };
# service config
# Useful alias: "inventory.services.borgbackup.default"
services = {
borgbackup = {
roles.server.machines = [ "vyr_machine" ];
roles.client.tags = [ "laptop" ];
};
};
# OR
# merged with
inventory = builtins.fromJSON (builtins.readFile ./src/tests/borgbackup.json);
# merged with
machines = {
"vyr_machine" = { };
"vi_machine" = {

View File

@ -31,59 +31,62 @@
inventory ? { },
}:
let
_inventory =
(
if services != { } && inventory == { } then
{ services = lib.mapAttrs (_name: value: { default = value; }) services; }
else if services == { } && inventory != { } then
# Internal inventory, this is the result of merging all potential inventory sources:
# - Default instances configured via 'services'
# - The inventory overrides
# - Machines that exist in inventory.machines
# - Machines explicitly configured via 'machines' argument
# - Machines that exist in the machines directory
# Checks on the module level:
# - Each service role must reference a valid machine after all machines are merged
mergedInventory =
(lib.evalModules {
modules = [
./interface.nix
{ inherit meta; }
# Default instances configured via 'services'
{
services = lib.mapAttrs (_name: value: {
default = value // {
meta.name = lib.mkDefault _name;
};
}) services;
}
# The inventory overrides
inventory
else if services != { } && inventory != { } then
throw "Either services or inventory should be set, but not both."
else
{ }
)
// {
machines = lib.mapAttrs (
name: config:
(lib.attrByPath [
"clan"
"meta"
] { } config)
// {
name = (
lib.attrByPath [
# Machines explicitly configured via 'machines' argument
{
# { ${name} :: meta // { name, tags } }
machines = lib.mapAttrs (
name: config:
(lib.attrByPath [
"clan"
"meta"
"name"
] name config
);
tags = lib.attrByPath [
"clan"
"tags"
] [ ] config;
] { } config)
// {
# meta.name default is the attribute name of the machine
name = lib.mkDefault (
lib.attrByPath [
"clan"
"meta"
"name"
] name config
);
tags = lib.attrByPath [
"clan"
"tags"
] [ ] config;
}
) machines;
}
) machines;
};
# Machines that exist in the machines directory
{ machines = lib.mapAttrs (name: _: { inherit name; }) machinesDirs; }
];
}).config;
buildInventory = import ./inventory.nix { inherit lib clan-core; };
pkgs = import nixpkgs { };
inventoryFile = builtins.toFile "inventory.json" (builtins.toJSON _inventory);
# a Derivation that can be forced to validate the inventory
# It is not used directly here.
validatedFile = pkgs.stdenv.mkDerivation {
name = "validated-inventory";
src = ../../inventory/src;
buildInputs = [ pkgs.cue ];
installPhase = ''
cue vet ${inventoryFile} root.cue -d "#Root"
cp ${inventoryFile} $out
'';
};
serviceConfigs = buildInventory _inventory;
serviceConfigs = buildInventory mergedInventory;
deprecationWarnings = [
(lib.warnIf (
@ -167,6 +170,8 @@ let
clan-core.nixosModules.clanCore
extraConfig
(machines.${name} or { })
# Inherit the inventory assertions ?
{ inherit (mergedInventory) assertions; }
{ imports = serviceConfigs.${name} or { }; }
(
{
@ -250,7 +255,7 @@ builtins.deepSeq deprecationWarnings {
# Evaluated clan meta
# Merged /clan/meta.json with overrides from buildClan
meta = mergedMeta;
inherit _inventory validatedFile;
inventory = mergedInventory;
# machine specifics
machines = configsPerSystem;

View File

@ -0,0 +1,120 @@
{ config, lib, ... }:
let
t = lib.types;
metaOptions = {
name = lib.mkOption { type = t.str; };
description = lib.mkOption {
default = null;
type = t.nullOr t.str;
};
icon = lib.mkOption {
default = null;
type = t.nullOr t.str;
};
};
machineRef = lib.mkOptionType {
name = "machineRef";
description = "Machine :: [${builtins.concatStringsSep " | " (builtins.attrNames config.machines)}]";
check = v: lib.isString v && builtins.elem v (builtins.attrNames config.machines);
merge = lib.mergeEqualOption;
};
allTags = lib.unique (
lib.foldlAttrs (
tags: _: m:
tags ++ m.tags or [ ]
) [ ] config.machines
);
tagRef = lib.mkOptionType {
name = "tagRef";
description = "Tags :: [${builtins.concatStringsSep " | " allTags}]";
check = v: lib.isString v && builtins.elem v allTags;
merge = lib.mergeEqualOption;
};
in
{
options.assertions = lib.mkOption {
type = t.listOf t.unspecified;
internal = true;
default = [ ];
};
config.assertions = lib.foldlAttrs (
ass1: serviceName: c:
ass1
++ lib.foldlAttrs (
ass2: instanceName: instanceConfig:
let
serviceMachineNames = lib.attrNames instanceConfig.machines;
topLevelMachines = lib.attrNames config.machines;
# All machines must be defined in the top-level machines
assertions = builtins.map (m: {
assertion = builtins.elem m topLevelMachines;
message = "${serviceName}.${instanceName}.machines.${m}. Should be one of [ ${builtins.concatStringsSep " | " topLevelMachines} ]";
}) serviceMachineNames;
in
ass2 ++ assertions
) [ ] c
) [ ] config.services;
options.meta = metaOptions;
options.machines = lib.mkOption {
default = { };
type = t.attrsOf (
t.submodule {
options = {
inherit (metaOptions) name description icon;
tags = lib.mkOption {
default = [ ];
apply = lib.unique;
type = t.listOf t.str;
};
};
}
);
};
options.services = lib.mkOption {
type = t.attrsOf (
t.attrsOf (
t.submodule {
options.meta = metaOptions;
options.config = lib.mkOption {
default = { };
type = t.anything;
};
options.machines = lib.mkOption {
default = { };
type = t.attrsOf (
t.submodule {
options.config = lib.mkOption {
default = { };
type = t.anything;
};
}
);
};
options.roles = lib.mkOption {
default = { };
type = t.attrsOf (
t.submodule {
options.machines = lib.mkOption {
default = [ ];
type = t.listOf machineRef;
};
options.tags = lib.mkOption {
default = [ ];
apply = lib.unique;
type = t.listOf tagRef;
};
}
);
};
}
)
);
};
}

View File

@ -90,7 +90,7 @@ let
];
}
{
config.clan.services.${moduleName}.${instanceName} = {
config.clan.inventory.services.${moduleName}.${instanceName} = {
roles = resolvedRoles;
# TODO: Add inverseRoles to the service config if needed
# inherit inverseRoles;

View File

@ -29,9 +29,7 @@ let
};
in
{
# clan.inventory.${moduleName}.${instanceName} = { ... }
# TODO: resolve clash with clan.services.waypipe
options.clan.services = lib.mkOption {
options.clan.inventory.services = lib.mkOption {
type = lib.types.attrsOf (lib.types.attrsOf instanceOptions);
};
}