build-inventory: move inventory and inventoryClass into explizitly different folders

This commit is contained in:
Johannes Kirschbauer
2025-06-25 17:45:10 +02:00
parent ae4e18c152
commit 345aa12e99
42 changed files with 49 additions and 139 deletions

View File

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

View File

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

View File

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

View 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;
}
];
}

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

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

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

View 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
'';
};
};
}

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

View File

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

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

View 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
'';
};
};
}

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

View 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 = [
];
};
};
};
}

View 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
'';
}

View 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)

View 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 = [ ];
};
}

View File

@@ -0,0 +1,4 @@
---
features = [ "inventory" ]
---
Description

View File

@@ -0,0 +1,9 @@
{
lib,
clan-core,
...
}:
{
# Just some random stuff
options.test = lib.mapAttrs clan-core;
}

View 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;
}
];
}

View 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 { });
};
}

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

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

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

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

View 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" ];
};
}

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

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

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