Inventory: generate exact schema for validation & documentation

This commit is contained in:
Johannes Kirschbauer
2024-07-14 13:30:02 +02:00
parent 3034b9ef92
commit e54101165f
8 changed files with 209 additions and 205 deletions

View File

@@ -15,7 +15,7 @@ let
};
machineRef = lib.mkOptionType {
name = "machineRef";
name = "str";
description = "Machine :: [${builtins.concatStringsSep " | " (builtins.attrNames config.machines)}]";
check = v: lib.isString v && builtins.elem v (builtins.attrNames config.machines);
merge = lib.mergeEqualOption;
@@ -29,20 +29,85 @@ let
);
tagRef = lib.mkOptionType {
name = "tagRef";
name = "str";
description = "Tags :: [${builtins.concatStringsSep " | " allTags}]";
check = v: lib.isString v && builtins.elem v allTags;
merge = lib.mergeEqualOption;
};
moduleConfig = lib.mkOption {
default = { };
type = t.attrsOf t.anything;
};
in
{
options.assertions = lib.mkOption {
type = t.listOf t.unspecified;
internal = true;
default = [ ];
options = {
assertions = lib.mkOption {
type = t.listOf t.unspecified;
internal = true;
visible = false;
default = [ ];
};
meta = metaOptions;
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;
};
system = lib.mkOption {
default = null;
type = t.nullOr t.str;
};
};
}
);
};
services = lib.mkOption {
default = { };
type = t.attrsOf (
t.attrsOf (
t.submodule {
options.meta = metaOptions;
options.config = moduleConfig;
options.machines = lib.mkOption {
default = { };
type = t.attrsOf (t.submodule { options.config = moduleConfig; });
};
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;
};
}
);
};
}
)
);
};
};
# Smoke validation of the inventory
config.assertions =
let
# Inventory assertions
# - All referenced machines must exist in the top-level machines
serviceAssertions = lib.foldlAttrs (
ass1: serviceName: c:
ass1
@@ -60,8 +125,11 @@ in
ass2 ++ assertions
) [ ] c
) [ ] config.services;
# Machine assertions
# - A machine must define their host system
machineAssertions = map (
{ name, value }:
{ name }:
{
assertion = true;
message = "Machine ${name} should define its host system in the inventory. ()";
@@ -69,68 +137,4 @@ in
) (lib.attrsToList (lib.filterAttrs (_n: v: v.system or null == null) config.machines));
in
machineAssertions ++ serviceAssertions;
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;
};
system = lib.mkOption {
default = null;
type = t.nullOr t.str;
};
};
}
);
};
options.services = lib.mkOption {
default = { };
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

@@ -1,47 +0,0 @@
{
"machines": {
"camina_machine": {
"name": "camina"
},
"vyr_machine": {
"name": "vyr"
},
"vi_machine": {
"name": "vi"
}
},
"meta": {
"name": "kenjis clan"
},
"services": {
"syncthing": {
"instance_1": {
"meta": {
"name": "My sync"
},
"roles": {
"peer": {
"machines": ["vyr_machine", "vi_machine", "camina_machine"]
}
},
"machines": {},
"config": {
"folders": {
"test": {
"path": "~/data/docs",
"devices": ["camina_machine", "vyr_machine", "vi_machine"]
},
"videos": {
"path": "~/data/videos",
"devices": ["camina_machine", "vyr_machine"]
},
"playlist": {
"path": "~/data/playlist",
"devices": ["camina_machine", "vi_machine"]
}
}
}
}
}
}
}

View File

@@ -1,36 +0,0 @@
{
"machines": {
"camina_machine": {
"name": "camina"
},
"vyr_machine": {
"name": "vyr"
},
"vi_machine": {
"name": "vi"
}
},
"meta": {
"name": "kenjis clan"
},
"services": {
"zerotier": {
"instance_1": {
"meta": {
"name": "My Network"
},
"roles": {
"controller": { "machines": ["vyr_machine"] },
"moon": { "machines": ["vyr_machine"] },
"peer": { "machines": ["vi_machine", "camina_machine"] }
},
"machines": {
"vyr_machine": {
"config": {}
}
},
"config": {}
}
}
}
}

View File

@@ -21,16 +21,113 @@ in
clan-core = self;
inherit lib;
};
optionsFromModule =
mName:
let
eval = self.lib.evalClanModules [ mName ];
in
if (eval.options.clan ? "${mName}") then eval.options.clan.${mName} else { };
modulesSchema = lib.mapAttrs (
moduleName: _: jsonLib'.parseOptions (optionsFromModule moduleName) { }
) self.clanModules;
jsonLib = self.lib.jsonschema {
# includeDefaults = false;
};
jsonLib' = self.lib.jsonschema {
# includeDefaults = false;
header = { };
};
inventorySchema = jsonLib.parseModule (import ./build-inventory/interface.nix);
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
(map (fileName: lib.removeSuffix ".nix" fileName))
]
else
null;
schema = inventorySchema // {
properties = inventorySchema.properties // {
services = {
type = "object";
additionalProperties = false;
properties = lib.mapAttrs (moduleName: moduleSchema: {
type = "object";
additionalProperties = {
type = "object";
additionalProperties = false;
properties = {
meta =
inventorySchema.properties.services.additionalProperties.additionalProperties.properties.meta;
config = moduleSchema;
roles = {
type = "object";
additionalProperties = false;
required = [ ];
properties = lib.listToAttrs (
map
(role: {
name = role;
value =
inventorySchema.properties.services.additionalProperties.additionalProperties.properties.roles.additionalProperties;
})
(
let
roles = getRoles self.clanModules.${moduleName};
in
if roles == null then [ ] else roles
)
);
};
machines =
lib.recursiveUpdate
inventorySchema.properties.services.additionalProperties.additionalProperties.properties.machines
{ additionalProperties.properties.config = moduleSchema; };
};
};
}) modulesSchema;
};
};
};
in
{
legacyPackages.inventorySchema = schema;
devShells.inventory-schema = pkgs.mkShell {
inputsFrom = with config.checks; [
lib-inventory-examples-cue
lib-inventory-schema
lib-inventory-eval
self'.devShells.default
];
};
# Inventory schema with concrete module implementations
packages.inventory-schema = pkgs.stdenv.mkDerivation {
name = "inventory-schema";
buildInputs = [ pkgs.cue ];
src = ./.;
buildPhase = ''
export SCHEMA=${builtins.toFile "inventory-schema.json" (builtins.toJSON self'.legacyPackages.inventorySchema)}
cp $SCHEMA schema.json
cue import -f -p compose -l '#Root:' schema.json
mkdir $out
cp schema.cue $out
cp schema.json $out
'';
};
# Run: nix-unit --extra-experimental-features flakes --flake .#legacyPackages.x86_64-linux.evalTests
legacyPackages.evalTests-inventory = import ./tests {
inherit buildInventory;
@@ -38,32 +135,21 @@ in
};
checks = {
lib-inventory-eval = pkgs.runCommand "tests" { nativeBuildInputs = [ pkgs.nix-unit ]; } ''
export HOME="$(realpath .)"
nix-unit --eval-store "$HOME" \
--extra-experimental-features flakes \
${inputOverrides} \
--flake ${self}#legacyPackages.${system}.evalTests-inventory
touch $out
'';
lib-inventory-schema = pkgs.stdenv.mkDerivation {
lib-inventory-examples-cue = pkgs.stdenv.mkDerivation {
name = "inventory-schema-checks";
src = ./.;
buildInputs = [ pkgs.cue ];
buildPhase = ''
echo "Running inventory tests..."
# Cue is easier to run in the same directory as the schema
cd spec
cp ${self'.packages.inventory-schema}/schema.cue root.cue
echo "Export cue as json-schema..."
cue export --out openapi root.cue
ls -la .
echo "Validate test/*.json against inventory-schema..."
cat root.cue
test_dir="../examples"
test_dir="./examples"
for file in "$test_dir"/*; do
# Check if the item is a file
if [ -f "$file" ]; then
@@ -78,6 +164,16 @@ in
touch $out
'';
};
lib-inventory-eval = pkgs.runCommand "tests" { nativeBuildInputs = [ pkgs.nix-unit ]; } ''
export HOME="$(realpath .)"
nix-unit --eval-store "$HOME" \
--extra-experimental-features flakes \
${inputOverrides} \
--flake ${self}#legacyPackages.${system}.evalTests-inventory
touch $out
'';
};
};
}