build-inventory: move inventory and inventoryClass into explizitly different folders
This commit is contained in:
@@ -9,7 +9,7 @@
|
||||
{ machines, ... }:
|
||||
{
|
||||
# Only compute the default value
|
||||
# The option MUST be defined in ./build-inventory/interface.nix
|
||||
# The option MUST be defined in inventoryClass/interface.nix
|
||||
all = lib.mkDefault (builtins.attrNames machines);
|
||||
nixos = lib.mkDefault (
|
||||
builtins.attrNames (lib.filterAttrs (_n: m: m.machineClass == "nixos") machines)
|
||||
|
||||
@@ -104,7 +104,7 @@ in
|
||||
_module.args = { inherit clanLib; };
|
||||
_file = "clan interface";
|
||||
}
|
||||
../../inventory/build-inventory/interface.nix
|
||||
../inventoryClass/interface.nix
|
||||
];
|
||||
};
|
||||
description = ''
|
||||
@@ -120,7 +120,7 @@ in
|
||||
Global information about the clan.
|
||||
'';
|
||||
type = types.deferredModuleWith {
|
||||
staticModules = [ ../../inventory/build-inventory/meta-interface.nix ];
|
||||
staticModules = [ ../inventoryClass/meta-interface.nix ];
|
||||
};
|
||||
default = { };
|
||||
};
|
||||
|
||||
@@ -42,38 +42,6 @@ let
|
||||
);
|
||||
|
||||
inherit (clan-core) clanLib;
|
||||
inventoryClass =
|
||||
let
|
||||
localModuleSet =
|
||||
lib.filterAttrs (n: _: !inventory._legacyModules ? ${n}) inventory.modules // config.modules;
|
||||
flakeInputs = config.self.inputs;
|
||||
in
|
||||
{
|
||||
_module.args = {
|
||||
inherit clanLib;
|
||||
};
|
||||
imports = [
|
||||
../../inventory/build-inventory/builder/default.nix
|
||||
(lib.modules.importApply ../../inventory/build-inventory/service-list-from-inputs.nix {
|
||||
inherit localModuleSet flakeInputs clanLib;
|
||||
})
|
||||
{
|
||||
inherit inventory directory;
|
||||
}
|
||||
(
|
||||
{ config, ... }:
|
||||
{
|
||||
distributedServices = clanLib.inventory.mapInstances {
|
||||
inherit (config) inventory;
|
||||
inherit localModuleSet flakeInputs;
|
||||
prefix = [ "distributedServices" ];
|
||||
};
|
||||
machines = config.distributedServices.allMachines;
|
||||
}
|
||||
)
|
||||
../../inventory/build-inventory/inventory-introspection.nix
|
||||
];
|
||||
};
|
||||
|
||||
moduleSystemConstructor = {
|
||||
# TODO: remove default system once we have a hardware-config mechanism
|
||||
@@ -81,7 +49,7 @@ let
|
||||
darwin = nix-darwin.lib.darwinSystem;
|
||||
};
|
||||
|
||||
allMachines = inventoryClass.machines; # <- inventory.machines <- clan.machines
|
||||
allMachines = config.clanInternals.inventoryClass.machines; # <- inventory.machines <- clan.machines
|
||||
|
||||
machineClasses = lib.mapAttrs (
|
||||
name: _: inventory.machines.${name}.machineClass or "nixos"
|
||||
@@ -238,7 +206,7 @@ in
|
||||
networking.hostName = lib.mkDefault name;
|
||||
}
|
||||
)
|
||||
) inventoryClass.machines)
|
||||
) config.clanInternals.inventoryClass.machines)
|
||||
|
||||
# The user can define some machine config here
|
||||
# i.e. 'clan.machines.jon = ...'
|
||||
@@ -260,7 +228,39 @@ in
|
||||
inherit darwinConfigurations;
|
||||
|
||||
clanInternals = {
|
||||
inherit inventoryClass;
|
||||
inventoryClass =
|
||||
let
|
||||
localModuleSet =
|
||||
lib.filterAttrs (n: _: !inventory._legacyModules ? ${n}) inventory.modules // config.modules;
|
||||
flakeInputs = config.self.inputs;
|
||||
in
|
||||
{
|
||||
_module.args = {
|
||||
inherit clanLib;
|
||||
};
|
||||
imports = [
|
||||
../inventoryClass/builder/default.nix
|
||||
(lib.modules.importApply ../inventoryClass/service-list-from-inputs.nix {
|
||||
inherit localModuleSet flakeInputs clanLib;
|
||||
})
|
||||
{
|
||||
inherit inventory directory;
|
||||
}
|
||||
(
|
||||
{ config, ... }:
|
||||
{
|
||||
distributedServices = clanLib.inventory.mapInstances {
|
||||
inherit (config) inventory;
|
||||
inherit localModuleSet flakeInputs;
|
||||
prefix = [ "distributedServices" ];
|
||||
};
|
||||
machines = config.distributedServices.allMachines;
|
||||
}
|
||||
)
|
||||
../inventoryClass/inventory-introspection.nix
|
||||
];
|
||||
};
|
||||
|
||||
# TODO: remove this after a month or so
|
||||
# This is here for backwards compatibility for older CLI versions
|
||||
inventory = config.inventory;
|
||||
|
||||
55
lib/modules/inventory/constraints/default.nix
Normal file
55
lib/modules/inventory/constraints/default.nix
Normal file
@@ -0,0 +1,55 @@
|
||||
{
|
||||
resolvedRoles,
|
||||
instanceName,
|
||||
moduleName,
|
||||
allRoles,
|
||||
}:
|
||||
{
|
||||
lib,
|
||||
config,
|
||||
...
|
||||
}:
|
||||
let
|
||||
inherit (config) roles;
|
||||
in
|
||||
{
|
||||
imports = [
|
||||
(lib.modules.importApply ./interface.nix { inherit allRoles; })
|
||||
# Role assertions
|
||||
{
|
||||
config.assertions = lib.foldlAttrs (
|
||||
ass: roleName: roleConstraints:
|
||||
let
|
||||
members = resolvedRoles.${roleName}.machines;
|
||||
memberCount = builtins.length members;
|
||||
# Checks
|
||||
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} members of the '${roleName}' role
|
||||
but found '${builtins.toString memberCount}' members within instance '${instanceName}':
|
||||
|
||||
${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} members of the '${roleName}' role
|
||||
but found '${builtins.toString memberCount}' members within instance '${instanceName}':
|
||||
|
||||
${lib.concatLines members}
|
||||
'';
|
||||
};
|
||||
};
|
||||
|
||||
in
|
||||
ass // maxCheck // minCheck
|
||||
) { } roles;
|
||||
}
|
||||
];
|
||||
}
|
||||
66
lib/modules/inventory/constraints/interface.nix
Normal file
66
lib/modules/inventory/constraints/interface.nix
Normal file
@@ -0,0 +1,66 @@
|
||||
{
|
||||
allRoles,
|
||||
}:
|
||||
{
|
||||
lib,
|
||||
...
|
||||
}:
|
||||
let
|
||||
inherit (lib) mkOption types;
|
||||
rolesAttrs = builtins.groupBy lib.id allRoles;
|
||||
in
|
||||
{
|
||||
options.roles = lib.mapAttrs (
|
||||
_name: _:
|
||||
mkOption {
|
||||
description = ''
|
||||
Sub-attributes of `${_name}` are constraints for the role.
|
||||
'';
|
||||
default = { };
|
||||
type = types.submoduleWith {
|
||||
modules = [
|
||||
{
|
||||
options = {
|
||||
max = mkOption {
|
||||
type = types.nullOr types.int;
|
||||
default = null;
|
||||
description = ''
|
||||
Maximum number of instances of this role that can be assigned to a module of this type.
|
||||
'';
|
||||
};
|
||||
min = mkOption {
|
||||
type = types.int;
|
||||
default = 0;
|
||||
description = ''
|
||||
Minimum number of instances of this role that must at least be assigned to a module of this type.
|
||||
'';
|
||||
};
|
||||
};
|
||||
}
|
||||
];
|
||||
};
|
||||
}
|
||||
) rolesAttrs;
|
||||
|
||||
# The resulting assertions
|
||||
options.assertions = mkOption {
|
||||
visible = false;
|
||||
default = { };
|
||||
type = types.attrsOf (
|
||||
types.submoduleWith {
|
||||
modules = [
|
||||
{
|
||||
options = {
|
||||
assertion = mkOption {
|
||||
type = types.bool;
|
||||
};
|
||||
message = mkOption {
|
||||
type = types.str;
|
||||
};
|
||||
};
|
||||
}
|
||||
];
|
||||
}
|
||||
);
|
||||
};
|
||||
}
|
||||
51
lib/modules/inventory/default.nix
Normal file
51
lib/modules/inventory/default.nix
Normal file
@@ -0,0 +1,51 @@
|
||||
{ lib, clanLib }:
|
||||
let
|
||||
services = clanLib.callLib ./distributed-service/inventory-adapter.nix { };
|
||||
in
|
||||
{
|
||||
inherit (services) evalClanService mapInstances resolveModule;
|
||||
inherit (import ../inventoryClass { inherit lib clanLib; }) buildInventory;
|
||||
interface = {
|
||||
_file = "clanLib.inventory.interface";
|
||||
imports = [
|
||||
../inventoryClass/interface.nix
|
||||
];
|
||||
_module.args = { inherit clanLib; };
|
||||
};
|
||||
# Returns the list of machine names
|
||||
# { ... } -> [ string ]
|
||||
resolveTags =
|
||||
{
|
||||
# Available InventoryMachines :: { {name} :: { tags = [ string ]; }; }
|
||||
machines,
|
||||
# Requested members :: { machines, tags }
|
||||
# Those will be resolved against the available machines
|
||||
members,
|
||||
# Not needed for resolution - only for error reporting
|
||||
roleName,
|
||||
instanceName,
|
||||
}:
|
||||
{
|
||||
machines =
|
||||
members.machines or [ ]
|
||||
++ (builtins.foldl' (
|
||||
acc: tag:
|
||||
let
|
||||
# For error printing
|
||||
availableTags = lib.foldlAttrs (
|
||||
acc: _: v:
|
||||
v.tags or [ ] ++ acc
|
||||
) [ ] (machines);
|
||||
|
||||
tagMembers = builtins.attrNames (lib.filterAttrs (_n: v: builtins.elem tag v.tags or [ ]) machines);
|
||||
in
|
||||
if tagMembers == [ ] then
|
||||
lib.warn ''
|
||||
Service instance '${instanceName}': - ${roleName} tags: no machine with tag '${tag}' found.
|
||||
Available tags: ${builtins.toJSON (lib.unique availableTags)}
|
||||
'' acc
|
||||
else
|
||||
acc ++ tagMembers
|
||||
) [ ] members.tags or [ ]);
|
||||
};
|
||||
}
|
||||
56
lib/modules/inventory/distributed-service/api-feature.nix
Normal file
56
lib/modules/inventory/distributed-service/api-feature.nix
Normal file
@@ -0,0 +1,56 @@
|
||||
# This module enables itself if
|
||||
# manifest.features.API = true
|
||||
# It converts the roles.interface to a json-schema
|
||||
{ clanLib, prefix }:
|
||||
let
|
||||
converter = clanLib.jsonschema {
|
||||
includeDefaults = true;
|
||||
};
|
||||
in
|
||||
{ lib, config, ... }:
|
||||
{
|
||||
options.result.api = lib.mkOption {
|
||||
visible = false;
|
||||
default = { };
|
||||
type = lib.types.submodule ({
|
||||
options.schema = lib.mkOption {
|
||||
description = ''
|
||||
The API schema for configuring the service.
|
||||
|
||||
Each 'role.<name>.interface' is converted to a json-schema.
|
||||
This can be used to generate and type check the API relevant objects.
|
||||
'';
|
||||
defaultText = lib.literalExpression ''
|
||||
{
|
||||
peer = { $schema" = "http://json-schema.org/draft-07/schema#"; ... }
|
||||
commuter = { $schema" = "http://json-schema.org/draft-07/schema#"; ... }
|
||||
distributor = { $schema" = "http://json-schema.org/draft-07/schema#"; ... }
|
||||
}
|
||||
'';
|
||||
default = lib.mapAttrs (_roleName: v: converter.parseModule v.interface) config.roles;
|
||||
};
|
||||
});
|
||||
};
|
||||
|
||||
config.result.assertions = (
|
||||
lib.mapAttrs' (roleName: _role: {
|
||||
name = "${roleName}";
|
||||
value = {
|
||||
# TODO: make the path to access the schema shorter
|
||||
message = ''
|
||||
`roles.${roleName}.interface` is not JSON serializable.
|
||||
|
||||
'clan.services' modules require all 'roles.*.interfaces' to be subset of JSON.
|
||||
|
||||
: clan.service module '${config.manifest.name}
|
||||
|
||||
To see the evaluation problem run
|
||||
|
||||
nix eval .#${lib.concatStringsSep "." prefix}.config.result.api.schema.${roleName}
|
||||
'';
|
||||
assertion = (builtins.tryEval (lib.deepSeq config.result.api.schema.${roleName} true)).success;
|
||||
};
|
||||
}) config.roles
|
||||
);
|
||||
|
||||
}
|
||||
35
lib/modules/inventory/distributed-service/flake-module.nix
Normal file
35
lib/modules/inventory/distributed-service/flake-module.nix
Normal file
@@ -0,0 +1,35 @@
|
||||
{ self, inputs, ... }:
|
||||
let
|
||||
inputOverrides = builtins.concatStringsSep " " (
|
||||
builtins.map (input: " --override-input ${input} ${inputs.${input}}") (builtins.attrNames inputs)
|
||||
);
|
||||
in
|
||||
{
|
||||
perSystem =
|
||||
{
|
||||
pkgs,
|
||||
lib,
|
||||
system,
|
||||
...
|
||||
}:
|
||||
{
|
||||
# Run: nix-unit --extra-experimental-features flakes --flake .#legacyPackages.x86_64-linux.<attrName>
|
||||
legacyPackages.evalTests-distributedServices = import ./tests {
|
||||
inherit lib;
|
||||
clanLib = self.clanLib;
|
||||
};
|
||||
|
||||
checks = {
|
||||
lib-distributedServices-eval = pkgs.runCommand "tests" { nativeBuildInputs = [ pkgs.nix-unit ]; } ''
|
||||
export HOME="$(realpath .)"
|
||||
nix-unit --eval-store "$HOME" \
|
||||
--extra-experimental-features flakes \
|
||||
--show-trace \
|
||||
${inputOverrides} \
|
||||
--flake ${self}#legacyPackages.${system}.evalTests-distributedServices
|
||||
|
||||
touch $out
|
||||
'';
|
||||
};
|
||||
};
|
||||
}
|
||||
205
lib/modules/inventory/distributed-service/inventory-adapter.nix
Normal file
205
lib/modules/inventory/distributed-service/inventory-adapter.nix
Normal file
@@ -0,0 +1,205 @@
|
||||
# Adapter function between the inventory.instances and the clan.service module
|
||||
#
|
||||
# Data flow:
|
||||
# - inventory.instances -> Adapter -> clan.service module -> Service Resources (i.e. NixosModules per Machine, Vars per Service, etc.)
|
||||
#
|
||||
# What this file does:
|
||||
#
|
||||
# - Resolves the [Module] to an actual module-path and imports it.
|
||||
# - Groups together all the same modules into a single import and creates all instances for it.
|
||||
# - Resolves the inventory tags into machines. Tags don't exist at the service level.
|
||||
# Also combines the settings for 'machines' and 'tags'.
|
||||
{
|
||||
lib,
|
||||
clanLib,
|
||||
...
|
||||
}:
|
||||
let
|
||||
evalClanService =
|
||||
{ modules, prefix }:
|
||||
(lib.evalModules {
|
||||
class = "clan.service";
|
||||
specialArgs._ctx = prefix;
|
||||
modules = [
|
||||
./service-module.nix
|
||||
# feature modules
|
||||
(lib.modules.importApply ./api-feature.nix {
|
||||
inherit clanLib prefix;
|
||||
})
|
||||
] ++ modules;
|
||||
});
|
||||
|
||||
resolveModule =
|
||||
{
|
||||
moduleSpec,
|
||||
flakeInputs,
|
||||
localModuleSet,
|
||||
}:
|
||||
let
|
||||
# TODO:
|
||||
resolvedModuleSet =
|
||||
# If the module.name is self then take the modules defined in the flake
|
||||
# Otherwise its an external input which provides the modules via 'clan.modules' attribute
|
||||
if moduleSpec.input == null then
|
||||
localModuleSet
|
||||
else
|
||||
let
|
||||
input =
|
||||
flakeInputs.${moduleSpec.input} or (throw ''
|
||||
Flake doesn't provide input with name '${moduleSpec.input}'
|
||||
|
||||
Choose one of the following inputs:
|
||||
- ${
|
||||
builtins.concatStringsSep "\n- " (
|
||||
lib.attrNames (lib.filterAttrs (_name: input: input ? clan) flakeInputs)
|
||||
)
|
||||
}
|
||||
|
||||
To import a local module from 'clan.modules' remove the 'input' attribute from the module definition
|
||||
Remove the following line from the module definition:
|
||||
|
||||
...
|
||||
- module.input = "${moduleSpec.input}"
|
||||
|
||||
'');
|
||||
clanAttrs =
|
||||
input.clan
|
||||
or (throw "It seems the flake input ${moduleSpec.input} doesn't export any clan resources");
|
||||
in
|
||||
clanAttrs.modules;
|
||||
|
||||
resolvedModule =
|
||||
resolvedModuleSet.${moduleSpec.name}
|
||||
or (throw "flake doesn't provide clan-module with name ${moduleSpec.name}");
|
||||
in
|
||||
resolvedModule;
|
||||
in
|
||||
{
|
||||
inherit evalClanService resolveModule;
|
||||
mapInstances =
|
||||
{
|
||||
# This is used to resolve the module imports from 'flake.inputs'
|
||||
flakeInputs,
|
||||
# The clan inventory
|
||||
inventory,
|
||||
localModuleSet,
|
||||
prefix ? [ ],
|
||||
}:
|
||||
let
|
||||
# machineHasTag = machineName: tagName: lib.elem tagName inventory.machines.${machineName}.tags;
|
||||
|
||||
# map the instances into the module
|
||||
importedModuleWithInstances = lib.mapAttrs (
|
||||
instanceName: instance:
|
||||
let
|
||||
resolvedModule = resolveModule {
|
||||
moduleSpec = instance.module;
|
||||
inherit localModuleSet;
|
||||
inherit flakeInputs;
|
||||
};
|
||||
|
||||
# Every instance includes machines via roles
|
||||
# :: { client :: ... }
|
||||
instanceRoles = lib.mapAttrs (
|
||||
roleName: role:
|
||||
let
|
||||
resolvedMachines = clanLib.inventory.resolveTags {
|
||||
members = {
|
||||
# Explicit members
|
||||
machines = lib.attrNames role.machines;
|
||||
# Resolved Members
|
||||
tags = lib.attrNames role.tags;
|
||||
};
|
||||
inherit (inventory) machines;
|
||||
inherit instanceName roleName;
|
||||
};
|
||||
in
|
||||
# instances.<instanceName>.roles.<roleName> =
|
||||
# Remove "tags", they are resolved into "machines"
|
||||
(removeAttrs role [ "tags" ])
|
||||
// {
|
||||
machines = lib.genAttrs resolvedMachines.machines (
|
||||
machineName:
|
||||
let
|
||||
machineSettings = instance.roles.${roleName}.machines.${machineName}.settings or { };
|
||||
in
|
||||
# TODO: tag settings
|
||||
# Wait for this feature until option introspection for 'settings' is done.
|
||||
# This might get too complex to handle otherwise.
|
||||
# settingsViaTags = lib.filterAttrs (
|
||||
# tagName: _: machineHasTag machineName tagName
|
||||
# ) instance.roles.${roleName}.tags;
|
||||
{
|
||||
# TODO: Do we want to wrap settings with
|
||||
# setDefaultModuleLocation "inventory.instances.${instanceName}.roles.${roleName}.tags.${tagName}";
|
||||
settings = {
|
||||
imports = [
|
||||
machineSettings
|
||||
]; # ++ lib.attrValues (lib.mapAttrs (_tagName: v: v.settings) settingsViaTags);
|
||||
};
|
||||
}
|
||||
);
|
||||
}
|
||||
) instance.roles;
|
||||
in
|
||||
{
|
||||
inherit (instance) module;
|
||||
inherit resolvedModule instanceRoles;
|
||||
}
|
||||
) inventory.instances or { };
|
||||
|
||||
# TODO: Eagerly check the _class of the resolved module
|
||||
importedModulesEvaluated = lib.mapAttrs (
|
||||
module_ident: instances:
|
||||
evalClanService {
|
||||
prefix = prefix ++ [ module_ident ];
|
||||
modules =
|
||||
[
|
||||
# Import the resolved module.
|
||||
# i.e. clan.modules.admin
|
||||
(builtins.head instances).instance.resolvedModule
|
||||
] # Include all the instances that correlate to the resolved module
|
||||
++ (builtins.map (v: {
|
||||
instances.${v.instanceName}.roles = v.instance.instanceRoles;
|
||||
}) instances);
|
||||
}
|
||||
) grouped;
|
||||
|
||||
# Group the instances by the module they resolve to
|
||||
# This is necessary to evaluate the module in a single pass
|
||||
# :: { <module.input>_<module.name> :: [ { name, value } ] }
|
||||
# Since 'perMachine' needs access to all the instances we should include them as a whole
|
||||
grouped = lib.foldlAttrs (
|
||||
acc: instanceName: instance:
|
||||
let
|
||||
inputName = if instance.module.input == null then "self" else instance.module.input;
|
||||
id = inputName + "-" + instance.module.name;
|
||||
in
|
||||
acc
|
||||
// {
|
||||
${id} = acc.${id} or [ ] ++ [
|
||||
{
|
||||
inherit instanceName instance;
|
||||
}
|
||||
];
|
||||
}
|
||||
) { } importedModuleWithInstances;
|
||||
|
||||
allMachines = lib.mapAttrs (machineName: _: {
|
||||
# This is the list of nixosModules for each machine
|
||||
machineImports = lib.foldlAttrs (
|
||||
acc: _module_ident: eval:
|
||||
acc ++ [ eval.config.result.final.${machineName}.nixosModule or { } ]
|
||||
) [ ] importedModulesEvaluated;
|
||||
}) inventory.machines or { };
|
||||
in
|
||||
{
|
||||
inherit
|
||||
importedModuleWithInstances
|
||||
grouped
|
||||
allMachines
|
||||
importedModulesEvaluated
|
||||
;
|
||||
};
|
||||
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
{ lib, ... }:
|
||||
let
|
||||
inherit (lib) mkOption;
|
||||
inherit (lib) types;
|
||||
in
|
||||
{
|
||||
options = {
|
||||
name = mkOption {
|
||||
description = ''
|
||||
The name of the module
|
||||
|
||||
Mainly used to create an error context while evaluating.
|
||||
This helps backtracking which module was included; And where an error came from originally.
|
||||
'';
|
||||
type = types.str;
|
||||
};
|
||||
description = mkOption {
|
||||
type = types.str;
|
||||
description = ''
|
||||
A Short description of the module.
|
||||
'';
|
||||
default = "No description";
|
||||
};
|
||||
readme = mkOption {
|
||||
type = types.str;
|
||||
description = ''
|
||||
Extended usage description
|
||||
'';
|
||||
default = "";
|
||||
};
|
||||
categories = mkOption {
|
||||
default = [ "Uncategorized" ];
|
||||
description = ''
|
||||
Categories are used for Grouping and searching.
|
||||
|
||||
While initial oriented on [freedesktop](https://specifications.freedesktop.org/menu-spec/latest/category-registry.html) the following categories are allowed
|
||||
'';
|
||||
type = types.listOf (
|
||||
types.enum [
|
||||
"AudioVideo"
|
||||
"Audio"
|
||||
"Video"
|
||||
"Development"
|
||||
"Education"
|
||||
"Game"
|
||||
"Graphics"
|
||||
"Social"
|
||||
"Network"
|
||||
"Office"
|
||||
"Science"
|
||||
"System"
|
||||
"Settings"
|
||||
"Utility"
|
||||
"Uncategorized"
|
||||
]
|
||||
);
|
||||
};
|
||||
|
||||
features = mkOption {
|
||||
description = ''
|
||||
Enable built-in features for the module
|
||||
|
||||
See the documentation for each feature:
|
||||
- API
|
||||
'';
|
||||
type = types.submoduleWith {
|
||||
modules = [
|
||||
{
|
||||
options.API = mkOption {
|
||||
type = types.bool;
|
||||
# This is read only, because we don't support turning it off yet
|
||||
readOnly = true;
|
||||
default = true;
|
||||
description = ''
|
||||
Enables automatic API schema conversion for the interface of this module.
|
||||
'';
|
||||
};
|
||||
}
|
||||
];
|
||||
};
|
||||
default = { };
|
||||
};
|
||||
};
|
||||
}
|
||||
813
lib/modules/inventory/distributed-service/service-module.nix
Normal file
813
lib/modules/inventory/distributed-service/service-module.nix
Normal file
@@ -0,0 +1,813 @@
|
||||
{
|
||||
lib,
|
||||
config,
|
||||
_ctx,
|
||||
...
|
||||
}:
|
||||
let
|
||||
inherit (lib) mkOption types;
|
||||
inherit (types) attrsWith submoduleWith;
|
||||
|
||||
errorContext = "Error context: ${lib.concatStringsSep "." _ctx}";
|
||||
# TODO:
|
||||
# Remove once this gets merged upstream; performs in O(n*log(n) instead of O(n^2))
|
||||
# https://github.com/NixOS/nixpkgs/pull/355616/files
|
||||
uniqueStrings = list: builtins.attrNames (builtins.groupBy lib.id list);
|
||||
/**
|
||||
Merges the role- and machine-settings using the role interface
|
||||
|
||||
Arguments:
|
||||
|
||||
- roleName: The name of the role
|
||||
- instanceName: The name of the instance
|
||||
- settings: The settings of the machine. Leave empty to get the role settings
|
||||
|
||||
Returns: evalModules result
|
||||
|
||||
The caller is responsible to use .config or .extendModules
|
||||
*/
|
||||
evalMachineSettings =
|
||||
{
|
||||
roleName,
|
||||
instanceName,
|
||||
machineName ? null,
|
||||
settings,
|
||||
}:
|
||||
lib.evalModules {
|
||||
# Prefix for better error reporting
|
||||
# This prints the path where the option should be defined rather than the plain path within settings
|
||||
# "The option `instances.foo.roles.server.machines.test.settings.<>' was accessed but has no value defined. Try setting the option."
|
||||
prefix =
|
||||
_ctx
|
||||
++ [
|
||||
"instances"
|
||||
instanceName
|
||||
"roles"
|
||||
roleName
|
||||
]
|
||||
++ (lib.optionals (machineName != null) [
|
||||
"machines"
|
||||
machineName
|
||||
])
|
||||
++ [ "settings" ];
|
||||
|
||||
# This may lead to better error reporting
|
||||
# And catch errors if anyone tried to import i.e. a nixosConfiguration
|
||||
# Set some class: i.e "network.server.settings"
|
||||
class = lib.concatStringsSep "." [
|
||||
config.manifest.name
|
||||
roleName
|
||||
"settings"
|
||||
];
|
||||
|
||||
modules = [
|
||||
(lib.setDefaultModuleLocation "Via clan.service module: roles.${roleName}.interface"
|
||||
config.roles.${roleName}.interface
|
||||
)
|
||||
(lib.setDefaultModuleLocation "instances.${instanceName}.roles.${roleName}.settings"
|
||||
config.instances.${instanceName}.roles.${roleName}.settings
|
||||
)
|
||||
settings
|
||||
# Dont set the module location here
|
||||
# This should already be set by the tags resolver
|
||||
# config.instances.${instanceName}.roles.${roleName}.machines.${machineName}.settings
|
||||
];
|
||||
};
|
||||
|
||||
# Extend evalModules result by a module, returns .config.
|
||||
extendEval = eval: m: (eval.extendModules { modules = lib.toList m; }).config;
|
||||
|
||||
/**
|
||||
Apply the settings to the instance
|
||||
|
||||
Takes a [ServiceInstance] :: { roles :: { roleName :: { machines :: { machineName :: { settings :: { ... } } } } } }
|
||||
Returns the same object but evaluates the settings against the interface.
|
||||
|
||||
We need this because 'perMachine' shouldn't gain access the raw deferred module.
|
||||
*/
|
||||
applySettings =
|
||||
instanceName: instance:
|
||||
lib.mapAttrs (roleName: role: {
|
||||
machines = lib.mapAttrs (machineName: v: {
|
||||
settings =
|
||||
(evalMachineSettings {
|
||||
inherit roleName instanceName machineName;
|
||||
inherit (v) settings;
|
||||
}).config;
|
||||
}) role.machines;
|
||||
settings =
|
||||
(evalMachineSettings {
|
||||
inherit roleName instanceName;
|
||||
inherit (role) settings;
|
||||
}).config;
|
||||
}) instance.roles;
|
||||
in
|
||||
{
|
||||
options = {
|
||||
instances = mkOption {
|
||||
visible = false;
|
||||
defaultText = "Throws: 'The service must define its instances' when not defined";
|
||||
default = throw ''
|
||||
The clan service module ${config.manifest.name} doesn't define any instances.
|
||||
|
||||
Did you forget to create instances via 'instances'?
|
||||
|
||||
${errorContext}
|
||||
'';
|
||||
description = ''
|
||||
Instances of the service.
|
||||
|
||||
An Instance is a user-specific deployment or configuration of a service.
|
||||
It represents the active usage of the service configured to the user's settings or use case.
|
||||
The `<instanceName>` of the instance is arbitrary, but must be unique.
|
||||
|
||||
A common best practice is to name the instance after the 'service' and the 'use-case'.
|
||||
|
||||
For example:
|
||||
|
||||
- 'instances.zerotier-homelab = ...' for a zerotier instance that connects all machines of a homelab
|
||||
'';
|
||||
|
||||
type = attrsWith {
|
||||
placeholder = "instanceName";
|
||||
elemType = submoduleWith {
|
||||
modules = [
|
||||
(
|
||||
{ name, ... }:
|
||||
{
|
||||
options.roles = mkOption {
|
||||
description = ''
|
||||
Roles of the instance.
|
||||
|
||||
A role is a specific behavior or configuration of the service.
|
||||
It defines how the service should behave in the context of this instance.
|
||||
The `<roleName>` must match one of the roles defined in the service
|
||||
|
||||
For example:
|
||||
|
||||
- 'roles.client = ...' for a client role that connects to the service
|
||||
- 'roles.server = ...' for a server role that provides the service
|
||||
|
||||
Throws an error if empty, since this would mean that the service has no members.
|
||||
'';
|
||||
defaultText = "Throws: 'The service must define members via roles' when not defined";
|
||||
default = throw ''
|
||||
Instance '${name}' of service '${config.manifest.name}' mut define members via 'roles'.
|
||||
|
||||
To include a machine:
|
||||
'instances.${name}.roles.<role-name>.machines.<machine-name>' must be set.
|
||||
|
||||
${errorContext}
|
||||
'';
|
||||
type = attrsWith {
|
||||
placeholder = "roleName";
|
||||
elemType = submoduleWith {
|
||||
modules = [
|
||||
({
|
||||
# instances.{instanceName}.roles.{roleName}.machines
|
||||
options.machines = mkOption {
|
||||
description = ''
|
||||
Machines of the role.
|
||||
|
||||
A machine is a physical or virtual machine that is part of the instance.
|
||||
The `<machineName>` must match the name of any machine defined in the clan.
|
||||
|
||||
For example:
|
||||
|
||||
- 'machines.my-machine = { ...; }' for a machine that is part of the instance
|
||||
- 'machines.my-other-machine = { ...; }' for another machine that is part of the instance
|
||||
'';
|
||||
type = attrsWith {
|
||||
placeholder = "machineName";
|
||||
elemType = submoduleWith {
|
||||
modules = [
|
||||
(m: {
|
||||
options.settings = mkOption {
|
||||
type = types.raw;
|
||||
description = "Settings of '${name}-machine': ${m.name or "<machineName>"}.";
|
||||
default = { };
|
||||
};
|
||||
})
|
||||
];
|
||||
};
|
||||
};
|
||||
};
|
||||
|
||||
# instances.{instanceName}.roles.{roleName}.settings
|
||||
# options._settings = mkOption { };
|
||||
# options._settingsViaTags = mkOption { };
|
||||
# A deferred module that combines _settingsViaTags with _settings
|
||||
options.settings = mkOption {
|
||||
type = types.raw;
|
||||
description = "Settings of 'role': ${name}";
|
||||
default = { };
|
||||
};
|
||||
|
||||
options.extraModules = lib.mkOption {
|
||||
default = [ ];
|
||||
type = types.listOf (types.deferredModule);
|
||||
};
|
||||
})
|
||||
];
|
||||
};
|
||||
};
|
||||
apply =
|
||||
v:
|
||||
lib.seq (
|
||||
(
|
||||
|
||||
instanceName: instanceRoles:
|
||||
let
|
||||
unmatchedRoles = lib.filter (roleName: !lib.elem roleName (lib.attrNames config.roles)) (
|
||||
lib.attrNames instanceRoles
|
||||
);
|
||||
in
|
||||
if unmatchedRoles == [ ] then
|
||||
true
|
||||
else
|
||||
throw ''
|
||||
Instance: 'instances.${instanceName}' uses the following roles:
|
||||
${builtins.toJSON unmatchedRoles}
|
||||
|
||||
But the clan-service module '${config.manifest.name}' only defines roles:
|
||||
${builtins.toJSON (lib.attrNames config.roles)}
|
||||
|
||||
${errorContext}
|
||||
''
|
||||
|
||||
)
|
||||
name
|
||||
v
|
||||
) v;
|
||||
};
|
||||
}
|
||||
)
|
||||
];
|
||||
};
|
||||
};
|
||||
};
|
||||
|
||||
manifest = mkOption {
|
||||
description = "Meta information about this module itself";
|
||||
type = submoduleWith {
|
||||
modules = [
|
||||
./manifest/default.nix
|
||||
];
|
||||
};
|
||||
};
|
||||
roles = mkOption {
|
||||
description = ''
|
||||
Roles of the service.
|
||||
|
||||
A role is a specific behavior or configuration of the service.
|
||||
It defines how the service should behave in the context of the clan.
|
||||
|
||||
The `<roleName>`s of the service are defined here. Later usage of the roles must match one of the `roleNames`.
|
||||
|
||||
For example:
|
||||
|
||||
- 'roles.client = ...' for a client role that connects to the service
|
||||
- 'roles.server = ...' for a server role that provides the service
|
||||
|
||||
Throws an error if empty, since this would mean that the service has no way of adding members.
|
||||
'';
|
||||
defaultText = "Throws: 'The service must define its roles' when not defined";
|
||||
default = throw ''
|
||||
Role behavior of service '${config.manifest.name}' must be defined.
|
||||
A 'clan.service' module should always define its behavior via 'roles'
|
||||
---
|
||||
To add the role:
|
||||
`roles.client = {}`
|
||||
|
||||
To define multiple instance behavior:
|
||||
`roles.client.perInstance = { ... }: {}`
|
||||
|
||||
${errorContext}
|
||||
'';
|
||||
type = attrsWith {
|
||||
placeholder = "roleName";
|
||||
elemType = submoduleWith {
|
||||
modules = [
|
||||
(
|
||||
{ name, ... }:
|
||||
let
|
||||
roleName = name;
|
||||
in
|
||||
{
|
||||
options.interface = mkOption {
|
||||
description = ''
|
||||
Abstract interface of the role.
|
||||
|
||||
This is an abstract module which should define 'options' for the role's settings.
|
||||
|
||||
Example:
|
||||
|
||||
```nix
|
||||
{
|
||||
options.timeout = mkOption {
|
||||
type = types.int;
|
||||
default = 30;
|
||||
description = "Timeout in seconds";
|
||||
};
|
||||
}
|
||||
```
|
||||
|
||||
Note:
|
||||
|
||||
- `machine.config` is not available here, since the role is definition is abstract.
|
||||
- *defaults* that depend on the *machine* or *instance* should be added to *settings* later in 'perInstance' or 'perMachine'
|
||||
'';
|
||||
type = types.deferredModule;
|
||||
default = { };
|
||||
};
|
||||
options.perInstance = mkOption {
|
||||
description = ''
|
||||
Per-instance configuration of the role.
|
||||
|
||||
This option is used to define instance-specific behavior for the service-role. (Example below)
|
||||
|
||||
Although the type is a `deferredModule`, it helps to think of it as a function.
|
||||
The 'function' takes the `instance-name` and some other `arguments`.
|
||||
|
||||
*Arguments*:
|
||||
|
||||
- `instanceName` (`string`): The name of the instance.
|
||||
- `machine`: Machine information, containing:
|
||||
```nix
|
||||
{
|
||||
name = "machineName";
|
||||
roles = ["client" "server" ... ];
|
||||
}
|
||||
```
|
||||
- `roles`: Attribute set of all roles of the instance, in the form:
|
||||
```nix
|
||||
roles = {
|
||||
client = {
|
||||
machines = {
|
||||
jon = {
|
||||
settings = {
|
||||
timeout = 60;
|
||||
};
|
||||
};
|
||||
# ...
|
||||
};
|
||||
settings = {
|
||||
timeout = 30;
|
||||
};
|
||||
};
|
||||
# ...
|
||||
};
|
||||
```
|
||||
|
||||
- `settings`: The settings of the role, as defined in `instances`
|
||||
```nix
|
||||
{
|
||||
timeout = 30;
|
||||
}
|
||||
```
|
||||
- `extendSettings`: A function that takes a module and returns a new module with extended settings.
|
||||
```nix
|
||||
extendSettings {
|
||||
timeout = mkForce 60;
|
||||
};
|
||||
->
|
||||
{
|
||||
timeout = 60;
|
||||
}
|
||||
```
|
||||
|
||||
*Returns* an `attribute set` containing:
|
||||
|
||||
- `nixosModule`: The NixOS module for the instance.
|
||||
|
||||
'';
|
||||
type = types.deferredModuleWith {
|
||||
staticModules = [
|
||||
({
|
||||
options.nixosModule = mkOption {
|
||||
type = types.deferredModule;
|
||||
default = { };
|
||||
description = ''
|
||||
This module is later imported to configure the machine with the config derived from service's settings.
|
||||
|
||||
Example:
|
||||
|
||||
```nix
|
||||
roles.client.perInstance = { instanceName, ... }:
|
||||
{
|
||||
# Keep in mind that this module is produced once per-instance
|
||||
# Meaning you might end up with multiple of these modules.
|
||||
# Make sure they can be imported all together without conflicts
|
||||
#
|
||||
# ↓ nixos-config
|
||||
nixosModule = { config ,... }: {
|
||||
# create one systemd service per instance
|
||||
# It is a common practice to concatenate the *service-name* and *instance-name*
|
||||
# To ensure globally unique systemd-units for the target machine
|
||||
systemd.services."webly-''${instanceName}" = {
|
||||
...
|
||||
};
|
||||
};
|
||||
}
|
||||
```
|
||||
'';
|
||||
};
|
||||
options.services = mkOption {
|
||||
visible = false;
|
||||
type = attrsWith {
|
||||
placeholder = "serviceName";
|
||||
elemType = submoduleWith {
|
||||
modules = [
|
||||
{
|
||||
_module.args._ctx = _ctx ++ [
|
||||
config.manifest.name
|
||||
"roles"
|
||||
roleName
|
||||
"perInstance"
|
||||
"services"
|
||||
];
|
||||
}
|
||||
./service-module.nix
|
||||
];
|
||||
};
|
||||
};
|
||||
default = { };
|
||||
};
|
||||
})
|
||||
];
|
||||
};
|
||||
default = { };
|
||||
apply =
|
||||
/**
|
||||
This apply transforms the module into a function that takes arguments and returns an evaluated module
|
||||
The arguments of the function are determined by its scope:
|
||||
-> 'perInstance' maps over all instances and over all machines hence it takes 'instanceName' and 'machineName' as iterator arguments
|
||||
*/
|
||||
v: instanceName: machineName:
|
||||
(lib.evalModules {
|
||||
specialArgs =
|
||||
let
|
||||
roles = applySettings instanceName config.instances.${instanceName};
|
||||
in
|
||||
{
|
||||
inherit instanceName roles;
|
||||
machine = {
|
||||
name = machineName;
|
||||
roles = lib.attrNames (lib.filterAttrs (_n: v: v.machines ? ${machineName}) roles);
|
||||
};
|
||||
settings =
|
||||
(evalMachineSettings {
|
||||
inherit roleName instanceName machineName;
|
||||
settings =
|
||||
config.instances.${instanceName}.roles.${roleName}.machines.${machineName}.settings or { };
|
||||
}).config;
|
||||
extendSettings = extendEval (evalMachineSettings {
|
||||
inherit roleName instanceName machineName;
|
||||
settings =
|
||||
config.instances.${instanceName}.roles.${roleName}.machines.${machineName}.settings or { };
|
||||
});
|
||||
};
|
||||
modules = [ v ];
|
||||
}).config;
|
||||
};
|
||||
}
|
||||
)
|
||||
];
|
||||
};
|
||||
};
|
||||
};
|
||||
|
||||
perMachine = mkOption {
|
||||
description = ''
|
||||
Per-machine configuration of the service.
|
||||
|
||||
This option is used to define machine-specific settings for the service **once**, if any service-instance is used.
|
||||
|
||||
Although the type is a `deferredModule`, it helps to think of it as a function.
|
||||
The 'function' takes the `machine-name` and some other 'arguments'
|
||||
|
||||
*Arguments*:
|
||||
|
||||
- `machine`: `{ name :: string; roles :: listOf String }`
|
||||
- `instances`: The scope of the machine, containing all instances and roles that the machine is part of.
|
||||
```nix
|
||||
{
|
||||
instances = {
|
||||
<instanceName> = {
|
||||
roles = {
|
||||
<roleName> = {
|
||||
# Per-machine settings
|
||||
machines = { <machineName> = { settings = { ... }; }; }; };
|
||||
# Per-role settings
|
||||
settings = { ... };
|
||||
};
|
||||
};
|
||||
};
|
||||
}
|
||||
```
|
||||
|
||||
*Returns* an `attribute set` containing:
|
||||
|
||||
- `nixosModule`: The NixOS module for the machine.
|
||||
|
||||
'';
|
||||
type = types.deferredModuleWith {
|
||||
staticModules = [
|
||||
({
|
||||
options.nixosModule = mkOption {
|
||||
type = types.deferredModule;
|
||||
default = { };
|
||||
description = ''
|
||||
A single NixOS module for the machine.
|
||||
|
||||
This module is later imported to configure the machine with the config derived from service's settings.
|
||||
|
||||
Example:
|
||||
|
||||
```nix
|
||||
# ↓ machine.roles ...
|
||||
perMachine = { machine, ... }:
|
||||
{ # ↓ nixos-config
|
||||
nixosModule = { config ,... }: {
|
||||
systemd.services.foo = {
|
||||
enable = true;
|
||||
};
|
||||
}
|
||||
}
|
||||
```
|
||||
'';
|
||||
};
|
||||
options.services = mkOption {
|
||||
visible = false;
|
||||
type = attrsWith {
|
||||
placeholder = "serviceName";
|
||||
elemType = submoduleWith {
|
||||
modules = [
|
||||
{
|
||||
_module.args._ctx = _ctx ++ [
|
||||
config.manifest.name
|
||||
"perMachine"
|
||||
"services"
|
||||
];
|
||||
}
|
||||
./service-module.nix
|
||||
];
|
||||
};
|
||||
};
|
||||
default = { };
|
||||
};
|
||||
})
|
||||
];
|
||||
};
|
||||
default = { };
|
||||
apply =
|
||||
v: machineName: machineScope:
|
||||
(lib.evalModules {
|
||||
specialArgs = {
|
||||
/**
|
||||
This apply transforms the module into a function that takes arguments and returns an evaluated module
|
||||
The arguments of the function are determined by its scope:
|
||||
-> 'perMachine' maps over all machines of a service 'machineName' and a helper 'scope' (some aggregated attributes) as iterator arguments
|
||||
The 'scope' attribute is used to collect the 'roles' of all 'instances' where the machine is part of and inject both into the specialArgs
|
||||
*/
|
||||
machine = {
|
||||
name = machineName;
|
||||
roles =
|
||||
let
|
||||
collectRoles =
|
||||
instances:
|
||||
lib.foldlAttrs (
|
||||
r: _instanceName: instance:
|
||||
r
|
||||
++ lib.foldlAttrs (
|
||||
r2: roleName: _role:
|
||||
r2 ++ [ roleName ]
|
||||
) [ ] instance.roles
|
||||
) [ ] instances;
|
||||
in
|
||||
uniqueStrings (collectRoles machineScope.instances);
|
||||
};
|
||||
inherit (machineScope) instances;
|
||||
|
||||
# There are no machine settings.
|
||||
# Settings are always role specific, having settings that apply to a machine globally would mean to merge all role and all instance settings into a single module.
|
||||
# But that will likely cause conflicts because it is inherently wrong.
|
||||
settings = throw ''
|
||||
'perMachine' doesn't have a 'settings' argument.
|
||||
|
||||
Alternatives:
|
||||
- 'instances.<instanceName>.roles.<roleName>.settings' should be used instead.
|
||||
- 'instances.<instanceName>.roles.<roleName>.machines.<machineName>.settings' should be used instead.
|
||||
|
||||
If that is insufficient, you might also consider using 'roles.<roleName>.perInstance' instead of 'perMachine'.
|
||||
|
||||
${errorContext}
|
||||
'';
|
||||
};
|
||||
|
||||
modules = [ v ];
|
||||
}).config;
|
||||
};
|
||||
# ---
|
||||
# Place the result in _module.result to mark them as "internal" and discourage usage/overrides
|
||||
#
|
||||
# ---
|
||||
# Intermediate result by mapping over the 'roles', 'instances', and 'machines'.
|
||||
# During this step the 'perMachine' and 'perInstance' are applied.
|
||||
# The result-set for a single machine can then be found by collecting all 'nixosModules' recursively.
|
||||
|
||||
/**
|
||||
allRoles :: {
|
||||
<roleName> :: {
|
||||
allInstances :: {
|
||||
<instanceName> :: {
|
||||
allMachines :: {
|
||||
<machineName> :: {
|
||||
nixosModule :: NixOSModule;
|
||||
services :: { };
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
}
|
||||
*/
|
||||
result.allRoles = mkOption {
|
||||
visible = false;
|
||||
readOnly = true;
|
||||
default = lib.mapAttrs (roleName: roleCfg: {
|
||||
allInstances = lib.mapAttrs (instanceName: instanceCfg: {
|
||||
allMachines = lib.mapAttrs (
|
||||
machineName: _machineCfg:
|
||||
let
|
||||
instanceRes = roleCfg.perInstance instanceName machineName;
|
||||
in
|
||||
instanceRes
|
||||
// {
|
||||
nixosModule = {
|
||||
imports = [
|
||||
# Result of the applied 'perInstance = {...}: { nixosModule = { ... }; }'
|
||||
instanceRes.nixosModule
|
||||
] ++ instanceCfg.roles.${roleName}.extraModules;
|
||||
};
|
||||
}
|
||||
|
||||
) instanceCfg.roles.${roleName}.machines or { };
|
||||
}) config.instances;
|
||||
}) config.roles;
|
||||
};
|
||||
|
||||
result.assertions = mkOption {
|
||||
default = { };
|
||||
visible = false;
|
||||
type = types.attrsOf types.raw;
|
||||
};
|
||||
|
||||
# The result collected from 'perMachine'
|
||||
result.allMachines = mkOption {
|
||||
visible = false;
|
||||
readOnly = true;
|
||||
default =
|
||||
let
|
||||
collectMachinesFromInstance =
|
||||
instance:
|
||||
uniqueStrings (
|
||||
lib.foldlAttrs (
|
||||
acc: _roleName: role:
|
||||
acc ++ (lib.attrNames role.machines)
|
||||
) [ ] instance.roles
|
||||
);
|
||||
# The service machines are defined by collecting all instance machines
|
||||
# returns "allMachines" that are part of the service in the form:
|
||||
# serviceMachines :: { ${machineName} :: MachineOrigin; }
|
||||
# MachineOrigin :: { instances :: [ string ]; roles :: [ string ]; }
|
||||
serviceMachines = lib.foldlAttrs (
|
||||
acc: instanceName: instance:
|
||||
acc
|
||||
// lib.genAttrs (collectMachinesFromInstance instance) (machineName:
|
||||
# Store information why this machine is part of the service
|
||||
# MachineOrigin :: { instances :: [ string ]; }
|
||||
{
|
||||
# Helper attribute to
|
||||
instances = [ instanceName ] ++ acc.${machineName}.instances or [ ];
|
||||
# All roles of the machine ?
|
||||
roles = lib.foldlAttrs (
|
||||
acc2: roleName: role:
|
||||
if builtins.elem machineName (lib.attrNames role.machines) then acc2 ++ [ roleName ] else acc2
|
||||
) [ ] instance.roles;
|
||||
})
|
||||
) { } config.instances;
|
||||
|
||||
allMachines = lib.mapAttrs (_machineName: MachineOrigin: {
|
||||
# Filter out instances of which the machine is not part of
|
||||
instances = lib.mapAttrs (_n: v: { roles = v; }) (
|
||||
lib.filterAttrs (instanceName: _: builtins.elem instanceName MachineOrigin.instances) (
|
||||
# Instances with evaluated settings
|
||||
lib.mapAttrs applySettings config.instances
|
||||
)
|
||||
);
|
||||
}) serviceMachines;
|
||||
in
|
||||
# allMachines;
|
||||
lib.mapAttrs config.perMachine allMachines;
|
||||
};
|
||||
|
||||
result.final = mkOption {
|
||||
visible = false;
|
||||
readOnly = true;
|
||||
default = lib.mapAttrs (
|
||||
machineName: machineResult:
|
||||
let
|
||||
instanceResults =
|
||||
lib.foldlAttrs
|
||||
(
|
||||
roleAcc: roleName: role:
|
||||
roleAcc
|
||||
// lib.foldlAttrs (
|
||||
instanceAcc: instanceName: instance:
|
||||
instanceAcc
|
||||
// {
|
||||
nixosModules =
|
||||
(
|
||||
(lib.mapAttrsToList (
|
||||
nestedServiceName: serviceModule:
|
||||
let
|
||||
unmatchedMachines = lib.attrNames (
|
||||
lib.removeAttrs serviceModule.result.final (lib.attrNames config.result.allMachines)
|
||||
);
|
||||
in
|
||||
if unmatchedMachines != [ ] then
|
||||
throw ''
|
||||
The following machines are not part of the parent service: ${builtins.toJSON unmatchedMachines}
|
||||
Either remove the machines, or include them into the parent via a role.
|
||||
(Added via roles.${roleName}.perInstance.services.${nestedServiceName})
|
||||
|
||||
${errorContext}
|
||||
''
|
||||
else
|
||||
serviceModule.result.final.${machineName}.nixosModule
|
||||
) instance.allMachines.${machineName}.services or { })
|
||||
|
||||
)
|
||||
++ (
|
||||
if instance.allMachines.${machineName}.nixosModule or { } != { } then
|
||||
instanceAcc.nixosModules
|
||||
++ [
|
||||
(lib.setDefaultModuleLocation
|
||||
"Via instances.${instanceName}.roles.${roleName}.machines.${machineName}"
|
||||
instance.allMachines.${machineName}.nixosModule
|
||||
)
|
||||
]
|
||||
else
|
||||
instanceAcc.nixosModules
|
||||
);
|
||||
}
|
||||
) roleAcc role.allInstances
|
||||
)
|
||||
{
|
||||
nixosModules = [ ];
|
||||
# ...
|
||||
}
|
||||
config.result.allRoles;
|
||||
in
|
||||
{
|
||||
inherit instanceResults machineResult;
|
||||
nixosModule = {
|
||||
imports =
|
||||
[
|
||||
# include service assertions:
|
||||
(
|
||||
let
|
||||
failedAssertions = (lib.filterAttrs (_: v: !v.assertion) config.result.assertions);
|
||||
in
|
||||
{
|
||||
assertions = lib.attrValues failedAssertions;
|
||||
}
|
||||
)
|
||||
(lib.setDefaultModuleLocation "Via ${config.manifest.name}.perMachine - machine='${machineName}';" machineResult.nixosModule)
|
||||
]
|
||||
++ (lib.mapAttrsToList (
|
||||
nestedServiceName: serviceModule:
|
||||
let
|
||||
unmatchedMachines = lib.attrNames (
|
||||
lib.removeAttrs serviceModule.result.final (lib.attrNames config.result.allMachines)
|
||||
);
|
||||
in
|
||||
if unmatchedMachines != [ ] then
|
||||
throw ''
|
||||
The following machines are not part of the parent service: ${builtins.toJSON unmatchedMachines}
|
||||
Either remove the machines, or include them into the parent via a role.
|
||||
(Added via perMachine.services.${nestedServiceName})
|
||||
|
||||
${errorContext}
|
||||
''
|
||||
else
|
||||
serviceModule.result.final.${machineName}.nixosModule
|
||||
) machineResult.services)
|
||||
++ instanceResults.nixosModules;
|
||||
};
|
||||
}
|
||||
) config.result.allMachines;
|
||||
};
|
||||
};
|
||||
}
|
||||
283
lib/modules/inventory/distributed-service/tests/default.nix
Normal file
283
lib/modules/inventory/distributed-service/tests/default.nix
Normal file
@@ -0,0 +1,283 @@
|
||||
{
|
||||
lib,
|
||||
clanLib,
|
||||
...
|
||||
}:
|
||||
let
|
||||
inherit (lib)
|
||||
evalModules
|
||||
;
|
||||
|
||||
evalInventory =
|
||||
m:
|
||||
(evalModules {
|
||||
# Static modules
|
||||
modules = [
|
||||
clanLib.inventory.interface
|
||||
{
|
||||
_file = "test file";
|
||||
tags.all = [ ];
|
||||
tags.nixos = [ ];
|
||||
tags.darwin = [ ];
|
||||
}
|
||||
{
|
||||
modules.test = { };
|
||||
}
|
||||
m
|
||||
];
|
||||
}).config;
|
||||
|
||||
flakeInputsFixture = {
|
||||
# Example upstream module
|
||||
upstream.clan.modules = {
|
||||
uzzi = {
|
||||
_class = "clan.service";
|
||||
manifest = {
|
||||
name = "uzzi-from-upstream";
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
|
||||
callInventoryAdapter =
|
||||
inventoryModule:
|
||||
let
|
||||
inventory = evalInventory inventoryModule;
|
||||
in
|
||||
clanLib.inventory.mapInstances {
|
||||
flakeInputs = flakeInputsFixture;
|
||||
inherit inventory;
|
||||
localModuleSet = inventory.modules;
|
||||
};
|
||||
in
|
||||
{
|
||||
resolve_module_spec = import ./import_module_spec.nix { inherit lib callInventoryAdapter; };
|
||||
test_simple =
|
||||
let
|
||||
res = callInventoryAdapter {
|
||||
# Authored module
|
||||
# A minimal module looks like this
|
||||
# It isn't exactly doing anything but it's a valid module that produces an output
|
||||
modules."simple-module" = {
|
||||
_class = "clan.service";
|
||||
manifest = {
|
||||
name = "netwitness";
|
||||
};
|
||||
};
|
||||
# User config
|
||||
instances."instance_foo" = {
|
||||
module = {
|
||||
name = "simple-module";
|
||||
};
|
||||
};
|
||||
};
|
||||
in
|
||||
{
|
||||
# Test that the module is mapped into the output
|
||||
# We might change the attribute name in the future
|
||||
expr = res.importedModulesEvaluated ? "self-simple-module";
|
||||
expected = true;
|
||||
inherit res;
|
||||
};
|
||||
|
||||
# A module can be imported multiple times
|
||||
# A module can also have multiple instances within the same module
|
||||
# This mean modules must be grouped together, imported once
|
||||
# All instances should be included within one evaluation to make all of them available
|
||||
test_module_grouping =
|
||||
let
|
||||
res = callInventoryAdapter {
|
||||
# Authored module
|
||||
# A minimal module looks like this
|
||||
# It isn't exactly doing anything but it's a valid module that produces an output
|
||||
modules."A" = {
|
||||
_class = "clan.service";
|
||||
manifest = {
|
||||
name = "A-name";
|
||||
};
|
||||
|
||||
perMachine = { }: { };
|
||||
};
|
||||
modules."B" = {
|
||||
_class = "clan.service";
|
||||
manifest = {
|
||||
name = "B-name";
|
||||
};
|
||||
|
||||
perMachine = { }: { };
|
||||
};
|
||||
# User config
|
||||
instances."instance_foo" = {
|
||||
module = {
|
||||
name = "A";
|
||||
};
|
||||
};
|
||||
instances."instance_bar" = {
|
||||
module = {
|
||||
name = "B";
|
||||
};
|
||||
};
|
||||
instances."instance_baz" = {
|
||||
module = {
|
||||
name = "A";
|
||||
};
|
||||
};
|
||||
};
|
||||
in
|
||||
{
|
||||
# Test that the module is mapped into the output
|
||||
# We might change the attribute name in the future
|
||||
expr = lib.mapAttrs (_n: v: builtins.length v) res.grouped;
|
||||
expected = {
|
||||
self-A = 2;
|
||||
self-B = 1;
|
||||
};
|
||||
};
|
||||
|
||||
test_creates_all_instances =
|
||||
let
|
||||
res = callInventoryAdapter {
|
||||
# Authored module
|
||||
# A minimal module looks like this
|
||||
# It isn't exactly doing anything but it's a valid module that produces an output
|
||||
modules."A" = {
|
||||
_class = "clan.service";
|
||||
manifest = {
|
||||
name = "network";
|
||||
};
|
||||
|
||||
perMachine = { }: { };
|
||||
};
|
||||
instances."instance_foo" = {
|
||||
module = {
|
||||
name = "A";
|
||||
};
|
||||
};
|
||||
instances."instance_bar" = {
|
||||
module = {
|
||||
name = "A";
|
||||
};
|
||||
};
|
||||
instances."instance_zaza" = {
|
||||
module = {
|
||||
name = "B";
|
||||
};
|
||||
};
|
||||
};
|
||||
in
|
||||
{
|
||||
# Test that the module is mapped into the output
|
||||
# We might change the attribute name in the future
|
||||
expr = lib.attrNames res.importedModulesEvaluated.self-A.config.instances;
|
||||
expected = [
|
||||
"instance_bar"
|
||||
"instance_foo"
|
||||
];
|
||||
};
|
||||
|
||||
# Membership via roles
|
||||
test_add_machines_directly =
|
||||
let
|
||||
res = callInventoryAdapter {
|
||||
# Authored module
|
||||
# A minimal module looks like this
|
||||
# It isn't exactly doing anything but it's a valid module that produces an output
|
||||
modules."A" = {
|
||||
_class = "clan.service";
|
||||
manifest = {
|
||||
name = "network";
|
||||
};
|
||||
# Define a role without special behavior
|
||||
roles.peer = { };
|
||||
|
||||
# perMachine = {}: {};
|
||||
};
|
||||
machines = {
|
||||
jon = { };
|
||||
sara = { };
|
||||
hxi = { };
|
||||
};
|
||||
instances."instance_foo" = {
|
||||
module = {
|
||||
name = "A";
|
||||
};
|
||||
roles.peer.machines.jon = { };
|
||||
};
|
||||
instances."instance_bar" = {
|
||||
module = {
|
||||
name = "A";
|
||||
};
|
||||
roles.peer.machines.sara = { };
|
||||
};
|
||||
instances."instance_zaza" = {
|
||||
module = {
|
||||
name = "B";
|
||||
};
|
||||
roles.peer.tags.all = { };
|
||||
};
|
||||
};
|
||||
in
|
||||
{
|
||||
# Test that the module is mapped into the output
|
||||
# We might change the attribute name in the future
|
||||
expr = lib.attrNames res.importedModulesEvaluated.self-A.config.result.allMachines;
|
||||
expected = [
|
||||
"jon"
|
||||
"sara"
|
||||
];
|
||||
};
|
||||
|
||||
# Membership via tags
|
||||
test_add_machines_via_tags =
|
||||
let
|
||||
res = callInventoryAdapter {
|
||||
# Authored module
|
||||
# A minimal module looks like this
|
||||
# It isn't exactly doing anything but it's a valid module that produces an output
|
||||
modules."A" = {
|
||||
_class = "clan.service";
|
||||
manifest = {
|
||||
name = "network";
|
||||
};
|
||||
# Define a role without special behavior
|
||||
roles.peer = { };
|
||||
|
||||
# perMachine = {}: {};
|
||||
};
|
||||
machines = {
|
||||
jon = {
|
||||
tags = [ "foo" ];
|
||||
};
|
||||
sara = {
|
||||
tags = [ "foo" ];
|
||||
};
|
||||
hxi = { };
|
||||
};
|
||||
instances."instance_foo" = {
|
||||
module = {
|
||||
name = "A";
|
||||
};
|
||||
roles.peer.tags.foo = { };
|
||||
};
|
||||
instances."instance_zaza" = {
|
||||
module = {
|
||||
name = "B";
|
||||
};
|
||||
roles.peer.tags.all = { };
|
||||
};
|
||||
};
|
||||
in
|
||||
{
|
||||
# Test that the module is mapped into the output
|
||||
# We might change the attribute name in the future
|
||||
expr = lib.attrNames res.importedModulesEvaluated.self-A.config.result.allMachines;
|
||||
expected = [
|
||||
"jon"
|
||||
"sara"
|
||||
];
|
||||
};
|
||||
|
||||
per_machine_args = import ./per_machine_args.nix { inherit lib callInventoryAdapter; };
|
||||
per_instance_args = import ./per_instance_args.nix { inherit lib callInventoryAdapter; };
|
||||
nested = import ./nested_services { inherit lib clanLib; };
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
{ callInventoryAdapter, ... }:
|
||||
let
|
||||
# Authored module
|
||||
# A minimal module looks like this
|
||||
# It isn't exactly doing anything but it's a valid module that produces an output
|
||||
modules."A" = {
|
||||
_class = "clan.service";
|
||||
manifest = {
|
||||
name = "network";
|
||||
};
|
||||
};
|
||||
|
||||
modules."B" =
|
||||
{ ... }:
|
||||
{
|
||||
options.stuff = "legacy-clan-service";
|
||||
};
|
||||
|
||||
machines = {
|
||||
jon = { };
|
||||
sara = { };
|
||||
};
|
||||
|
||||
resolve =
|
||||
spec:
|
||||
callInventoryAdapter {
|
||||
inherit modules machines;
|
||||
instances."instance_foo" = {
|
||||
module = spec;
|
||||
};
|
||||
};
|
||||
in
|
||||
{
|
||||
test_import_local_module_by_name = {
|
||||
expr = (resolve { name = "A"; }).importedModuleWithInstances.instance_foo.resolvedModule;
|
||||
expected = {
|
||||
_class = "clan.service";
|
||||
manifest = {
|
||||
name = "network";
|
||||
};
|
||||
};
|
||||
};
|
||||
test_import_remote_module_by_name = {
|
||||
expr =
|
||||
(resolve {
|
||||
name = "uzzi";
|
||||
input = "upstream";
|
||||
}).importedModuleWithInstances.instance_foo.resolvedModule;
|
||||
expected = {
|
||||
_class = "clan.service";
|
||||
manifest = {
|
||||
name = "uzzi-from-upstream";
|
||||
};
|
||||
};
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
{ clanLib, lib, ... }:
|
||||
{
|
||||
test_simple = import ./simple.nix { inherit clanLib lib; };
|
||||
|
||||
test_multi_machine = import ./multi_machine.nix { inherit clanLib lib; };
|
||||
|
||||
test_multi_import_duplication = import ./multi_import_duplication.nix { inherit clanLib lib; };
|
||||
}
|
||||
@@ -0,0 +1,125 @@
|
||||
{ clanLib, lib, ... }:
|
||||
let
|
||||
# Potentially imported many times
|
||||
# To add the ssh key
|
||||
example-admin = (
|
||||
{ lib, ... }:
|
||||
{
|
||||
manifest.name = "example-admin";
|
||||
|
||||
roles.client.interface = {
|
||||
options.keys = lib.mkOption { };
|
||||
};
|
||||
|
||||
roles.client.perInstance =
|
||||
{ settings, ... }:
|
||||
{
|
||||
nixosModule = {
|
||||
inherit (settings) keys;
|
||||
};
|
||||
};
|
||||
}
|
||||
);
|
||||
|
||||
consumer-A =
|
||||
{ ... }:
|
||||
{
|
||||
manifest.name = "consumer-A";
|
||||
|
||||
instances.foo = {
|
||||
roles.server.machines."jon" = { };
|
||||
};
|
||||
instances.bar = {
|
||||
roles.server.machines."jon" = { };
|
||||
};
|
||||
|
||||
roles.server = {
|
||||
perInstance =
|
||||
{ machine, instanceName, ... }:
|
||||
{
|
||||
services."example-admin" = {
|
||||
imports = [
|
||||
example-admin
|
||||
];
|
||||
instances."${instanceName}" = {
|
||||
roles.client.machines.${machine.name} = {
|
||||
settings.keys = [ "pubkey-1" ];
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
consumer-B =
|
||||
{ ... }:
|
||||
{
|
||||
manifest.name = "consumer-A";
|
||||
|
||||
instances.foo = {
|
||||
roles.server.machines."jon" = { };
|
||||
};
|
||||
instances.bar = {
|
||||
roles.server.machines."jon" = { };
|
||||
};
|
||||
|
||||
roles.server = {
|
||||
perInstance =
|
||||
{ machine, instanceName, ... }:
|
||||
{
|
||||
services."example-admin" = {
|
||||
imports = [
|
||||
example-admin
|
||||
];
|
||||
instances."${instanceName}" = {
|
||||
roles.client.machines.${machine.name} = {
|
||||
settings.keys = [
|
||||
"pubkey-1"
|
||||
];
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
|
||||
eval = clanLib.inventory.evalClanService {
|
||||
modules = [
|
||||
(consumer-A)
|
||||
];
|
||||
prefix = [ ];
|
||||
};
|
||||
eval2 = clanLib.inventory.evalClanService {
|
||||
modules = [
|
||||
(consumer-B)
|
||||
];
|
||||
prefix = [ ];
|
||||
};
|
||||
|
||||
evalNixos = lib.evalModules {
|
||||
modules = [
|
||||
{
|
||||
options.assertions = lib.mkOption { };
|
||||
# This is suboptimal
|
||||
options.keys = lib.mkOption { };
|
||||
}
|
||||
eval.config.result.final.jon.nixosModule
|
||||
eval2.config.result.final.jon.nixosModule
|
||||
];
|
||||
};
|
||||
in
|
||||
{
|
||||
# Check that the nixos system has the settings from the nested module, as well as those from the "perMachine" and "perInstance"
|
||||
inherit eval;
|
||||
expr = evalNixos.config;
|
||||
expected = {
|
||||
assertions = [ ];
|
||||
# TODO: Some deduplication mechanism is nice
|
||||
# Could add types.set or do 'apply = unique', or something else ?
|
||||
keys = [
|
||||
"pubkey-1"
|
||||
"pubkey-1"
|
||||
"pubkey-1"
|
||||
"pubkey-1"
|
||||
];
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,108 @@
|
||||
{ clanLib, lib, ... }:
|
||||
let
|
||||
service-B = (
|
||||
{ lib, ... }:
|
||||
{
|
||||
manifest.name = "service-B";
|
||||
|
||||
roles.client.interface = {
|
||||
options.user = lib.mkOption { };
|
||||
options.host = lib.mkOption { };
|
||||
};
|
||||
roles.client.perInstance =
|
||||
{ settings, instanceName, ... }:
|
||||
{
|
||||
nixosModule = {
|
||||
units.${instanceName} = {
|
||||
script = settings.user + "@" + settings.host;
|
||||
};
|
||||
};
|
||||
};
|
||||
perMachine =
|
||||
{ ... }:
|
||||
{
|
||||
nixosModule = {
|
||||
ssh.enable = true;
|
||||
};
|
||||
};
|
||||
}
|
||||
);
|
||||
service-A =
|
||||
{ ... }:
|
||||
{
|
||||
manifest.name = "service-A";
|
||||
|
||||
instances.foo = {
|
||||
roles.server.machines."jon" = { };
|
||||
roles.server.machines."sara" = { };
|
||||
};
|
||||
|
||||
roles.server = {
|
||||
perInstance =
|
||||
{ machine, instanceName, ... }:
|
||||
{
|
||||
services."B" = {
|
||||
imports = [
|
||||
service-B
|
||||
];
|
||||
instances."A-${instanceName}-B" = {
|
||||
roles.client.machines.${machine.name} = {
|
||||
settings.user = "johnny";
|
||||
settings.host = machine.name;
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
|
||||
eval = clanLib.inventory.evalClanService {
|
||||
modules = [
|
||||
(service-A)
|
||||
];
|
||||
prefix = [ ];
|
||||
};
|
||||
|
||||
evalNixos = lib.mapAttrs (
|
||||
_n: v:
|
||||
(lib.evalModules {
|
||||
modules = [
|
||||
{
|
||||
options.assertions = lib.mkOption { };
|
||||
options.units = lib.mkOption { };
|
||||
options.ssh = lib.mkOption { };
|
||||
}
|
||||
v.nixosModule
|
||||
];
|
||||
}).config
|
||||
) eval.config.result.final;
|
||||
in
|
||||
{
|
||||
# Check that the nixos system has the settings from the nested module, as well as those from the "perMachine" and "perInstance"
|
||||
inherit eval;
|
||||
expr = evalNixos;
|
||||
expected = {
|
||||
jon = {
|
||||
assertions = [ ];
|
||||
ssh = {
|
||||
enable = true;
|
||||
};
|
||||
units = {
|
||||
A-foo-B = {
|
||||
script = "johnny@jon";
|
||||
};
|
||||
};
|
||||
};
|
||||
sara = {
|
||||
assertions = [ ];
|
||||
ssh = {
|
||||
enable = true;
|
||||
};
|
||||
units = {
|
||||
A-foo-B = {
|
||||
script = "johnny@sara";
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,117 @@
|
||||
/*
|
||||
service-B :: Service
|
||||
exports a nixosModule which set "address" and "hostname"
|
||||
Note: How we use null together with mkIf to create optional values.
|
||||
This is a method, to create mergable modules
|
||||
|
||||
service-A :: Service
|
||||
|
||||
service-A.roles.server.perInstance.services."B"
|
||||
imports service-B
|
||||
configures a client with hostname = "johnny"
|
||||
|
||||
service-A.perMachine.services."B"
|
||||
imports service-B
|
||||
configures a client with address = "root"
|
||||
*/
|
||||
{ clanLib, lib, ... }:
|
||||
let
|
||||
service-B = (
|
||||
{ lib, ... }:
|
||||
{
|
||||
manifest.name = "service-B";
|
||||
|
||||
roles.client.interface = {
|
||||
options.hostname = lib.mkOption { default = null; };
|
||||
options.address = lib.mkOption { default = null; };
|
||||
};
|
||||
roles.client.perInstance =
|
||||
{ settings, ... }:
|
||||
{
|
||||
nixosModule = {
|
||||
imports = [
|
||||
# Only export the value that is actually set.
|
||||
(lib.mkIf (settings.hostname != null) {
|
||||
hostname = settings.hostname;
|
||||
})
|
||||
(lib.mkIf (settings.address != null) {
|
||||
address = settings.address;
|
||||
})
|
||||
];
|
||||
};
|
||||
};
|
||||
}
|
||||
);
|
||||
service-A =
|
||||
{ ... }:
|
||||
{
|
||||
manifest.name = "service-A";
|
||||
|
||||
instances.foo = {
|
||||
roles.server.machines."jon" = { };
|
||||
};
|
||||
instances.bar = {
|
||||
roles.server.machines."jon" = { };
|
||||
};
|
||||
|
||||
roles.server = {
|
||||
perInstance =
|
||||
{ machine, instanceName, ... }:
|
||||
{
|
||||
services."B" = {
|
||||
imports = [
|
||||
service-B
|
||||
];
|
||||
instances."B-for-A" = {
|
||||
roles.client.machines.${machine.name} = {
|
||||
settings.hostname = instanceName + "+johnny";
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
perMachine =
|
||||
{ machine, ... }:
|
||||
{
|
||||
services."B" = {
|
||||
imports = [
|
||||
service-B
|
||||
];
|
||||
instances."B-for-A" = {
|
||||
roles.client.machines.${machine.name} = {
|
||||
settings.address = "root";
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
|
||||
eval = clanLib.inventory.evalClanService {
|
||||
modules = [
|
||||
(service-A)
|
||||
];
|
||||
prefix = [ ];
|
||||
};
|
||||
|
||||
evalNixos = lib.evalModules {
|
||||
modules = [
|
||||
{
|
||||
options.assertions = lib.mkOption { };
|
||||
options.hostname = lib.mkOption { type = lib.types.separatedString " "; };
|
||||
options.address = lib.mkOption { type = lib.types.str; };
|
||||
}
|
||||
eval.config.result.final."jon".nixosModule
|
||||
];
|
||||
};
|
||||
in
|
||||
{
|
||||
# Check that the nixos system has the settings from the nested module, as well as those from the "perMachine" and "perInstance"
|
||||
inherit eval;
|
||||
expr = evalNixos.config;
|
||||
expected = {
|
||||
address = "root";
|
||||
assertions = [ ];
|
||||
# Concatenates hostnames from both instances
|
||||
hostname = "bar+johnny foo+johnny";
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,169 @@
|
||||
{ lib, callInventoryAdapter }:
|
||||
let
|
||||
# Authored module
|
||||
# A minimal module looks like this
|
||||
# It isn't exactly doing anything but it's a valid module that produces an output
|
||||
modules."A" = {
|
||||
_class = "clan.service";
|
||||
manifest = {
|
||||
name = "network";
|
||||
};
|
||||
# Define two roles with unmergeable interfaces
|
||||
# Both define some 'timeout' but with completely different types.
|
||||
roles.controller = { };
|
||||
roles.peer.interface =
|
||||
{ lib, ... }:
|
||||
{
|
||||
options.timeout = lib.mkOption {
|
||||
type = lib.types.str;
|
||||
};
|
||||
};
|
||||
|
||||
roles.peer.perInstance =
|
||||
{
|
||||
instanceName,
|
||||
settings,
|
||||
extendSettings,
|
||||
machine,
|
||||
roles,
|
||||
...
|
||||
}:
|
||||
let
|
||||
finalSettings = extendSettings {
|
||||
# Sometimes we want to create a default settings set depending on the machine config.
|
||||
# Note: Other machines cannot depend on this settings. We must assign a new name to the settings.
|
||||
# And thus the new value is not accessible by other machines.
|
||||
timeout = lib.mkOverride 10 "config.thing";
|
||||
};
|
||||
in
|
||||
{
|
||||
options.passthru = lib.mkOption {
|
||||
default = {
|
||||
inherit
|
||||
instanceName
|
||||
settings
|
||||
machine
|
||||
roles
|
||||
;
|
||||
|
||||
# We are double vendoring the settings
|
||||
# To test that we can do it indefinitely
|
||||
vendoredSettings = finalSettings;
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
machines = {
|
||||
jon = { };
|
||||
sara = { };
|
||||
};
|
||||
res = callInventoryAdapter {
|
||||
inherit modules machines;
|
||||
instances."instance_foo" = {
|
||||
module = {
|
||||
name = "A";
|
||||
};
|
||||
roles.peer.machines.jon = {
|
||||
settings.timeout = lib.mkForce "foo-peer-jon";
|
||||
};
|
||||
roles.peer = {
|
||||
settings.timeout = "foo-peer";
|
||||
};
|
||||
roles.controller.machines.jon = { };
|
||||
};
|
||||
instances."instance_bar" = {
|
||||
module = {
|
||||
name = "A";
|
||||
};
|
||||
roles.peer.machines.jon = {
|
||||
settings.timeout = "bar-peer-jon";
|
||||
};
|
||||
};
|
||||
# TODO: move this into a seperate test.
|
||||
# Seperate out the check that this module is never imported
|
||||
# import the module "B" (undefined)
|
||||
# All machines have this instance
|
||||
instances."instance_zaza" = {
|
||||
module = {
|
||||
name = "B";
|
||||
};
|
||||
roles.peer.tags.all = { };
|
||||
};
|
||||
};
|
||||
|
||||
/*
|
||||
1 { imports = [ { instanceName = "instance_foo"; machine = { name = "jon"; roles = [ "controller" "pe 1 null
|
||||
. er" ]; }; roles = { controller = { machines = { jon = { settings = { }; }; }; settings = { }; }; pe .
|
||||
. er = { machines = { jon = { settings = { timeout = "foo-peer-jon"; }; }; }; settings = { timeout = .
|
||||
. "foo-peer"; }; }; }; settings = { timeout = "foo-peer-jon"; }; vendoredSettings = { timeout = "conf .
|
||||
. ig.thing"; }; } ]; } .
|
||||
*/
|
||||
in
|
||||
{
|
||||
# settings should evaluate
|
||||
test_per_instance_arguments = {
|
||||
expr = {
|
||||
instanceName =
|
||||
res.importedModulesEvaluated.self-A.config.result.allRoles.peer.allInstances."instance_foo".allMachines.jon.passthru.instanceName;
|
||||
|
||||
# settings are specific.
|
||||
# Below we access:
|
||||
# instance = instance_foo
|
||||
# roles = peer
|
||||
# machines = jon
|
||||
settings =
|
||||
res.importedModulesEvaluated.self-A.config.result.allRoles.peer.allInstances.instance_foo.allMachines.jon.passthru.settings;
|
||||
machine =
|
||||
res.importedModulesEvaluated.self-A.config.result.allRoles.peer.allInstances.instance_foo.allMachines.jon.passthru.machine;
|
||||
roles =
|
||||
res.importedModulesEvaluated.self-A.config.result.allRoles.peer.allInstances.instance_foo.allMachines.jon.passthru.roles;
|
||||
};
|
||||
expected = {
|
||||
instanceName = "instance_foo";
|
||||
settings = {
|
||||
timeout = "foo-peer-jon";
|
||||
};
|
||||
machine = {
|
||||
name = "jon";
|
||||
roles = [
|
||||
"controller"
|
||||
"peer"
|
||||
];
|
||||
};
|
||||
roles = {
|
||||
controller = {
|
||||
machines = {
|
||||
jon = {
|
||||
settings = {
|
||||
};
|
||||
};
|
||||
};
|
||||
settings = {
|
||||
};
|
||||
};
|
||||
peer = {
|
||||
machines = {
|
||||
jon = {
|
||||
settings = {
|
||||
timeout = "foo-peer-jon";
|
||||
};
|
||||
};
|
||||
};
|
||||
settings = {
|
||||
timeout = "foo-peer";
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
|
||||
# TODO: Cannot be tested like this anymore
|
||||
test_per_instance_settings_vendoring = {
|
||||
x = res.importedModulesEvaluated.self-A.config;
|
||||
expr =
|
||||
res.importedModulesEvaluated.self-A.config.result.allRoles.peer.allInstances.instance_foo.allMachines.jon.passthru.vendoredSettings;
|
||||
expected = {
|
||||
timeout = "config.thing";
|
||||
};
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,113 @@
|
||||
{ lib, callInventoryAdapter }:
|
||||
let
|
||||
# Authored module
|
||||
# A minimal module looks like this
|
||||
# It isn't exactly doing anything but it's a valid module that produces an output
|
||||
modules."A" = {
|
||||
_class = "clan.service";
|
||||
manifest = {
|
||||
name = "network";
|
||||
};
|
||||
# Define two roles with unmergeable interfaces
|
||||
# Both define some 'timeout' but with completely different types.
|
||||
roles.peer.interface =
|
||||
{ lib, ... }:
|
||||
{
|
||||
options.timeout = lib.mkOption {
|
||||
type = lib.types.str;
|
||||
};
|
||||
};
|
||||
roles.server.interface =
|
||||
{ lib, ... }:
|
||||
{
|
||||
options.timeout = lib.mkOption {
|
||||
type = lib.types.submodule;
|
||||
};
|
||||
};
|
||||
|
||||
perMachine =
|
||||
{ instances, machine, ... }:
|
||||
{
|
||||
options.passthru = lib.mkOption {
|
||||
default = {
|
||||
inherit instances machine;
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
machines = {
|
||||
jon = { };
|
||||
sara = { };
|
||||
};
|
||||
res = callInventoryAdapter {
|
||||
inherit modules machines;
|
||||
instances."instance_foo" = {
|
||||
module = {
|
||||
name = "A";
|
||||
};
|
||||
roles.peer.machines.jon = {
|
||||
settings.timeout = lib.mkForce "foo-peer-jon";
|
||||
};
|
||||
roles.peer = {
|
||||
settings.timeout = "foo-peer";
|
||||
};
|
||||
};
|
||||
instances."instance_bar" = {
|
||||
module = {
|
||||
name = "A";
|
||||
};
|
||||
roles.peer.machines.jon = {
|
||||
settings.timeout = "bar-peer-jon";
|
||||
};
|
||||
};
|
||||
instances."instance_zaza" = {
|
||||
module = {
|
||||
name = "B";
|
||||
};
|
||||
roles.peer.tags.all = { };
|
||||
};
|
||||
};
|
||||
|
||||
filterInternals = lib.filterAttrs (n: _v: !lib.hasPrefix "_" n);
|
||||
in
|
||||
|
||||
{
|
||||
|
||||
# settings should evaluate
|
||||
test_per_machine_receives_instance_settings = {
|
||||
inherit res;
|
||||
expr = {
|
||||
hasMachineSettings =
|
||||
res.importedModulesEvaluated.self-A.config.result.allMachines.jon.passthru.instances.instance_foo.roles.peer.machines.jon
|
||||
? settings;
|
||||
|
||||
# settings are specific.
|
||||
# Below we access:
|
||||
# instance = instance_foo
|
||||
# roles = peer
|
||||
# machines = jon
|
||||
specificMachineSettings = filterInternals res.importedModulesEvaluated.self-A.config.result.allMachines.jon.passthru.instances.instance_foo.roles.peer.machines.jon.settings;
|
||||
|
||||
hasRoleSettings =
|
||||
res.importedModulesEvaluated.self-A.config.result.allMachines.jon.passthru.instances.instance_foo.roles.peer
|
||||
? settings;
|
||||
|
||||
# settings are specific.
|
||||
# Below we access:
|
||||
# instance = instance_foo
|
||||
# roles = peer
|
||||
# machines = *
|
||||
specificRoleSettings = filterInternals res.importedModulesEvaluated.self-A.config.result.allMachines.jon.passthru.instances.instance_foo.roles.peer.settings;
|
||||
};
|
||||
expected = {
|
||||
hasMachineSettings = true;
|
||||
specificMachineSettings = {
|
||||
timeout = "foo-peer-jon";
|
||||
};
|
||||
hasRoleSettings = true;
|
||||
specificRoleSettings = {
|
||||
timeout = "foo-peer";
|
||||
};
|
||||
};
|
||||
};
|
||||
}
|
||||
107
lib/modules/inventory/eval-clan-modules/default.nix
Normal file
107
lib/modules/inventory/eval-clan-modules/default.nix
Normal file
@@ -0,0 +1,107 @@
|
||||
{
|
||||
lib,
|
||||
clanLib,
|
||||
}:
|
||||
let
|
||||
baseModule =
|
||||
{ pkgs }:
|
||||
# Module
|
||||
{ config, ... }:
|
||||
{
|
||||
imports = (import (pkgs.path + "/nixos/modules/module-list.nix"));
|
||||
nixpkgs.pkgs = pkgs;
|
||||
clan.core.name = "dummy";
|
||||
system.stateVersion = config.system.nixos.release;
|
||||
# Set this to work around a bug where `clan.core.settings.machine.name`
|
||||
# is forced due to `networking.interfaces` being forced
|
||||
# somewhere in the nixpkgs options
|
||||
facter.detected.dhcp.enable = lib.mkForce false;
|
||||
};
|
||||
|
||||
# This function takes a list of module names and evaluates them
|
||||
# [ module ] -> { config, options, ... }
|
||||
evalClanModulesLegacy =
|
||||
{
|
||||
modules,
|
||||
pkgs,
|
||||
clan-core,
|
||||
}:
|
||||
let
|
||||
evaled = lib.evalModules {
|
||||
class = "nixos";
|
||||
modules = [
|
||||
(baseModule { inherit pkgs; })
|
||||
{
|
||||
clan.core.settings.directory = clan-core;
|
||||
}
|
||||
clan-core.nixosModules.clanCore
|
||||
] ++ modules;
|
||||
};
|
||||
in
|
||||
# lib.warn ''
|
||||
# doesn't respect role specific interfaces.
|
||||
|
||||
# The following {module}/default.nix file trying to be imported.
|
||||
|
||||
# Modules: ${builtins.toJSON modulenames}
|
||||
|
||||
# This might result in incomplete or incorrect interfaces.
|
||||
|
||||
# FIX: Use evalClanModuleWithRole instead.
|
||||
# ''
|
||||
evaled;
|
||||
|
||||
/*
|
||||
This function takes a list of module names and evaluates them
|
||||
Returns a set of interfaces as described below:
|
||||
|
||||
Fn :: { ${moduleName} = Module; } -> {
|
||||
${moduleName} :: {
|
||||
${roleName}: JSONSchema
|
||||
}
|
||||
}
|
||||
*/
|
||||
evalClanModulesWithRoles =
|
||||
{
|
||||
allModules,
|
||||
clan-core,
|
||||
pkgs,
|
||||
}:
|
||||
let
|
||||
res = builtins.mapAttrs (
|
||||
moduleName: module:
|
||||
let
|
||||
frontmatter = clanLib.modules.getFrontmatter allModules.${moduleName} moduleName;
|
||||
roles =
|
||||
if builtins.elem "inventory" frontmatter.features or [ ] then
|
||||
assert lib.isPath module;
|
||||
clan-core.clanLib.modules.getRoles "Documentation: inventory.modules" allModules moduleName
|
||||
else
|
||||
[ ];
|
||||
in
|
||||
lib.listToAttrs (
|
||||
lib.map (role: {
|
||||
name = role;
|
||||
value =
|
||||
(lib.evalModules {
|
||||
class = "nixos";
|
||||
modules = [
|
||||
(baseModule { inherit pkgs; })
|
||||
clan-core.nixosModules.clanCore
|
||||
{
|
||||
clan.core.settings.directory = clan-core;
|
||||
}
|
||||
# Role interface
|
||||
(module + "/roles/${role}.nix")
|
||||
];
|
||||
}).options.clan.${moduleName} or { };
|
||||
}) roles
|
||||
)
|
||||
) allModules;
|
||||
in
|
||||
res;
|
||||
in
|
||||
{
|
||||
evalClanModules = evalClanModulesLegacy;
|
||||
inherit evalClanModulesWithRoles;
|
||||
}
|
||||
85
lib/modules/inventory/flake-module.nix
Normal file
85
lib/modules/inventory/flake-module.nix
Normal file
@@ -0,0 +1,85 @@
|
||||
{
|
||||
self,
|
||||
inputs,
|
||||
options,
|
||||
...
|
||||
}:
|
||||
let
|
||||
inputOverrides = builtins.concatStringsSep " " (
|
||||
builtins.map (input: " --override-input ${input} ${inputs.${input}}") (builtins.attrNames inputs)
|
||||
);
|
||||
in
|
||||
{
|
||||
imports = [
|
||||
./distributed-service/flake-module.nix
|
||||
];
|
||||
perSystem =
|
||||
{
|
||||
pkgs,
|
||||
lib,
|
||||
config,
|
||||
system,
|
||||
self',
|
||||
...
|
||||
}:
|
||||
{
|
||||
devShells.inventory-schema = pkgs.mkShell {
|
||||
name = "clan-inventory-schema";
|
||||
inputsFrom = with config.checks; [
|
||||
lib-inventory-eval
|
||||
self'.devShells.default
|
||||
];
|
||||
};
|
||||
|
||||
legacyPackages.schemas = (
|
||||
import ./schemas {
|
||||
flakeOptions = options;
|
||||
inherit
|
||||
pkgs
|
||||
self
|
||||
lib
|
||||
self'
|
||||
;
|
||||
}
|
||||
);
|
||||
|
||||
legacyPackages.clan-service-module-interface =
|
||||
(pkgs.nixosOptionsDoc {
|
||||
options =
|
||||
(self.clanLib.inventory.evalClanService {
|
||||
modules = [ ];
|
||||
prefix = [ ];
|
||||
}).options;
|
||||
warningsAreErrors = true;
|
||||
}).optionsJSON;
|
||||
|
||||
# Run: nix-unit --extra-experimental-features flakes --flake .#legacyPackages.x86_64-linux.evalTests
|
||||
legacyPackages.evalTests-inventory = import ./tests {
|
||||
inherit lib;
|
||||
clan-core = self;
|
||||
};
|
||||
|
||||
checks = {
|
||||
lib-inventory-eval = pkgs.runCommand "tests" { nativeBuildInputs = [ pkgs.nix-unit ]; } ''
|
||||
export HOME="$(realpath .)"
|
||||
export NIX_ABORT_ON_WARN=1
|
||||
nix-unit --eval-store "$HOME" \
|
||||
--extra-experimental-features flakes \
|
||||
--show-trace \
|
||||
${inputOverrides} \
|
||||
--flake ${
|
||||
self.filter {
|
||||
include = [
|
||||
"flakeModules"
|
||||
"lib"
|
||||
"clanModules/flake-module.nix"
|
||||
"clanModules/borgbackup"
|
||||
];
|
||||
}
|
||||
}#legacyPackages.${system}.evalTests-inventory
|
||||
|
||||
touch $out
|
||||
'';
|
||||
};
|
||||
};
|
||||
}
|
||||
173
lib/modules/inventory/frontmatter/default.nix
Normal file
173
lib/modules/inventory/frontmatter/default.nix
Normal file
@@ -0,0 +1,173 @@
|
||||
{ lib, clanLib }:
|
||||
let
|
||||
# Trim the .nix extension from a filename
|
||||
trimExtension = name: builtins.substring 0 (builtins.stringLength name - 4) name;
|
||||
|
||||
jsonWithoutHeader = clanLib.jsonschema {
|
||||
includeDefaults = true;
|
||||
header = { };
|
||||
};
|
||||
|
||||
getModulesSchema =
|
||||
{
|
||||
modules,
|
||||
clan-core,
|
||||
pkgs,
|
||||
}:
|
||||
lib.mapAttrs
|
||||
(
|
||||
_moduleName: rolesOptions:
|
||||
lib.mapAttrs (_roleName: options: jsonWithoutHeader.parseOptions options { }) rolesOptions
|
||||
)
|
||||
(
|
||||
clanLib.evalClan.evalClanModulesWithRoles {
|
||||
allModules = modules;
|
||||
inherit pkgs clan-core;
|
||||
}
|
||||
);
|
||||
|
||||
evalFrontmatter =
|
||||
{
|
||||
moduleName,
|
||||
instanceName,
|
||||
resolvedRoles,
|
||||
allModules,
|
||||
}:
|
||||
lib.evalModules {
|
||||
modules = [
|
||||
(getFrontmatter allModules.${moduleName} moduleName)
|
||||
./interface.nix
|
||||
{
|
||||
constraints.imports = [
|
||||
(lib.modules.importApply ../constraints {
|
||||
inherit moduleName resolvedRoles instanceName;
|
||||
allRoles = getRoles "inventory.modules" allModules moduleName;
|
||||
})
|
||||
];
|
||||
}
|
||||
];
|
||||
};
|
||||
|
||||
# For Documentation purposes only
|
||||
frontmatterOptions =
|
||||
(lib.evalModules {
|
||||
modules = [
|
||||
./interface.nix
|
||||
{
|
||||
constraints.imports = [
|
||||
(lib.modules.importApply ../constraints {
|
||||
resolvedRoles = { };
|
||||
moduleName = "{moduleName}";
|
||||
instanceName = "{instanceName}";
|
||||
allRoles = [ "{roleName}" ];
|
||||
})
|
||||
];
|
||||
}
|
||||
];
|
||||
}).options;
|
||||
|
||||
migratedModules = [ "admin" ];
|
||||
|
||||
makeModuleNotFoundError =
|
||||
serviceName:
|
||||
if builtins.elem serviceName migratedModules then
|
||||
''
|
||||
(Legacy) ClanModule not found: '${serviceName}'.
|
||||
|
||||
Please update your configuration to use this module via 'inventory.instances'
|
||||
See: https://docs.clan.lol/guides/clanServices/
|
||||
''
|
||||
else
|
||||
''
|
||||
(Legacy) ClanModule not found: '${serviceName}'.
|
||||
|
||||
Make sure the module is added to inventory.modules.${serviceName}
|
||||
'';
|
||||
# This is a legacy function
|
||||
# Old modules needed to define their roles by directory
|
||||
# This means if this function gets anything other than a string/path it will throw
|
||||
getRoles =
|
||||
_scope: allModules: serviceName:
|
||||
let
|
||||
module = allModules.${serviceName} or (throw (makeModuleNotFoundError serviceName));
|
||||
moduleType = (lib.typeOf module);
|
||||
checked =
|
||||
if
|
||||
builtins.elem moduleType [
|
||||
"string"
|
||||
"path"
|
||||
]
|
||||
then
|
||||
true
|
||||
else
|
||||
throw "(Legacy) ClanModule must be a 'path' or 'string' pointing to a directory: Got 'typeOf inventory.modules.${serviceName}' => ${moduleType} ";
|
||||
modulePath = lib.seq checked module + "/roles";
|
||||
checkedPath =
|
||||
if builtins.pathExists modulePath then
|
||||
modulePath
|
||||
else
|
||||
throw ''
|
||||
(Legacy) ClanModule must have a 'roles' directory'
|
||||
|
||||
Fixes:
|
||||
- Provide a 'roles' subdirectory
|
||||
- Use the newer 'clan.service' modules. (Recommended)
|
||||
'';
|
||||
in
|
||||
lib.seq checkedPath lib.mapAttrsToList (name: _value: trimExtension name) (
|
||||
lib.filterAttrs (name: type: type == "regular" && lib.hasSuffix ".nix" name) (
|
||||
builtins.readDir (checkedPath)
|
||||
)
|
||||
);
|
||||
|
||||
checkConstraints = args: (evalFrontmatter args).config.constraints.assertions;
|
||||
|
||||
getReadme =
|
||||
modulepath: modulename:
|
||||
let
|
||||
readme = modulepath + "/README.md";
|
||||
readmeContents =
|
||||
if (builtins.pathExists readme) then
|
||||
(builtins.readFile readme)
|
||||
else
|
||||
throw "No README.md found for module ${modulename} (expected at ${readme})";
|
||||
in
|
||||
readmeContents;
|
||||
|
||||
getFrontmatter =
|
||||
modulepath: modulename:
|
||||
let
|
||||
content = getReadme modulepath modulename;
|
||||
parts = lib.splitString "---" content;
|
||||
# Partition the parts into the first part (the readme content) and the rest (the metadata)
|
||||
parsed = builtins.partition ({ index, ... }: if index >= 2 then false else true) (
|
||||
lib.filter ({ index, ... }: index != 0) (lib.imap0 (index: part: { inherit index part; }) parts)
|
||||
);
|
||||
meta = builtins.fromTOML (builtins.head parsed.right).part;
|
||||
in
|
||||
if (builtins.length parts >= 3) then
|
||||
meta
|
||||
else
|
||||
throw ''
|
||||
TOML Frontmatter not found in README.md for module ${modulename}
|
||||
|
||||
Please add the following to the top of your README.md:
|
||||
|
||||
---
|
||||
description = "Your description here"
|
||||
categories = [ "Your categories here" ]
|
||||
features = [ "inventory" ]
|
||||
---
|
||||
...rest of your README.md...
|
||||
'';
|
||||
in
|
||||
{
|
||||
inherit
|
||||
frontmatterOptions
|
||||
getModulesSchema
|
||||
getFrontmatter
|
||||
|
||||
checkConstraints
|
||||
getRoles
|
||||
;
|
||||
}
|
||||
84
lib/modules/inventory/frontmatter/interface.nix
Normal file
84
lib/modules/inventory/frontmatter/interface.nix
Normal file
@@ -0,0 +1,84 @@
|
||||
{
|
||||
lib,
|
||||
...
|
||||
}:
|
||||
let
|
||||
inherit (lib) mkOption types;
|
||||
in
|
||||
{
|
||||
options = {
|
||||
description = mkOption {
|
||||
type = types.str;
|
||||
description = ''
|
||||
A Short description of the module.
|
||||
'';
|
||||
};
|
||||
categories = mkOption {
|
||||
default = [ "Uncategorized" ];
|
||||
description = ''
|
||||
Categories are used for Grouping and searching.
|
||||
|
||||
While initial oriented on [freedesktop](https://specifications.freedesktop.org/menu-spec/latest/category-registry.html) the following categories are allowed
|
||||
'';
|
||||
type = types.listOf (
|
||||
types.enum [
|
||||
"AudioVideo"
|
||||
"Audio"
|
||||
"Video"
|
||||
"Development"
|
||||
"Education"
|
||||
"Game"
|
||||
"Graphics"
|
||||
"Social"
|
||||
"Network"
|
||||
"Office"
|
||||
"Science"
|
||||
"System"
|
||||
"Settings"
|
||||
"Utility"
|
||||
"Uncategorized"
|
||||
]
|
||||
);
|
||||
};
|
||||
features = mkOption {
|
||||
default = [ ];
|
||||
description = ''
|
||||
Clans Features that the module implements support for.
|
||||
|
||||
!!! warning "Important"
|
||||
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.
|
||||
'';
|
||||
type = types.listOf (
|
||||
types.enum [
|
||||
"experimental"
|
||||
"inventory"
|
||||
]
|
||||
);
|
||||
};
|
||||
|
||||
constraints = mkOption {
|
||||
default = { };
|
||||
description = ''
|
||||
Constraints 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
|
||||
---
|
||||
```
|
||||
'';
|
||||
type = types.submoduleWith {
|
||||
modules = [
|
||||
|
||||
];
|
||||
};
|
||||
};
|
||||
};
|
||||
}
|
||||
78
lib/modules/inventory/schemas/default.nix
Normal file
78
lib/modules/inventory/schemas/default.nix
Normal file
@@ -0,0 +1,78 @@
|
||||
{
|
||||
self,
|
||||
self',
|
||||
pkgs,
|
||||
flakeOptions,
|
||||
...
|
||||
}:
|
||||
let
|
||||
|
||||
modulesSchema = self.clanLib.modules.getModulesSchema {
|
||||
modules = self.clanModules;
|
||||
inherit pkgs;
|
||||
clan-core = self;
|
||||
};
|
||||
|
||||
jsonLib = self.clanLib.jsonschema { inherit includeDefaults; };
|
||||
includeDefaults = true;
|
||||
|
||||
frontMatterSchema = jsonLib.parseOptions self.clanLib.modules.frontmatterOptions { };
|
||||
|
||||
inventorySchema = jsonLib.parseModule ({
|
||||
imports = [ ../../inventoryClass/interface.nix ];
|
||||
_module.args = { inherit (self) clanLib; };
|
||||
});
|
||||
|
||||
clanSchema = jsonLib.parseOptions (flakeOptions.clan.type.getSubOptions [ "clan" ]) { };
|
||||
|
||||
renderSchema = pkgs.writers.writePython3Bin "render-schema" {
|
||||
flakeIgnore = [
|
||||
"F401"
|
||||
"E501"
|
||||
];
|
||||
} ./render_schema.py;
|
||||
|
||||
clan-schema-abstract = pkgs.stdenv.mkDerivation {
|
||||
name = "clan-schema-files";
|
||||
buildInputs = [ pkgs.cue ];
|
||||
src = ./.;
|
||||
buildPhase = ''
|
||||
export SCHEMA=${builtins.toFile "clan-schema.json" (builtins.toJSON clanSchema)}
|
||||
cp $SCHEMA schema.json
|
||||
# Also generate a CUE schema version that is derived from the JSON schema
|
||||
cue import -f -p compose -l '#Root:' schema.json
|
||||
mkdir $out
|
||||
cp schema.cue $out
|
||||
cp schema.json $out
|
||||
'';
|
||||
};
|
||||
in
|
||||
{
|
||||
inherit
|
||||
flakeOptions
|
||||
frontMatterSchema
|
||||
clanSchema
|
||||
inventorySchema
|
||||
modulesSchema
|
||||
renderSchema
|
||||
clan-schema-abstract
|
||||
;
|
||||
|
||||
# Inventory schema, with the modules schema added per role
|
||||
inventory =
|
||||
pkgs.runCommand "rendered"
|
||||
{
|
||||
buildInputs = [
|
||||
pkgs.python3
|
||||
self'.packages.clan-cli
|
||||
];
|
||||
}
|
||||
''
|
||||
export INVENTORY_SCHEMA_PATH=${builtins.toFile "inventory-schema.json" (builtins.toJSON inventorySchema)}
|
||||
export MODULES_SCHEMA_PATH=${builtins.toFile "modules-schema.json" (builtins.toJSON modulesSchema)}
|
||||
|
||||
mkdir $out
|
||||
# The python script will place the schemas in the output directory
|
||||
exec python3 ${renderSchema}/bin/render-schema
|
||||
'';
|
||||
}
|
||||
162
lib/modules/inventory/schemas/render_schema.py
Normal file
162
lib/modules/inventory/schemas/render_schema.py
Normal file
@@ -0,0 +1,162 @@
|
||||
"""
|
||||
Python script to join the abstract inventory schema, with the concrete clan modules
|
||||
Inventory has slots which are 'Any' type.
|
||||
We dont want to evaluate the clanModules interface in nix, when evaluating the inventory
|
||||
"""
|
||||
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from clan_lib.errors import ClanError
|
||||
|
||||
# Get environment variables
|
||||
INVENTORY_SCHEMA_PATH = Path(os.environ["INVENTORY_SCHEMA_PATH"])
|
||||
|
||||
# { [moduleName] :: { [roleName] :: SCHEMA }}
|
||||
MODULES_SCHEMA_PATH = Path(os.environ["MODULES_SCHEMA_PATH"])
|
||||
|
||||
OUT = os.environ.get("out")
|
||||
|
||||
if not INVENTORY_SCHEMA_PATH:
|
||||
msg = f"Environment variables are not set correctly: INVENTORY_SCHEMA_PATH={INVENTORY_SCHEMA_PATH}."
|
||||
raise ClanError(msg)
|
||||
|
||||
if not MODULES_SCHEMA_PATH:
|
||||
msg = f"Environment variables are not set correctly: MODULES_SCHEMA_PATH={MODULES_SCHEMA_PATH}."
|
||||
raise ClanError(msg)
|
||||
|
||||
if not OUT:
|
||||
msg = f"Environment variables are not set correctly: OUT={OUT}."
|
||||
raise ClanError(msg)
|
||||
|
||||
|
||||
def service_roles_to_schema(
|
||||
schema: dict[str, Any],
|
||||
service_name: str,
|
||||
roles: list[str],
|
||||
roles_schemas: dict[str, dict[str, Any]],
|
||||
# Original service properties: {'config': Schema, 'machines': Schema, 'meta': Schema, 'extraModules': Schema, ...?}
|
||||
orig: dict[str, Any],
|
||||
) -> dict[str, Any]:
|
||||
"""
|
||||
Add roles to the service schema
|
||||
"""
|
||||
# collect all the roles for the service, to form a type union
|
||||
all_roles_schema: list[dict[str, Any]] = []
|
||||
for role_name, role_schema in roles_schemas.items():
|
||||
role_schema["title"] = f"{module_name}-config-role-{role_name}"
|
||||
all_roles_schema.append(role_schema)
|
||||
|
||||
role_schema = {}
|
||||
for role in roles:
|
||||
role_schema[role] = {
|
||||
"type": "object",
|
||||
"additionalProperties": False,
|
||||
"properties": {
|
||||
**orig["roles"]["additionalProperties"]["properties"],
|
||||
"config": {
|
||||
**roles_schemas.get(role, {}),
|
||||
"title": f"{service_name}-config-role-{role}",
|
||||
"type": "object",
|
||||
"default": {},
|
||||
"additionalProperties": False,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
machines_schema = {
|
||||
"type": "object",
|
||||
"additionalProperties": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
**orig["machines"]["additionalProperties"]["properties"],
|
||||
"config": {
|
||||
"title": f"{service_name}-config",
|
||||
"oneOf": all_roles_schema,
|
||||
"type": "object",
|
||||
"default": {},
|
||||
"additionalProperties": False,
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
services["properties"][service_name] = {
|
||||
"type": "object",
|
||||
"additionalProperties": {
|
||||
"type": "object",
|
||||
"additionalProperties": False,
|
||||
"properties": {
|
||||
# Original inventory schema
|
||||
**orig,
|
||||
# Inject the roles schemas
|
||||
"roles": {
|
||||
"title": f"{service_name}-roles",
|
||||
"type": "object",
|
||||
"properties": role_schema,
|
||||
"additionalProperties": False,
|
||||
},
|
||||
"machines": machines_schema,
|
||||
"config": {
|
||||
"title": f"{service_name}-config",
|
||||
"oneOf": all_roles_schema,
|
||||
"type": "object",
|
||||
"default": {},
|
||||
"additionalProperties": False,
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
return schema
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
print("Joining inventory schema with modules schema")
|
||||
print(f"Inventory schema path: {INVENTORY_SCHEMA_PATH}")
|
||||
print(f"Modules schema path: {MODULES_SCHEMA_PATH}")
|
||||
|
||||
modules_schema = {}
|
||||
with Path.open(MODULES_SCHEMA_PATH) as f:
|
||||
modules_schema = json.load(f)
|
||||
|
||||
inventory_schema = {}
|
||||
with Path.open(INVENTORY_SCHEMA_PATH) as f:
|
||||
inventory_schema = json.load(f)
|
||||
|
||||
services = inventory_schema["properties"]["services"]
|
||||
original_service_props = services["additionalProperties"]["additionalProperties"][
|
||||
"properties"
|
||||
].copy()
|
||||
# Init the outer services schema
|
||||
# Properties (service names) will be filled in the next step
|
||||
services = {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
# Service names
|
||||
},
|
||||
"additionalProperties": False,
|
||||
}
|
||||
|
||||
for module_name, roles_schemas in modules_schema.items():
|
||||
# Add the roles schemas to the service schema
|
||||
roles = list(roles_schemas.keys())
|
||||
if roles:
|
||||
services = service_roles_to_schema(
|
||||
services,
|
||||
module_name,
|
||||
roles,
|
||||
roles_schemas,
|
||||
original_service_props,
|
||||
)
|
||||
|
||||
inventory_schema["properties"]["services"] = services
|
||||
|
||||
outpath = Path(OUT)
|
||||
with (outpath / "schema.json").open("w") as f:
|
||||
json.dump(inventory_schema, f, indent=2)
|
||||
|
||||
with (outpath / "modules_schemas.json").open("w") as f:
|
||||
json.dump(modules_schema, f, indent=2)
|
||||
294
lib/modules/inventory/tests/default.nix
Normal file
294
lib/modules/inventory/tests/default.nix
Normal file
@@ -0,0 +1,294 @@
|
||||
{ clan-core, lib, ... }:
|
||||
let
|
||||
inventory = (
|
||||
import ../build-inventory {
|
||||
inherit lib;
|
||||
clanLib = clan-core.clanLib;
|
||||
}
|
||||
);
|
||||
inherit (inventory) buildInventory;
|
||||
in
|
||||
{
|
||||
test_inventory_a =
|
||||
let
|
||||
compiled = buildInventory {
|
||||
flakeInputs = { };
|
||||
inventory = {
|
||||
machines = {
|
||||
A = { };
|
||||
};
|
||||
services = {
|
||||
legacyModule = { };
|
||||
};
|
||||
modules = {
|
||||
legacyModule = ./legacyModule;
|
||||
};
|
||||
};
|
||||
directory = ./.;
|
||||
};
|
||||
in
|
||||
{
|
||||
expr = {
|
||||
legacyModule = lib.filterAttrs (
|
||||
name: _: name == "isClanModule"
|
||||
) compiled.machines.A.compiledServices.legacyModule;
|
||||
};
|
||||
expected = {
|
||||
legacyModule = {
|
||||
};
|
||||
};
|
||||
};
|
||||
|
||||
test_inventory_empty =
|
||||
let
|
||||
compiled = buildInventory {
|
||||
flakeInputs = { };
|
||||
inventory = { };
|
||||
directory = ./.;
|
||||
};
|
||||
in
|
||||
{
|
||||
# Empty inventory should return an empty module
|
||||
expr = compiled.machines;
|
||||
expected = { };
|
||||
};
|
||||
test_inventory_role_resolve =
|
||||
let
|
||||
compiled = buildInventory {
|
||||
flakeInputs = { };
|
||||
directory = ./.;
|
||||
inventory = {
|
||||
modules = clan-core.clanModules;
|
||||
services = {
|
||||
borgbackup.instance_1 = {
|
||||
roles.server.machines = [ "backup_server" ];
|
||||
roles.client.machines = [
|
||||
"client_1_machine"
|
||||
"client_2_machine"
|
||||
];
|
||||
};
|
||||
};
|
||||
machines = {
|
||||
"backup_server" = { };
|
||||
"client_1_machine" = { };
|
||||
"client_2_machine" = { };
|
||||
};
|
||||
};
|
||||
};
|
||||
in
|
||||
{
|
||||
expr = {
|
||||
m1 = (compiled.machines."backup_server").compiledServices.borgbackup.matchedRoles;
|
||||
m2 = (compiled.machines."client_1_machine").compiledServices.borgbackup.matchedRoles;
|
||||
m3 = (compiled.machines."client_2_machine").compiledServices.borgbackup.matchedRoles;
|
||||
inherit ((compiled.machines."client_2_machine").compiledServices.borgbackup)
|
||||
resolvedRolesPerInstance
|
||||
;
|
||||
};
|
||||
|
||||
expected = {
|
||||
m1 = [
|
||||
"server"
|
||||
];
|
||||
m2 = [
|
||||
"client"
|
||||
];
|
||||
m3 = [
|
||||
"client"
|
||||
];
|
||||
resolvedRolesPerInstance = {
|
||||
instance_1 = {
|
||||
client = {
|
||||
machines = [
|
||||
"client_1_machine"
|
||||
"client_2_machine"
|
||||
];
|
||||
};
|
||||
server = {
|
||||
machines = [ "backup_server" ];
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
test_inventory_tag_resolve =
|
||||
let
|
||||
configs = buildInventory {
|
||||
flakeInputs = { };
|
||||
directory = ./.;
|
||||
inventory = {
|
||||
modules = clan-core.clanModules;
|
||||
services = {
|
||||
borgbackup.instance_1 = {
|
||||
roles.client.tags = [ "backup" ];
|
||||
};
|
||||
};
|
||||
machines = {
|
||||
"not_used_machine" = { };
|
||||
"client_1_machine" = {
|
||||
tags = [ "backup" ];
|
||||
};
|
||||
"client_2_machine" = {
|
||||
tags = [ "backup" ];
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
in
|
||||
{
|
||||
expr = configs.machines.client_1_machine.compiledServices.borgbackup.resolvedRolesPerInstance;
|
||||
expected = {
|
||||
instance_1 = {
|
||||
client = {
|
||||
machines = [
|
||||
"client_1_machine"
|
||||
"client_2_machine"
|
||||
];
|
||||
};
|
||||
server = {
|
||||
machines = [ ];
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
|
||||
test_inventory_multiple_roles =
|
||||
let
|
||||
configs = buildInventory {
|
||||
flakeInputs = { };
|
||||
directory = ./.;
|
||||
inventory = {
|
||||
modules = clan-core.clanModules;
|
||||
services = {
|
||||
borgbackup.instance_1 = {
|
||||
roles.client.machines = [ "machine_1" ];
|
||||
roles.server.machines = [ "machine_1" ];
|
||||
};
|
||||
};
|
||||
machines = {
|
||||
"machine_1" = { };
|
||||
};
|
||||
};
|
||||
};
|
||||
in
|
||||
{
|
||||
expr = configs.machines.machine_1.compiledServices.borgbackup.matchedRoles;
|
||||
expected = [
|
||||
"client"
|
||||
"server"
|
||||
];
|
||||
};
|
||||
|
||||
test_inventory_module_doesnt_exist =
|
||||
let
|
||||
configs = buildInventory {
|
||||
flakeInputs = { };
|
||||
directory = ./.;
|
||||
inventory = {
|
||||
modules = clan-core.clanModules;
|
||||
services = {
|
||||
fanatasy.instance_1 = {
|
||||
roles.default.machines = [ "machine_1" ];
|
||||
};
|
||||
};
|
||||
machines = {
|
||||
"machine_1" = { };
|
||||
};
|
||||
};
|
||||
};
|
||||
in
|
||||
{
|
||||
inherit configs;
|
||||
expr = configs.machines.machine_1.machineImports;
|
||||
expectedError = {
|
||||
type = "ThrownError";
|
||||
msg = "ClanModule not found*";
|
||||
};
|
||||
};
|
||||
|
||||
test_inventory_role_doesnt_exist =
|
||||
let
|
||||
configs = buildInventory {
|
||||
flakeInputs = { };
|
||||
directory = ./.;
|
||||
inventory = {
|
||||
modules = clan-core.clanModules;
|
||||
services = {
|
||||
borgbackup.instance_1 = {
|
||||
roles.roleXYZ.machines = [ "machine_1" ];
|
||||
};
|
||||
};
|
||||
machines = {
|
||||
"machine_1" = { };
|
||||
};
|
||||
};
|
||||
};
|
||||
in
|
||||
{
|
||||
inherit configs;
|
||||
expr = configs.machines.machine_1.machineImports;
|
||||
expectedError = {
|
||||
type = "ThrownError";
|
||||
msg = ''Roles \["roleXYZ"\] are not defined in the service borgbackup'';
|
||||
};
|
||||
};
|
||||
# Needs NIX_ABORT_ON_WARN=1
|
||||
# So the lib.warn is turned into abort
|
||||
test_inventory_tag_doesnt_exist =
|
||||
let
|
||||
configs = buildInventory {
|
||||
flakeInputs = { };
|
||||
directory = ./.;
|
||||
inventory = {
|
||||
modules = clan-core.clanModules;
|
||||
services = {
|
||||
borgbackup.instance_1 = {
|
||||
roles.client.machines = [ "machine_1" ];
|
||||
roles.client.tags = [ "tagXYZ" ];
|
||||
};
|
||||
};
|
||||
machines = {
|
||||
"machine_1" = {
|
||||
tags = [ "tagABC" ];
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
in
|
||||
{
|
||||
expr = configs.machines.machine_1.machineImports;
|
||||
expectedError = {
|
||||
type = "Error";
|
||||
# TODO: Add warning matching in nix-unit
|
||||
msg = ".*";
|
||||
};
|
||||
};
|
||||
test_inventory_disabled_service =
|
||||
let
|
||||
configs = buildInventory {
|
||||
flakeInputs = { };
|
||||
directory = ./.;
|
||||
inventory = {
|
||||
modules = clan-core.clanModules;
|
||||
services = {
|
||||
borgbackup.instance_1 = {
|
||||
enabled = false;
|
||||
roles.client.machines = [ "machine_1" ];
|
||||
};
|
||||
};
|
||||
machines = {
|
||||
"machine_1" = {
|
||||
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
in
|
||||
{
|
||||
inherit configs;
|
||||
expr = builtins.filter (
|
||||
v: v != { } && !v.clan.inventory.assertions ? "alive.assertion.inventory"
|
||||
) configs.machines.machine_1.machineImports;
|
||||
expected = [ ];
|
||||
};
|
||||
}
|
||||
4
lib/modules/inventory/tests/legacyModule/README.md
Normal file
4
lib/modules/inventory/tests/legacyModule/README.md
Normal file
@@ -0,0 +1,4 @@
|
||||
---
|
||||
features = [ "inventory" ]
|
||||
---
|
||||
Description
|
||||
@@ -0,0 +1,9 @@
|
||||
{
|
||||
lib,
|
||||
clan-core,
|
||||
...
|
||||
}:
|
||||
{
|
||||
# Just some random stuff
|
||||
options.test = lib.mapAttrs clan-core;
|
||||
}
|
||||
78
lib/modules/inventoryClass/assertions.nix
Normal file
78
lib/modules/inventoryClass/assertions.nix
Normal file
@@ -0,0 +1,78 @@
|
||||
# Integrity validation of the inventory
|
||||
{ config, lib, ... }:
|
||||
{
|
||||
# Assertion must be of type
|
||||
# { assertion :: bool, message :: string, severity :: "error" | "warning" }
|
||||
imports = [
|
||||
# Check that each machine used in a service is defined in the top-level machines
|
||||
{
|
||||
assertions = lib.foldlAttrs (
|
||||
ass1: serviceName: c:
|
||||
ass1
|
||||
++ lib.foldlAttrs (
|
||||
ass2: instanceName: instanceConfig:
|
||||
let
|
||||
topLevelMachines = lib.attrNames config.machines;
|
||||
# All machines must be defined in the top-level machines
|
||||
assertions = lib.foldlAttrs (
|
||||
assertions: roleName: role:
|
||||
assertions
|
||||
++ builtins.filter (a: !a.assertion) (
|
||||
builtins.map (m: {
|
||||
assertion = builtins.elem m topLevelMachines;
|
||||
message = ''
|
||||
Machine '${m}' is not defined in the inventory. This might still work, if the machine is defined via nix.
|
||||
|
||||
Defined in service: '${serviceName}' instance: '${instanceName}' role: '${roleName}'.
|
||||
|
||||
Inventory machines:
|
||||
${builtins.concatStringsSep "\n" (map (n: "'${n}'") topLevelMachines)}
|
||||
'';
|
||||
severity = "warning";
|
||||
}) role.machines
|
||||
)
|
||||
) [ ] instanceConfig.roles;
|
||||
in
|
||||
ass2 ++ assertions
|
||||
) [ ] c
|
||||
) [ ] config.services;
|
||||
}
|
||||
# Check that each tag used in a role is defined in at least one machines tags
|
||||
{
|
||||
assertions = lib.foldlAttrs (
|
||||
ass1: serviceName: c:
|
||||
ass1
|
||||
++ lib.foldlAttrs (
|
||||
ass2: instanceName: instanceConfig:
|
||||
let
|
||||
allTags = lib.foldlAttrs (
|
||||
tags: _machineName: machine:
|
||||
tags ++ machine.tags
|
||||
) [ ] config.machines;
|
||||
# All machines must be defined in the top-level machines
|
||||
assertions = lib.foldlAttrs (
|
||||
assertions: roleName: role:
|
||||
assertions
|
||||
++ builtins.filter (a: !a.assertion) (
|
||||
builtins.map (m: {
|
||||
assertion = builtins.elem m allTags;
|
||||
message = ''
|
||||
Tag '${m}' is not defined in the inventory.
|
||||
|
||||
Defined in service: '${serviceName}' instance: '${instanceName}' role: '${roleName}'.
|
||||
|
||||
Available tags:
|
||||
${builtins.concatStringsSep "\n" (map (n: "'${n}'") allTags)}
|
||||
'';
|
||||
severity = "error";
|
||||
}) role.tags
|
||||
)
|
||||
) [ ] instanceConfig.roles;
|
||||
in
|
||||
ass2 ++ assertions
|
||||
) [ ] c
|
||||
) [ ] config.services;
|
||||
}
|
||||
];
|
||||
|
||||
}
|
||||
268
lib/modules/inventoryClass/builder/default.nix
Normal file
268
lib/modules/inventoryClass/builder/default.nix
Normal file
@@ -0,0 +1,268 @@
|
||||
{
|
||||
lib,
|
||||
config,
|
||||
clanLib,
|
||||
...
|
||||
}:
|
||||
let
|
||||
inherit (config) inventory directory;
|
||||
resolveTags =
|
||||
# Inventory, { machines :: [string], tags :: [string] }
|
||||
{
|
||||
serviceName,
|
||||
instanceName,
|
||||
roleName,
|
||||
inventory,
|
||||
members,
|
||||
}:
|
||||
{
|
||||
machines =
|
||||
members.machines or [ ]
|
||||
++ (builtins.foldl' (
|
||||
acc: tag:
|
||||
let
|
||||
# For error printing
|
||||
availableTags = lib.foldlAttrs (
|
||||
acc: _: v:
|
||||
v.tags or [ ] ++ acc
|
||||
) [ ] (inventory.machines);
|
||||
|
||||
tagMembers = builtins.attrNames (
|
||||
lib.filterAttrs (_n: v: builtins.elem tag v.tags or [ ]) inventory.machines
|
||||
);
|
||||
in
|
||||
if tagMembers == [ ] then
|
||||
lib.warn ''
|
||||
inventory.services.${serviceName}.${instanceName}: - ${roleName} tags: no machine with tag '${tag}' found.
|
||||
Available tags: ${builtins.toJSON (lib.unique availableTags)}
|
||||
'' [ ]
|
||||
else
|
||||
acc ++ tagMembers
|
||||
) [ ] members.tags or [ ]);
|
||||
};
|
||||
|
||||
checkService =
|
||||
modulepath: serviceName:
|
||||
builtins.elem "inventory" (clanLib.modules.getFrontmatter modulepath serviceName).features or [ ];
|
||||
|
||||
compileMachine =
|
||||
{ machineConfig }:
|
||||
{
|
||||
machineImports = [
|
||||
(lib.optionalAttrs (machineConfig.deploy.targetHost or null != null) {
|
||||
config.clan.core.networking.targetHost = lib.mkForce machineConfig.deploy.targetHost;
|
||||
})
|
||||
(lib.optionalAttrs (machineConfig.deploy.buildHost or null != null) {
|
||||
config.clan.core.networking.buildHost = lib.mkForce machineConfig.deploy.buildHost;
|
||||
})
|
||||
];
|
||||
assertions = { };
|
||||
};
|
||||
|
||||
resolveImports =
|
||||
{
|
||||
supportedRoles,
|
||||
resolvedRolesPerInstance,
|
||||
serviceConfigs,
|
||||
serviceName,
|
||||
machineName,
|
||||
getRoleFile,
|
||||
}:
|
||||
(lib.foldlAttrs (
|
||||
# : [ Modules ] -> String -> ServiceConfig -> [ Modules ]
|
||||
acc2: instanceName: serviceConfig:
|
||||
let
|
||||
resolvedRoles = resolvedRolesPerInstance.${instanceName};
|
||||
|
||||
isInService = builtins.any (members: builtins.elem machineName members.machines) (
|
||||
builtins.attrValues resolvedRoles
|
||||
);
|
||||
|
||||
# all roles where the machine is present
|
||||
machineRoles = builtins.attrNames (
|
||||
lib.filterAttrs (_role: roleConfig: builtins.elem machineName roleConfig.machines) resolvedRoles
|
||||
);
|
||||
|
||||
machineServiceConfig = (serviceConfig.machines.${machineName} or { }).config or { };
|
||||
globalConfig = serviceConfig.config or { };
|
||||
|
||||
globalExtraModules = serviceConfig.extraModules or [ ];
|
||||
machineExtraModules = serviceConfig.machines.${machineName}.extraModules or [ ];
|
||||
roleServiceExtraModules = builtins.foldl' (
|
||||
acc: role: acc ++ serviceConfig.roles.${role}.extraModules or [ ]
|
||||
) [ ] machineRoles;
|
||||
|
||||
# TODO: maybe optimize this don't lookup the role in inverse roles. Imports are not lazy
|
||||
roleModules = builtins.map (
|
||||
role:
|
||||
if builtins.elem role supportedRoles && inventory.modules ? ${serviceName} then
|
||||
getRoleFile role
|
||||
else
|
||||
throw "Module ${serviceName} doesn't have role: '${role}'. Role: ${
|
||||
inventory.modules.${serviceName}
|
||||
}/roles/${role}.nix not found."
|
||||
) machineRoles;
|
||||
|
||||
roleServiceConfigs = builtins.filter (m: m != { }) (
|
||||
builtins.map (role: serviceConfig.roles.${role}.config or { }) machineRoles
|
||||
);
|
||||
|
||||
extraModules = map (s: if builtins.typeOf s == "string" then "${directory}/${s}" else s) (
|
||||
globalExtraModules ++ machineExtraModules ++ roleServiceExtraModules
|
||||
);
|
||||
|
||||
features =
|
||||
(clanLib.modules.getFrontmatter inventory.modules.${serviceName} serviceName).features or [ ];
|
||||
deprecationWarning = lib.optionalAttrs (builtins.elem "deprecated" features) {
|
||||
warnings = [
|
||||
''
|
||||
The '${serviceName}' module has been migrated from `inventory.services` to `inventory.instances`
|
||||
See https://docs.clan.lol/guides/clanServices/ for usage.
|
||||
''
|
||||
];
|
||||
};
|
||||
in
|
||||
if !(serviceConfig.enabled or true) then
|
||||
acc2
|
||||
else if isInService then
|
||||
acc2
|
||||
++ [
|
||||
deprecationWarning
|
||||
{
|
||||
imports = roleModules ++ extraModules;
|
||||
clan.inventory.services.${serviceName}.${instanceName} = {
|
||||
roles = resolvedRoles;
|
||||
# TODO: Add inverseRoles to the service config if needed
|
||||
# inherit inverseRoles;
|
||||
};
|
||||
}
|
||||
(lib.optionalAttrs (globalConfig != { } || machineServiceConfig != { } || roleServiceConfigs != [ ])
|
||||
{
|
||||
clan.${serviceName} = lib.mkMerge (
|
||||
[
|
||||
globalConfig
|
||||
machineServiceConfig
|
||||
]
|
||||
++ roleServiceConfigs
|
||||
);
|
||||
}
|
||||
)
|
||||
]
|
||||
else
|
||||
acc2
|
||||
) [ ] (serviceConfigs));
|
||||
in
|
||||
{
|
||||
imports = [
|
||||
./interface.nix
|
||||
];
|
||||
config = {
|
||||
machines = builtins.mapAttrs (
|
||||
machineName: machineConfig: m:
|
||||
let
|
||||
compiledServices = lib.mapAttrs (
|
||||
_: serviceConfigs:
|
||||
(
|
||||
{ config, ... }:
|
||||
let
|
||||
serviceName = config.serviceName;
|
||||
|
||||
getRoleFile = role: builtins.seq role inventory.modules.${serviceName} + "/roles/${role}.nix";
|
||||
in
|
||||
{
|
||||
_file = "inventory/builder.nix";
|
||||
_module.args = {
|
||||
inherit
|
||||
resolveTags
|
||||
inventory
|
||||
clanLib
|
||||
machineName
|
||||
serviceConfigs
|
||||
;
|
||||
};
|
||||
imports = [
|
||||
./roles.nix
|
||||
];
|
||||
|
||||
machineImports = resolveImports {
|
||||
supportedRoles = config.supportedRoles;
|
||||
resolvedRolesPerInstance = config.resolvedRolesPerInstance;
|
||||
inherit
|
||||
serviceConfigs
|
||||
serviceName
|
||||
machineName
|
||||
getRoleFile
|
||||
;
|
||||
};
|
||||
|
||||
# Assertions
|
||||
assertions = {
|
||||
"checkservice.${serviceName}" = {
|
||||
assertion = checkService inventory.modules.${serviceName} serviceName;
|
||||
message = ''
|
||||
Service ${serviceName} cannot be used in inventory. It does not declare the 'inventory' feature.
|
||||
|
||||
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.
|
||||
|
||||
'';
|
||||
};
|
||||
};
|
||||
}
|
||||
)
|
||||
) (config.inventory.services or { });
|
||||
|
||||
compiledMachine = compileMachine {
|
||||
inherit
|
||||
machineConfig
|
||||
;
|
||||
};
|
||||
|
||||
machineImports = (
|
||||
compiledMachine.machineImports
|
||||
++ builtins.foldl' (
|
||||
acc: service:
|
||||
let
|
||||
failedAssertions = (lib.filterAttrs (_: v: !v.assertion) service.assertions);
|
||||
failedAssertionsImports =
|
||||
if failedAssertions != { } then
|
||||
[
|
||||
{
|
||||
clan.inventory.assertions = failedAssertions;
|
||||
}
|
||||
]
|
||||
else
|
||||
[
|
||||
{
|
||||
clan.inventory.assertions = {
|
||||
"alive.assertion.inventory" = {
|
||||
assertion = true;
|
||||
message = ''
|
||||
No failed assertions found for machine ${machineName}. This will never be displayed.
|
||||
It is here for testing purposes.
|
||||
'';
|
||||
};
|
||||
};
|
||||
}
|
||||
];
|
||||
in
|
||||
acc
|
||||
++ service.machineImports
|
||||
# Import failed assertions
|
||||
++ failedAssertionsImports
|
||||
) [ ] (builtins.attrValues m.config.compiledServices)
|
||||
);
|
||||
in
|
||||
{
|
||||
inherit machineImports compiledServices compiledMachine;
|
||||
}
|
||||
) (inventory.machines or { });
|
||||
};
|
||||
}
|
||||
91
lib/modules/inventoryClass/builder/interface.nix
Normal file
91
lib/modules/inventoryClass/builder/interface.nix
Normal file
@@ -0,0 +1,91 @@
|
||||
{ lib, ... }:
|
||||
let
|
||||
inherit (lib) types mkOption;
|
||||
submodule = m: types.submoduleWith { modules = [ m ]; };
|
||||
|
||||
in
|
||||
{
|
||||
options = {
|
||||
directory = mkOption {
|
||||
type = types.path;
|
||||
};
|
||||
distributedServices = mkOption {
|
||||
type = types.raw;
|
||||
};
|
||||
inventory = mkOption {
|
||||
type = types.raw;
|
||||
};
|
||||
machines = mkOption {
|
||||
type = types.attrsOf (
|
||||
submodule (
|
||||
{ name, ... }:
|
||||
let
|
||||
machineName = name;
|
||||
in
|
||||
{
|
||||
options = {
|
||||
compiledMachine = mkOption {
|
||||
type = types.raw;
|
||||
};
|
||||
compiledServices = mkOption {
|
||||
# type = types.attrsOf;
|
||||
type = types.attrsOf (
|
||||
types.submoduleWith {
|
||||
modules = [
|
||||
(
|
||||
{ name, ... }:
|
||||
let
|
||||
serviceName = name;
|
||||
in
|
||||
{
|
||||
options = {
|
||||
machineName = mkOption {
|
||||
default = machineName;
|
||||
readOnly = true;
|
||||
};
|
||||
serviceName = mkOption {
|
||||
default = serviceName;
|
||||
readOnly = true;
|
||||
};
|
||||
# Outputs
|
||||
machineImports = mkOption {
|
||||
type = types.listOf types.raw;
|
||||
};
|
||||
supportedRoles = mkOption {
|
||||
type = types.listOf types.str;
|
||||
};
|
||||
matchedRoles = mkOption {
|
||||
type = types.listOf types.str;
|
||||
};
|
||||
machinesRoles = mkOption {
|
||||
type = types.attrsOf (types.listOf types.str);
|
||||
};
|
||||
resolvedRolesPerInstance = mkOption {
|
||||
type = types.attrsOf (
|
||||
types.attrsOf (submodule {
|
||||
options.machines = mkOption {
|
||||
type = types.listOf types.str;
|
||||
};
|
||||
})
|
||||
);
|
||||
};
|
||||
assertions = mkOption {
|
||||
type = types.attrsOf types.raw;
|
||||
};
|
||||
};
|
||||
}
|
||||
)
|
||||
];
|
||||
}
|
||||
);
|
||||
};
|
||||
machineImports = mkOption {
|
||||
type = types.listOf types.raw;
|
||||
};
|
||||
};
|
||||
}
|
||||
)
|
||||
);
|
||||
};
|
||||
};
|
||||
}
|
||||
65
lib/modules/inventoryClass/builder/roles.nix
Normal file
65
lib/modules/inventoryClass/builder/roles.nix
Normal file
@@ -0,0 +1,65 @@
|
||||
{
|
||||
lib,
|
||||
config,
|
||||
resolveTags,
|
||||
inventory,
|
||||
clanLib,
|
||||
machineName,
|
||||
serviceConfigs,
|
||||
...
|
||||
}:
|
||||
let
|
||||
serviceName = config.serviceName;
|
||||
in
|
||||
{
|
||||
# Roles resolution
|
||||
# : List String
|
||||
supportedRoles = clanLib.modules.getRoles "inventory.modules" inventory.modules serviceName;
|
||||
matchedRoles = builtins.attrNames (
|
||||
lib.filterAttrs (_: ms: builtins.elem machineName ms) config.machinesRoles
|
||||
);
|
||||
resolvedRolesPerInstance = lib.mapAttrs (
|
||||
instanceName: instanceConfig:
|
||||
let
|
||||
resolvedRoles = lib.genAttrs config.supportedRoles (
|
||||
roleName:
|
||||
resolveTags {
|
||||
members = instanceConfig.roles.${roleName} or { };
|
||||
inherit
|
||||
instanceName
|
||||
serviceName
|
||||
roleName
|
||||
inventory
|
||||
;
|
||||
}
|
||||
);
|
||||
usedRoles = builtins.attrNames instanceConfig.roles;
|
||||
unmatchedRoles = builtins.filter (role: !builtins.elem role config.supportedRoles) usedRoles;
|
||||
in
|
||||
if unmatchedRoles != [ ] then
|
||||
throw ''
|
||||
Roles ${builtins.toJSON unmatchedRoles} are not defined in the service ${serviceName}.
|
||||
Instance: '${instanceName}'
|
||||
Please use one of available roles: ${builtins.toJSON config.supportedRoles}
|
||||
''
|
||||
else
|
||||
resolvedRoles
|
||||
) serviceConfigs;
|
||||
|
||||
machinesRoles = builtins.zipAttrsWith (
|
||||
_n: vs:
|
||||
let
|
||||
flat = builtins.foldl' (acc: s: acc ++ s.machines) [ ] vs;
|
||||
in
|
||||
lib.unique flat
|
||||
) (builtins.attrValues config.resolvedRolesPerInstance);
|
||||
|
||||
assertions = lib.concatMapAttrs (
|
||||
instanceName: resolvedRoles:
|
||||
clanLib.modules.checkConstraints {
|
||||
moduleName = serviceName;
|
||||
allModules = inventory.modules;
|
||||
inherit resolvedRoles instanceName;
|
||||
}
|
||||
) config.resolvedRolesPerInstance;
|
||||
}
|
||||
49
lib/modules/inventoryClass/default.nix
Normal file
49
lib/modules/inventoryClass/default.nix
Normal file
@@ -0,0 +1,49 @@
|
||||
# Generate partial NixOS configurations for every machine in the inventory
|
||||
# This function is responsible for generating the module configuration for every machine in the inventory.
|
||||
{ lib, clanLib }:
|
||||
let
|
||||
/*
|
||||
Returns a set with NixOS configuration for every machine in the inventory.
|
||||
|
||||
machinesFromInventory :: Inventory -> { ${machine_name} :: NixOSConfiguration }
|
||||
*/
|
||||
buildInventory =
|
||||
{
|
||||
inventory,
|
||||
directory,
|
||||
flakeInputs,
|
||||
prefix ? [ ],
|
||||
localModuleSet ? { },
|
||||
}:
|
||||
(lib.evalModules {
|
||||
# TODO: move clanLib from specialArgs to options
|
||||
specialArgs = {
|
||||
inherit clanLib;
|
||||
};
|
||||
modules = [
|
||||
./builder/default.nix
|
||||
(lib.modules.importApply ./service-list-from-inputs.nix {
|
||||
inherit flakeInputs clanLib localModuleSet;
|
||||
})
|
||||
{ inherit directory inventory; }
|
||||
(
|
||||
# config.distributedServices.allMachines.${name} or [ ];
|
||||
{ config, ... }:
|
||||
{
|
||||
distributedServices = clanLib.inventory.mapInstances {
|
||||
inherit (config) inventory;
|
||||
inherit localModuleSet;
|
||||
inherit flakeInputs;
|
||||
prefix = prefix ++ [ "distributedServices" ];
|
||||
};
|
||||
machines = config.distributedServices.allMachines;
|
||||
|
||||
}
|
||||
)
|
||||
(lib.modules.importApply ./inventory-introspection.nix { inherit clanLib; })
|
||||
];
|
||||
}).config;
|
||||
in
|
||||
{
|
||||
inherit buildInventory;
|
||||
}
|
||||
598
lib/modules/inventoryClass/interface.nix
Normal file
598
lib/modules/inventoryClass/interface.nix
Normal file
@@ -0,0 +1,598 @@
|
||||
{
|
||||
lib,
|
||||
clanLib,
|
||||
config,
|
||||
options,
|
||||
...
|
||||
}:
|
||||
let
|
||||
types = lib.types;
|
||||
|
||||
metaOptionsWith = name: {
|
||||
name = lib.mkOption {
|
||||
type = types.str;
|
||||
default = name;
|
||||
description = ''
|
||||
Name of the machine or service
|
||||
'';
|
||||
};
|
||||
description = lib.mkOption {
|
||||
default = null;
|
||||
type = types.nullOr types.str;
|
||||
description = ''
|
||||
Optional freeform description
|
||||
'';
|
||||
};
|
||||
icon = lib.mkOption {
|
||||
default = null;
|
||||
type = types.nullOr types.str;
|
||||
description = ''
|
||||
Under construction, will be used for the UI
|
||||
'';
|
||||
};
|
||||
};
|
||||
|
||||
moduleConfig = lib.mkOption {
|
||||
default = { };
|
||||
# TODO: use types.deferredModule
|
||||
# clan.borgbackup MUST be defined as submodule
|
||||
type = types.attrsOf types.anything;
|
||||
description = ''
|
||||
Configuration of the specific clanModule.
|
||||
|
||||
!!! Note
|
||||
Configuration is passed to the nixos configuration scoped to the module.
|
||||
|
||||
```nix
|
||||
clan.<serviceName> = { ... # Config }
|
||||
```
|
||||
'';
|
||||
};
|
||||
|
||||
extraModulesOption = lib.mkOption {
|
||||
description = ''
|
||||
List of additionally imported `.nix` expressions.
|
||||
|
||||
Supported types:
|
||||
|
||||
- **Strings**: Interpreted relative to the 'directory' passed to buildClan.
|
||||
- **Paths**: should be relative to the current file.
|
||||
- **Any**: Nix expression must be serializable to JSON.
|
||||
|
||||
!!! Note
|
||||
**The import only happens if the machine is part of the service or role.**
|
||||
|
||||
Other types are passed through to the nixos configuration.
|
||||
|
||||
???+ Example
|
||||
To import the `special.nix` file
|
||||
|
||||
```
|
||||
. Clan Directory
|
||||
├── flake.nix
|
||||
...
|
||||
└── modules
|
||||
├── special.nix
|
||||
└── ...
|
||||
```
|
||||
|
||||
```nix
|
||||
{
|
||||
extraModules = [ "modules/special.nix" ];
|
||||
}
|
||||
```
|
||||
'';
|
||||
apply = value: if lib.isString value then value else builtins.seq (builtins.toJSON value) value;
|
||||
default = [ ];
|
||||
type = types.listOf (
|
||||
types.oneOf [
|
||||
types.str
|
||||
types.anything
|
||||
]
|
||||
);
|
||||
};
|
||||
in
|
||||
{
|
||||
imports = [
|
||||
./assertions.nix
|
||||
];
|
||||
options = {
|
||||
# Internal things
|
||||
_inventoryFile = lib.mkOption {
|
||||
type = types.path;
|
||||
readOnly = true;
|
||||
internal = true;
|
||||
visible = false;
|
||||
};
|
||||
_legacyModules = lib.mkOption {
|
||||
internal = true;
|
||||
visible = false;
|
||||
default = { };
|
||||
};
|
||||
noInstanceOptions = lib.mkOption {
|
||||
type = types.bool;
|
||||
internal = true;
|
||||
visible = false;
|
||||
default = false;
|
||||
};
|
||||
|
||||
options = lib.mkOption {
|
||||
internal = true;
|
||||
visible = false;
|
||||
type = types.raw;
|
||||
default = options;
|
||||
};
|
||||
# ---------------------------
|
||||
|
||||
modules = lib.mkOption {
|
||||
# Don't define the type yet
|
||||
# We manually transform the value with types.deferredModule.merge later to keep them serializable
|
||||
type = types.attrsOf types.raw;
|
||||
default = { };
|
||||
defaultText = "clanModules of clan-core";
|
||||
description = ''
|
||||
A mapping of module names to their path.
|
||||
|
||||
Each module can be referenced by its `attributeName` in the `inventory.services` attribute set.
|
||||
|
||||
!!! Important
|
||||
Each module MUST fulfill the following requirements to be usable with the inventory:
|
||||
|
||||
- The module MUST have a `README.md` file with a `description`.
|
||||
- The module MUST have at least `features = [ "inventory" ]` in the frontmatter section.
|
||||
- The module MUST have a subfolder `roles` with at least one `{roleName}.nix` file.
|
||||
|
||||
For further information see: [Module Authoring Guide](../../guides/authoring/clanServices/index.md).
|
||||
|
||||
???+ example
|
||||
```nix
|
||||
buildClan {
|
||||
# 1. Add the module to the available inventory modules
|
||||
inventory.modules = {
|
||||
custom-module = ./modules/my_module;
|
||||
};
|
||||
# 2. Use the module in the inventory
|
||||
inventory.services = {
|
||||
custom-module.instance_1 = {
|
||||
roles.default.machines = [ "machineA" ];
|
||||
};
|
||||
};
|
||||
};
|
||||
```
|
||||
'';
|
||||
|
||||
apply =
|
||||
moduleSet:
|
||||
let
|
||||
allowedNames = lib.attrNames config._legacyModules;
|
||||
in
|
||||
if builtins.all (moduleName: builtins.elem moduleName allowedNames) (lib.attrNames moduleSet) then
|
||||
moduleSet
|
||||
else
|
||||
lib.warn ''
|
||||
`inventory.modules` will be deprecated soon.
|
||||
|
||||
Please migrate the following modules into `clan.service` modules
|
||||
and register them in `clan.modules`
|
||||
|
||||
${lib.concatStringsSep "\n" (
|
||||
map (m: "'${m}'") (lib.attrNames (lib.filterAttrs (n: _v: !builtins.elem n allowedNames) moduleSet))
|
||||
)}
|
||||
|
||||
See: https://docs.clan.lol/guides/clanServices/
|
||||
And: https://docs.clan.lol/guides/authoring/clanServices/
|
||||
'' moduleSet;
|
||||
};
|
||||
|
||||
assertions = lib.mkOption {
|
||||
type = types.listOf types.unspecified;
|
||||
internal = true;
|
||||
visible = false;
|
||||
default = [ ];
|
||||
};
|
||||
meta = lib.mkOption {
|
||||
type = lib.types.submoduleWith {
|
||||
modules = [
|
||||
./meta-interface.nix
|
||||
];
|
||||
};
|
||||
};
|
||||
tags = lib.mkOption {
|
||||
default = { };
|
||||
description = ''
|
||||
Tags of the inventory are used to group machines together.
|
||||
|
||||
It is recommended to use [`machine.tags`](#inventory.machines.tags) to define the tags of the machines.
|
||||
|
||||
This can be used to define custom tags that are either statically set or dynamically computed.
|
||||
|
||||
#### Static Tags
|
||||
|
||||
???+ example "Static Tag Example"
|
||||
```nix
|
||||
inventory.tags = {
|
||||
foo = [ "machineA" "machineB" ];
|
||||
};
|
||||
```
|
||||
|
||||
The tag `foo` will always be added to `machineA` and `machineB`.
|
||||
|
||||
#### Dynamic Tags
|
||||
|
||||
It is possible to compute tags based on the machines properties or based on other tags.
|
||||
|
||||
!!! danger
|
||||
This is a powerful feature and should be used with caution.
|
||||
|
||||
It is possible to cause infinite recursion by computing tags based on the machines properties or based on other tags.
|
||||
|
||||
???+ example "Dynamic Tag Example"
|
||||
|
||||
allButFoo is a computed tag. It will be added to all machines except 'foo'
|
||||
|
||||
`all` is a predefined tag. See the docs of [`tags.all`](#inventory.tags.all).
|
||||
|
||||
```nix
|
||||
# inventory.tags ↓ ↓ inventory.machines
|
||||
inventory.tags = {config, machines...}: {
|
||||
# ↓↓↓ The "all" tag
|
||||
allButFoo = builtins.filter (name: name != "foo") config.all;
|
||||
};
|
||||
```
|
||||
|
||||
!!! warning
|
||||
Do NOT compute `tags` from `machine.tags` this will cause infinite recursion.
|
||||
'';
|
||||
type = types.submoduleWith {
|
||||
specialArgs = {
|
||||
inherit (config) machines;
|
||||
};
|
||||
modules = [
|
||||
{
|
||||
freeformType = with lib.types; lazyAttrsOf (listOf str);
|
||||
# Reserved tags
|
||||
# Defined as options here to show them in advance
|
||||
options = {
|
||||
# 'All machines' tag
|
||||
all = lib.mkOption {
|
||||
type = with lib.types; listOf str;
|
||||
defaultText = "[ <All Machines> ]";
|
||||
description = ''
|
||||
!!! example "Predefined Tag"
|
||||
|
||||
Will be added to all machines
|
||||
|
||||
```nix
|
||||
inventory.machines.machineA.tags = [ "all" ];
|
||||
```
|
||||
'';
|
||||
};
|
||||
nixos = lib.mkOption {
|
||||
type = with lib.types; listOf str;
|
||||
defaultText = "[ <All NixOS Machines> ]";
|
||||
description = ''
|
||||
!!! example "Predefined Tag"
|
||||
|
||||
Will be added to all machines that set `machineClass = "nixos"`
|
||||
|
||||
```nix
|
||||
inventory.machines.machineA.tags = [ "nixos" ];
|
||||
```
|
||||
'';
|
||||
};
|
||||
darwin = lib.mkOption {
|
||||
type = with lib.types; listOf str;
|
||||
defaultText = "[ <All Darwin Machines> ]";
|
||||
description = ''
|
||||
!!! example "Predefined Tag"
|
||||
|
||||
Will be added to all machines that set `machineClass = "darwin"`
|
||||
|
||||
```nix
|
||||
inventory.machines.machineA.tags = [ "darwin" ];
|
||||
```
|
||||
'';
|
||||
};
|
||||
};
|
||||
}
|
||||
];
|
||||
};
|
||||
};
|
||||
|
||||
machines = lib.mkOption {
|
||||
description = ''
|
||||
Machines in the inventory.
|
||||
|
||||
Each machine declared here can be referencd via its `attributeName` by the `inventory.service`s `roles`.
|
||||
'';
|
||||
default = { };
|
||||
type = types.lazyAttrsOf (
|
||||
types.submoduleWith ({
|
||||
modules = [
|
||||
(
|
||||
{ name, ... }:
|
||||
{
|
||||
tags = builtins.attrNames (
|
||||
# config.tags
|
||||
lib.filterAttrs (_t: tagMembers: builtins.elem name tagMembers) config.tags
|
||||
);
|
||||
}
|
||||
)
|
||||
(
|
||||
{ name, ... }:
|
||||
{
|
||||
options = {
|
||||
inherit (metaOptionsWith name) name description icon;
|
||||
|
||||
machineClass = lib.mkOption {
|
||||
default = "nixos";
|
||||
type = types.enum [
|
||||
"nixos"
|
||||
"darwin"
|
||||
];
|
||||
description = ''
|
||||
The module system that should be used to construct the machine
|
||||
|
||||
Set this to `darwin` for macOS machines
|
||||
'';
|
||||
};
|
||||
|
||||
tags = lib.mkOption {
|
||||
description = ''
|
||||
List of tags for the machine.
|
||||
|
||||
The machine can be referenced by its tags in `inventory.services`
|
||||
|
||||
???+ Example
|
||||
```nix
|
||||
inventory.machines.machineA.tags = [ "tag1" "tag2" ];
|
||||
```
|
||||
|
||||
```nix
|
||||
services.borgbackup."instance_1".roles.client.tags = [ "tag1" ];
|
||||
```
|
||||
|
||||
!!! Note
|
||||
Tags can be used to determine the membership of the machine in the services.
|
||||
Without changing the service configuration, the machine can be added to a service by adding the correct tags to the machine.
|
||||
|
||||
'';
|
||||
default = [ ];
|
||||
apply = lib.unique;
|
||||
type = types.listOf types.str;
|
||||
};
|
||||
deploy.targetHost = lib.mkOption {
|
||||
description = "SSH address of the host to deploy the machine to";
|
||||
default = null;
|
||||
type = types.nullOr types.str;
|
||||
};
|
||||
deploy.buildHost = lib.mkOption {
|
||||
description = "SSH address of the host to build the machine on";
|
||||
default = null;
|
||||
type = types.nullOr types.str;
|
||||
};
|
||||
};
|
||||
}
|
||||
)
|
||||
];
|
||||
})
|
||||
);
|
||||
};
|
||||
|
||||
instances =
|
||||
if config.noInstanceOptions then
|
||||
{ }
|
||||
else
|
||||
lib.mkOption {
|
||||
description = "Multi host service module instances";
|
||||
type = types.attrsOf (
|
||||
types.submoduleWith {
|
||||
modules = [
|
||||
(
|
||||
{ name, ... }:
|
||||
{
|
||||
options = {
|
||||
# ModuleSpec
|
||||
module = lib.mkOption {
|
||||
type = types.submodule {
|
||||
options.input = lib.mkOption {
|
||||
type = types.nullOr types.str;
|
||||
default = null;
|
||||
defaultText = "Name of the input. Default to 'null' which means the module is local";
|
||||
description = ''
|
||||
Name of the input. Default to 'null' which means the module is local
|
||||
'';
|
||||
};
|
||||
options.name = lib.mkOption {
|
||||
type = types.str;
|
||||
default = name;
|
||||
defaultText = "<Name of the Instance>";
|
||||
description = ''
|
||||
Attribute of the clan service module imported from the chosen input.
|
||||
|
||||
Defaults to the name of the instance.
|
||||
'';
|
||||
};
|
||||
};
|
||||
};
|
||||
roles = lib.mkOption {
|
||||
default = { };
|
||||
type = types.attrsOf (
|
||||
types.submodule {
|
||||
imports = [
|
||||
{
|
||||
_file = "inventory/interface";
|
||||
_module.args = {
|
||||
inherit clanLib;
|
||||
};
|
||||
}
|
||||
(import ./roles-interface.nix { })
|
||||
];
|
||||
}
|
||||
);
|
||||
};
|
||||
};
|
||||
}
|
||||
)
|
||||
];
|
||||
}
|
||||
);
|
||||
default = { };
|
||||
};
|
||||
|
||||
services = lib.mkOption {
|
||||
# TODO: deprecate these options
|
||||
# services are deprecated in favor of `instances`
|
||||
# visible = false;
|
||||
description = ''
|
||||
Services of the inventory.
|
||||
|
||||
- The first `<name>` is the moduleName. It must be a valid clanModule name.
|
||||
- The second `<name>` is an arbitrary instance name.
|
||||
|
||||
???+ Example
|
||||
```nix
|
||||
# ClanModule name. See the module documentation for the available modules.
|
||||
# ↓ ↓ Instance name, can be anything, some services might use it as a unique identifier.
|
||||
services.borgbackup."instance_1" = {
|
||||
roles.client.machines = ["machineA"];
|
||||
};
|
||||
```
|
||||
|
||||
!!! Note
|
||||
Services MUST be added to machines via `roles` exclusively.
|
||||
See [`roles.<rolename>.machines`](#inventory.services.roles.machines) or [`roles.<rolename>.tags`](#inventory.services.roles.tags) for more information.
|
||||
'';
|
||||
default = { };
|
||||
type = types.attrsOf (
|
||||
types.attrsOf (
|
||||
types.submodule (
|
||||
# instance name
|
||||
{ name, ... }:
|
||||
{
|
||||
options.enabled = lib.mkOption {
|
||||
type = lib.types.bool;
|
||||
default = true;
|
||||
description = ''
|
||||
Enable or disable the complete service.
|
||||
|
||||
If the service is disabled, it will not be added to any machine.
|
||||
|
||||
!!! Note
|
||||
This flag is primarily used to temporarily disable a service.
|
||||
I.e. A 'backup service' without any 'server' might be incomplete and would cause failure if enabled.
|
||||
'';
|
||||
};
|
||||
options.meta = metaOptionsWith name;
|
||||
options.extraModules = extraModulesOption;
|
||||
options.config = moduleConfig // {
|
||||
description = ''
|
||||
Configuration of the specific clanModule.
|
||||
|
||||
!!! Note
|
||||
Configuration is passed to the nixos configuration scoped to the module.
|
||||
|
||||
```nix
|
||||
clan.<serviceName> = { ... # Config }
|
||||
```
|
||||
|
||||
???+ Example
|
||||
|
||||
For `services.borgbackup` the config is the passed to the machine with the prefix of `clan.borgbackup`.
|
||||
This means all config values are mapped to the `borgbackup` clanModule exclusively (`config.clan.borgbackup`).
|
||||
|
||||
```nix
|
||||
{
|
||||
services.borgbackup."instance_1".config = {
|
||||
destinations = [ ... ];
|
||||
# See the 'borgbackup' module docs for all options
|
||||
};
|
||||
}
|
||||
```
|
||||
|
||||
!!! Note
|
||||
The module author is responsible for supporting multiple instance configurations in different roles.
|
||||
See each clanModule's documentation for more information.
|
||||
'';
|
||||
};
|
||||
options.machines = lib.mkOption {
|
||||
description = ''
|
||||
Attribute set of machines specific config for the service.
|
||||
|
||||
Will be merged with other service configs, such as the role config and the global config.
|
||||
For machine specific overrides use `mkForce` or other higher priority methods.
|
||||
|
||||
???+ Example
|
||||
|
||||
```{.nix hl_lines="4-7"}
|
||||
services.borgbackup."instance_1" = {
|
||||
roles.client.machines = ["machineA"];
|
||||
|
||||
machines.machineA.config = {
|
||||
# Additional specific config for the machine
|
||||
# This is merged with all other config places
|
||||
};
|
||||
};
|
||||
```
|
||||
'';
|
||||
default = { };
|
||||
type = types.attrsOf (
|
||||
types.submodule {
|
||||
options.extraModules = extraModulesOption;
|
||||
options.config = moduleConfig // {
|
||||
description = ''
|
||||
Additional configuration of the specific machine.
|
||||
|
||||
See how [`service.<name>.<name>.config`](#inventory.services.config) works in general for further information.
|
||||
'';
|
||||
};
|
||||
}
|
||||
);
|
||||
};
|
||||
options.roles = lib.mkOption {
|
||||
default = { };
|
||||
type = types.attrsOf (
|
||||
types.submodule {
|
||||
options.machines = lib.mkOption {
|
||||
default = [ ];
|
||||
type = types.listOf types.str;
|
||||
example = [ "machineA" ];
|
||||
description = ''
|
||||
List of machines which are part of the role.
|
||||
|
||||
The machines are referenced by their `attributeName` in the `inventory.machines` attribute set.
|
||||
|
||||
Memberships are declared here to determine which machines are part of the service.
|
||||
|
||||
Alternatively, `tags` can be used to determine the membership, more dynamically.
|
||||
'';
|
||||
};
|
||||
options.tags = lib.mkOption {
|
||||
default = [ ];
|
||||
apply = lib.unique;
|
||||
type = types.listOf types.str;
|
||||
description = ''
|
||||
List of tags which are used to determine the membership of the role.
|
||||
|
||||
The tags are matched against the `inventory.machines.<machineName>.tags` attribute set.
|
||||
If a machine has at least one tag of the role, it is part of the role.
|
||||
'';
|
||||
};
|
||||
options.config = moduleConfig // {
|
||||
description = ''
|
||||
Additional configuration of the specific role.
|
||||
|
||||
See how [`service.<name>.<name>.config`](#inventory.services.config) works in general for further information.
|
||||
'';
|
||||
};
|
||||
options.extraModules = extraModulesOption;
|
||||
}
|
||||
);
|
||||
};
|
||||
}
|
||||
)
|
||||
)
|
||||
);
|
||||
};
|
||||
};
|
||||
}
|
||||
16
lib/modules/inventoryClass/inventory-introspection.nix
Normal file
16
lib/modules/inventoryClass/inventory-introspection.nix
Normal file
@@ -0,0 +1,16 @@
|
||||
{
|
||||
config,
|
||||
lib,
|
||||
clanLib,
|
||||
...
|
||||
}:
|
||||
{
|
||||
options.introspection = lib.mkOption {
|
||||
readOnly = true;
|
||||
# TODO: use options.inventory instead of the evaluate config attribute
|
||||
default =
|
||||
builtins.removeAttrs (clanLib.introspection.getPrios { options = config.inventory.options; })
|
||||
# tags are freeformType which is not supported yet.
|
||||
[ "tags" ];
|
||||
};
|
||||
}
|
||||
35
lib/modules/inventoryClass/meta-interface.nix
Normal file
35
lib/modules/inventoryClass/meta-interface.nix
Normal file
@@ -0,0 +1,35 @@
|
||||
{ lib, ... }:
|
||||
let
|
||||
types = lib.types;
|
||||
|
||||
metaOptions = {
|
||||
name = lib.mkOption {
|
||||
type = types.strMatching "[a-zA-Z0-9_-]*";
|
||||
example = "my_clan";
|
||||
description = ''
|
||||
Name of the clan.
|
||||
|
||||
Needs to be (globally) unique, as this determines the folder name where the flake gets downloaded to.
|
||||
|
||||
Should only contain alphanumeric characters, `_` and `-`.
|
||||
'';
|
||||
};
|
||||
description = lib.mkOption {
|
||||
default = null;
|
||||
type = types.nullOr types.str;
|
||||
description = ''
|
||||
Optional freeform description
|
||||
'';
|
||||
};
|
||||
icon = lib.mkOption {
|
||||
default = null;
|
||||
type = types.nullOr types.str;
|
||||
description = ''
|
||||
Under construction, will be used for the UI
|
||||
'';
|
||||
};
|
||||
};
|
||||
in
|
||||
{
|
||||
options = metaOptions;
|
||||
}
|
||||
85
lib/modules/inventoryClass/roles-interface.nix
Normal file
85
lib/modules/inventoryClass/roles-interface.nix
Normal file
@@ -0,0 +1,85 @@
|
||||
{
|
||||
settingsOption ? null,
|
||||
nestedSettingsOption ? null,
|
||||
}:
|
||||
{ lib, clanLib, ... }:
|
||||
let
|
||||
inherit (lib)
|
||||
types
|
||||
;
|
||||
in
|
||||
{
|
||||
options = {
|
||||
# TODO: deduplicate
|
||||
machines = lib.mkOption {
|
||||
type = types.attrsOf (
|
||||
types.submodule {
|
||||
options.settings =
|
||||
if nestedSettingsOption != null then
|
||||
nestedSettingsOption
|
||||
else
|
||||
lib.mkOption {
|
||||
default = { };
|
||||
type = clanLib.types.uniqueDeferredSerializableModule;
|
||||
};
|
||||
}
|
||||
);
|
||||
default = { };
|
||||
};
|
||||
tags = lib.mkOption {
|
||||
type = types.attrsOf (types.submodule { });
|
||||
default = { };
|
||||
};
|
||||
settings =
|
||||
if settingsOption != null then
|
||||
settingsOption
|
||||
else
|
||||
lib.mkOption {
|
||||
default = { };
|
||||
type = clanLib.types.uniqueDeferredSerializableModule;
|
||||
};
|
||||
extraModules = lib.mkOption {
|
||||
description = ''
|
||||
List of additionally imported `.nix` expressions.
|
||||
|
||||
Supported types:
|
||||
|
||||
- **Strings**: Interpreted relative to the 'directory' passed to buildClan.
|
||||
- **Paths**: should be relative to the current file.
|
||||
- **Any**: Nix expression must be serializable to JSON.
|
||||
|
||||
!!! Note
|
||||
**The import only happens if the machine is part of the service or role.**
|
||||
|
||||
Other types are passed through to the nixos configuration.
|
||||
|
||||
???+ Example
|
||||
To import the `special.nix` file
|
||||
|
||||
```
|
||||
. Clan Directory
|
||||
├── flake.nix
|
||||
...
|
||||
└── modules
|
||||
├── special.nix
|
||||
└── ...
|
||||
```
|
||||
|
||||
```nix
|
||||
{
|
||||
extraModules = [ "modules/special.nix" ];
|
||||
}
|
||||
```
|
||||
'';
|
||||
apply = value: if lib.isString value then value else builtins.seq (builtins.toJSON value) value;
|
||||
default = [ ];
|
||||
type = types.listOf (
|
||||
types.oneOf [
|
||||
types.str
|
||||
types.path
|
||||
(types.attrsOf types.anything)
|
||||
]
|
||||
);
|
||||
};
|
||||
};
|
||||
}
|
||||
43
lib/modules/inventoryClass/service-list-from-inputs.nix
Normal file
43
lib/modules/inventoryClass/service-list-from-inputs.nix
Normal file
@@ -0,0 +1,43 @@
|
||||
{
|
||||
flakeInputs,
|
||||
clanLib,
|
||||
localModuleSet,
|
||||
}:
|
||||
{ lib, config, ... }:
|
||||
|
||||
let
|
||||
|
||||
inspectModule =
|
||||
inputName: moduleName: module:
|
||||
let
|
||||
eval = clanLib.inventory.evalClanService {
|
||||
modules = [ module ];
|
||||
prefix = [
|
||||
inputName
|
||||
"clan"
|
||||
"modules"
|
||||
moduleName
|
||||
];
|
||||
};
|
||||
in
|
||||
{
|
||||
manifest = eval.config.manifest;
|
||||
roles = lib.mapAttrs (_n: _v: { }) eval.config.roles;
|
||||
};
|
||||
in
|
||||
{
|
||||
options.modulesPerSource = lib.mkOption {
|
||||
# { sourceName :: { moduleName :: {} }}
|
||||
default =
|
||||
let
|
||||
inputsWithModules = lib.filterAttrs (_inputName: v: v ? clan.modules) flakeInputs;
|
||||
|
||||
in
|
||||
lib.mapAttrs (
|
||||
inputName: v: lib.mapAttrs (inspectModule inputName) v.clan.modules
|
||||
) inputsWithModules;
|
||||
};
|
||||
options.localModules = lib.mkOption {
|
||||
default = lib.mapAttrs (inspectModule "self") localModuleSet;
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user