Merge pull request 'clanServices: add flake level exports' (#4172) from flake-exports into main

Reviewed-on: https://git.clan.lol/clan/clan-core/pulls/4172
This commit is contained in:
hsjobeki
2025-07-02 08:42:07 +00:00
12 changed files with 545 additions and 36 deletions

View File

@@ -185,7 +185,6 @@ in
]; ];
clan.core.vars.generators.borgbackup = { clan.core.vars.generators.borgbackup = {
files."borgbackup.ssh.pub".secret = false; files."borgbackup.ssh.pub".secret = false;
files."borgbackup.ssh" = { }; files."borgbackup.ssh" = { };
files."borgbackup.repokey" = { }; files."borgbackup.repokey" = { };

View File

@@ -67,6 +67,44 @@ in
''; '';
}; };
# TODO: make this writable by moving the options from inventoryClass into clan.
exports = lib.mkOption {
readOnly = true;
visible = false;
internal = true;
};
exportsModule = lib.mkOption {
internal = true;
visible = false;
type = types.deferredModule;
default = { };
description = ''
A module that is used to define the module of flake level exports -
such as 'exports.machines.<name>' and 'exports.instances.<name>'
Example:
```nix
{
options.vars.generators = lib.mkOption {
type = lib.types.attrsOf (
lib.types.submoduleWith {
modules = [
{
options.script = lib.mkOption { type = lib.types.str; };
}
];
}
);
default = { };
};
}
```
'';
};
specialArgs = lib.mkOption { specialArgs = lib.mkOption {
type = types.attrsOf types.raw; type = types.attrsOf types.raw;
default = { }; default = { };

View File

@@ -224,6 +224,8 @@ in
inherit nixosConfigurations; inherit nixosConfigurations;
inherit darwinConfigurations; inherit darwinConfigurations;
exports = config.clanInternals.inventoryClass.distributedServices.servicesEval.config.exports;
clanInternals = { clanInternals = {
inventoryClass = inventoryClass =
let let
@@ -244,10 +246,13 @@ in
inherit inventory directory; inherit inventory directory;
} }
( (
let
clanConfig = config;
in
{ config, ... }: { config, ... }:
{ {
distributedServices = clanLib.inventory.mapInstances { distributedServices = clanLib.inventory.mapInstances {
inherit (config) inventory; inherit (clanConfig) inventory exportsModule;
inherit flakeInputs; inherit flakeInputs;
clanCoreModules = clan-core.clan.modules; clanCoreModules = clan-core.clan.modules;
prefix = [ "distributedServices" ]; prefix = [ "distributedServices" ];

View File

@@ -0,0 +1,75 @@
# Wraps all services in one fixed point module
{
lib,
config,
specialArgs,
_ctx,
...
}:
let
inherit (lib) mkOption types;
inherit (types) attrsWith submoduleWith;
in
{
# TODO: merge these options into clan options
options = {
exportsModule = mkOption {
type = types.deferredModule;
readOnly = true;
};
mappedServices = mkOption {
visible = false;
type = attrsWith {
placeholder = "mappedServiceName";
elemType = submoduleWith {
modules = [
(
{ name, ... }:
{
_module.args._ctx = [ name ];
_module.args.exports' = config.exports;
}
)
./service-module.nix
# feature modules
(lib.modules.importApply ./api-feature.nix {
inherit (specialArgs) clanLib;
prefix = _ctx;
})
];
};
};
default = { };
};
exports = mkOption {
type = submoduleWith {
modules = [
{
options = {
instances = lib.mkOption {
# instances.<instanceName>...
type = types.attrsOf (submoduleWith {
modules = [
config.exportsModule
];
});
};
# instances.<machineName>...
machines = lib.mkOption {
type = types.attrsOf (submoduleWith {
modules = [
config.exportsModule
];
});
};
};
}
] ++ lib.mapAttrsToList (_: service: service.exports) config.mappedServices;
};
default = { };
};
debug = mkOption {
default = lib.mapAttrsToList (_: service: service.exports) config.mappedServices;
};
};
}

View File

@@ -26,6 +26,7 @@ in
inventory, inventory,
clanCoreModules, clanCoreModules,
prefix ? [ ], prefix ? [ ],
exportsModule,
}: }:
let let
# machineHasTag = machineName: tagName: lib.elem tagName inventory.machines.${machineName}.tags; # machineHasTag = machineName: tagName: lib.elem tagName inventory.machines.${machineName}.tags;
@@ -89,23 +90,6 @@ in
} }
) inventory.instances or { }; ) inventory.instances or { };
# TODO: Eagerly check the _class of the resolved module
importedModulesEvaluated = lib.mapAttrs (
module_ident: instances:
clanLib.evalService {
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 # Group the instances by the module they resolve to
# This is necessary to evaluate the module in a single pass # This is necessary to evaluate the module in a single pass
# :: { <module.input>_<module.name> :: [ { name, value } ] } # :: { <module.input>_<module.name> :: [ { name, value } ] }
@@ -126,16 +110,52 @@ in
} }
) { } importedModuleWithInstances; ) { } importedModuleWithInstances;
# servicesEval.config.mappedServices.self-A.result.final.jon.nixosModule
allMachines = lib.mapAttrs (machineName: _: { allMachines = lib.mapAttrs (machineName: _: {
# This is the list of nixosModules for each machine # This is the list of nixosModules for each machine
machineImports = lib.foldlAttrs ( machineImports = lib.foldlAttrs (
acc: _module_ident: eval: acc: _module_ident: serviceModule:
acc ++ [ eval.config.result.final.${machineName}.nixosModule or { } ] acc ++ [ serviceModule.result.final.${machineName}.nixosModule or { } ]
) [ ] importedModulesEvaluated; ) [ ] servicesEval.config.mappedServices;
}) inventory.machines or { }; }) inventory.machines or { };
evalServices =
{ modules, prefix }:
lib.evalModules {
specialArgs = {
inherit clanLib;
_ctx = prefix;
};
modules = [
./all-services-wrapper.nix
] ++ modules;
};
servicesEval = evalServices {
inherit prefix;
modules = [
{
inherit exportsModule;
mappedServices = lib.mapAttrs (_module_ident: instances: {
imports =
[
# 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;
}
];
};
importedModulesEvaluated = servicesEval.config.mappedServices;
in in
{ {
inherit inherit
servicesEval
importedModuleWithInstances importedModuleWithInstances
grouped grouped
allMachines allMachines

View File

@@ -104,6 +104,13 @@ let
in in
{ {
options = { options = {
# Option to disable some behavior during docs rendering
_docs_rendering = mkOption {
default = false;
visible = false;
type = types.bool;
};
instances = mkOption { instances = mkOption {
visible = false; visible = false;
defaultText = "Throws: 'The service must define its instances' when not defined"; defaultText = "Throws: 'The service must define its instances' when not defined";
@@ -384,6 +391,33 @@ in
type = types.deferredModuleWith { type = types.deferredModuleWith {
staticModules = [ staticModules = [
({ ({
options.exports = mkOption {
type = types.deferredModule;
default = { };
description = ''
export modules defined in 'perInstance'
mapped to their instance name
Example
with instances:
```nix
instances.A = { ... };
instances.B= { ... };
roles.peer.perInstance = { instanceName, machine, ... }:
{
exports.foo = 1;
}
This yields all other services can access these exports
=>
exports.instances.A.foo = 1;
exports.instances.B.foo = 1;
```
'';
};
options.nixosModule = mkOption { options.nixosModule = mkOption {
type = types.deferredModule; type = types.deferredModule;
default = { }; default = { };
@@ -493,6 +527,32 @@ in
type = types.deferredModuleWith { type = types.deferredModuleWith {
staticModules = [ staticModules = [
({ ({
options.exports = mkOption {
type = types.deferredModule;
default = { };
description = ''
export modules defined in 'perMachine'
mapped to their machine name
Example
with machines:
```nix
instances.A = { roles.peer.machines.jon = ... };
instances.B = { roles.peer.machines.jon = ... };
perMachine = { machine, ... }:
{
exports.foo = 1;
}
This yields all other services can access these exports
=>
exports.machines.jon.foo = 1;
exports.machines.sara.foo = 1;
```
'';
};
options.nixosModule = mkOption { options.nixosModule = mkOption {
type = types.deferredModule; type = types.deferredModule;
default = { }; default = { };
@@ -568,6 +628,96 @@ in
modules = [ v ]; modules = [ v ];
}).config; }).config;
}; };
exports = mkOption {
description = ''
This services exports.
Gets merged with all other services exports
Final value (merged and evaluated with other services) available as `exports'` in the arguments of this module.
```nix
{ exports', ... }: {
_class = "clan.service";
# ...
}
```
'';
default = { };
type = types.submoduleWith {
# Static modules
modules = [
{
options.instances = mkOption {
type = types.attrsOf types.deferredModule;
description = ''
export modules defined in 'perInstance'
mapped to their instance name
Example
with instances:
```nix
instances.A = { ... };
instances.B= { ... };
roles.peer.perInstance = { instanceName, machine, ... }:
{
exports.foo = 1;
}
This yields all other services can access these exports
=>
exports.instances.A.foo = 1;
exports.instances.B.foo = 1;
```
'';
};
options.machines = mkOption {
type = types.attrsOf types.deferredModule;
description = ''
export modules defined in 'perMachine'
mapped to their machine name
Example
with machines:
```nix
instances.A = { roles.peer.machines.jon = ... };
instances.B = { roles.peer.machines.jon = ... };
perMachine = { machine, ... }:
{
exports.foo = 1;
}
This yields all other services can access these exports
=>
exports.machines.jon.foo = 1;
exports.machines.sara.foo = 1;
```
'';
};
# Lazy default via imports
# should probably be moved to deferredModuleWith { staticModules = [ ]; }
imports =
if config._docs_rendering then
[ ]
else
lib.mapAttrsToList (_roleName: role: {
instances = lib.mapAttrs (_instanceName: instance: {
imports = lib.mapAttrsToList (_machineName: v: v.exports) instance.allMachines;
}) role.allInstances;
}) config.result.allRoles
++ lib.mapAttrsToList (machineName: machine: {
machines.${machineName} = machine.exports;
}) config.result.allMachines;
}
];
};
};
# --- # ---
# Place the result in _module.result to mark them as "internal" and discourage usage/overrides # Place the result in _module.result to mark them as "internal" and discourage usage/overrides
# #

View File

@@ -48,9 +48,11 @@ let
clanCoreModules = { }; clanCoreModules = { };
flakeInputs = flakeInputsFixture; flakeInputs = flakeInputsFixture;
inherit inventory; inherit inventory;
exportsModule = { };
}; };
in in
{ {
exports = import ./exports.nix { inherit lib clanLib; };
resolve_module_spec = import ./import_module_spec.nix { inherit lib callInventoryAdapter; }; resolve_module_spec = import ./import_module_spec.nix { inherit lib callInventoryAdapter; };
test_simple = test_simple =
let let
@@ -171,7 +173,7 @@ in
{ {
# Test that the module is mapped into the output # Test that the module is mapped into the output
# We might change the attribute name in the future # We might change the attribute name in the future
expr = lib.attrNames res.importedModulesEvaluated.self-A.config.instances; expr = lib.attrNames res.importedModulesEvaluated.self-A.instances;
expected = [ expected = [
"instance_bar" "instance_bar"
"instance_foo" "instance_foo"
@@ -227,7 +229,7 @@ in
{ {
# Test that the module is mapped into the output # Test that the module is mapped into the output
# We might change the attribute name in the future # We might change the attribute name in the future
expr = lib.attrNames res.importedModulesEvaluated.self-A.config.result.allMachines; expr = lib.attrNames res.importedModulesEvaluated.self-A.result.allMachines;
expected = [ expected = [
"jon" "jon"
"sara" "sara"
@@ -279,13 +281,14 @@ in
{ {
# Test that the module is mapped into the output # Test that the module is mapped into the output
# We might change the attribute name in the future # We might change the attribute name in the future
expr = lib.attrNames res.importedModulesEvaluated.self-A.config.result.allMachines; expr = lib.attrNames res.importedModulesEvaluated.self-A.result.allMachines;
expected = [ expected = [
"jon" "jon"
"sara" "sara"
]; ];
}; };
machine_imports = import ./machine_imports.nix { inherit lib clanLib; };
per_machine_args = import ./per_machine_args.nix { inherit lib callInventoryAdapter; }; per_machine_args = import ./per_machine_args.nix { inherit lib callInventoryAdapter; };
per_instance_args = import ./per_instance_args.nix { inherit lib callInventoryAdapter; }; per_instance_args = import ./per_instance_args.nix { inherit lib callInventoryAdapter; };
} }

View File

@@ -0,0 +1,170 @@
{ lib, clanLib }:
let
clan = clanLib.clan {
self = { };
directory = ./.;
exportsModule = {
options.vars.generators = lib.mkOption {
type = lib.types.attrsOf (
lib.types.submoduleWith {
# TODO: import the vars submodule here
modules = [
{
options.script = lib.mkOption { type = lib.types.str; };
}
];
}
);
default = { };
};
};
machines.jon = { };
machines.sara = { };
# A module that adds exports perMachine
modules.A =
{ exports', ... }:
{
manifest.name = "A";
roles.peer.perInstance =
{ machine, ... }:
{
# Cross reference a perMachine exports
exports.vars.generators."${machine.name}-network-ip".script =
"A:" + exports'.machines.${machine.name}.vars.generators.key.script;
# Cross reference a perInstance exports from a different service
exports.vars.generators."${machine.name}-full-hostname".script =
"A:" + exports'.instances."B-1".vars.generators.hostname.script;
};
roles.server = { };
perMachine =
{ machine, ... }:
{
exports = {
vars.generators.key.script = machine.name;
};
};
};
# A module that adds exports perInstance
modules.B = {
manifest.name = "B";
roles.peer.perInstance =
{ instanceName, ... }:
{
exports = {
vars.generators.hostname.script = instanceName;
};
};
};
inventory = {
instances.B-1 = {
module.name = "B";
module.input = "self";
roles.peer.tags.all = { };
};
instances.B-2 = {
module.name = "B";
module.input = "self";
roles.peer.tags.all = { };
};
instances.A-1 = {
module.name = "A";
module.input = "self";
roles.peer.tags.all = { };
roles.server.tags.all = { };
};
instances.A-2 = {
module.name = "A";
module.input = "self";
roles.peer.tags.all = { };
roles.server.tags.all = { };
};
};
};
in
{
test_1 = {
inherit clan;
expr = clan.config.exports;
expected = {
instances = {
A-1 = {
vars = {
generators = {
jon-full-hostname = {
script = "A:B-1";
};
jon-network-ip = {
script = "A:jon";
};
sara-full-hostname = {
script = "A:B-1";
};
sara-network-ip = {
script = "A:sara";
};
};
};
};
A-2 = {
vars = {
generators = {
jon-full-hostname = {
script = "A:B-1";
};
jon-network-ip = {
script = "A:jon";
};
sara-full-hostname = {
script = "A:B-1";
};
sara-network-ip = {
script = "A:sara";
};
};
};
};
B-1 = {
vars = {
generators = {
hostname = {
script = "B-1";
};
};
};
};
B-2 = {
vars = {
generators = {
hostname = {
script = "B-2";
};
};
};
};
};
machines = {
jon = {
vars = {
generators = {
key = {
script = "jon";
};
};
};
};
sara = {
vars = {
generators = {
key = {
script = "sara";
};
};
};
};
};
};
};
}

View File

@@ -0,0 +1,49 @@
{ lib, clanLib }:
let
clan = clanLib.clan {
self = { };
directory = ./.;
machines.jon = { };
machines.sara = { };
# A module that adds exports perMachine
modules.A =
{ ... }:
{
manifest.name = "A";
roles.peer.perInstance =
{ ... }:
{
nixosModule = {
options.bar = lib.mkOption {
default = 1;
};
};
};
roles.server = { };
perMachine =
{ ... }:
{
nixosModule = {
options.foo = lib.mkOption {
default = 1;
};
};
};
};
inventory.instances.A = {
module.input = "self";
roles.peer.tags.all = { };
};
};
in
{
test_1 = {
inherit clan;
expr = { inherit (clan.config.clanInternals.machines.x86_64-linux.jon.config) bar foo; };
expected = {
foo = 1;
bar = 1;
};
};
}

View File

@@ -106,7 +106,7 @@ in
test_per_instance_arguments = { test_per_instance_arguments = {
expr = { expr = {
instanceName = instanceName =
res.importedModulesEvaluated.self-A.config.result.allRoles.peer.allInstances."instance_foo".allMachines.jon.passthru.instanceName; res.importedModulesEvaluated.self-A.result.allRoles.peer.allInstances."instance_foo".allMachines.jon.passthru.instanceName;
# settings are specific. # settings are specific.
# Below we access: # Below we access:
@@ -114,11 +114,11 @@ in
# roles = peer # roles = peer
# machines = jon # machines = jon
settings = settings =
res.importedModulesEvaluated.self-A.config.result.allRoles.peer.allInstances.instance_foo.allMachines.jon.passthru.settings; res.importedModulesEvaluated.self-A.result.allRoles.peer.allInstances.instance_foo.allMachines.jon.passthru.settings;
machine = machine =
res.importedModulesEvaluated.self-A.config.result.allRoles.peer.allInstances.instance_foo.allMachines.jon.passthru.machine; res.importedModulesEvaluated.self-A.result.allRoles.peer.allInstances.instance_foo.allMachines.jon.passthru.machine;
roles = roles =
res.importedModulesEvaluated.self-A.config.result.allRoles.peer.allInstances.instance_foo.allMachines.jon.passthru.roles; res.importedModulesEvaluated.self-A.result.allRoles.peer.allInstances.instance_foo.allMachines.jon.passthru.roles;
}; };
expected = { expected = {
instanceName = "instance_foo"; instanceName = "instance_foo";
@@ -161,9 +161,9 @@ in
# TODO: Cannot be tested like this anymore # TODO: Cannot be tested like this anymore
test_per_instance_settings_vendoring = { test_per_instance_settings_vendoring = {
x = res.importedModulesEvaluated.self-A.config; x = res.importedModulesEvaluated.self-A;
expr = expr =
res.importedModulesEvaluated.self-A.config.result.allRoles.peer.allInstances.instance_foo.allMachines.jon.passthru.vendoredSettings; res.importedModulesEvaluated.self-A.result.allRoles.peer.allInstances.instance_foo.allMachines.jon.passthru.vendoredSettings;
expected = { expected = {
timeout = "config.thing"; timeout = "config.thing";
}; };

View File

@@ -81,7 +81,7 @@ in
inherit res; inherit res;
expr = { expr = {
hasMachineSettings = hasMachineSettings =
res.importedModulesEvaluated.self-A.config.result.allMachines.jon.passthru.instances.instance_foo.roles.peer.machines.jon res.importedModulesEvaluated.self-A.result.allMachines.jon.passthru.instances.instance_foo.roles.peer.machines.jon
? settings; ? settings;
# settings are specific. # settings are specific.
@@ -89,10 +89,10 @@ in
# instance = instance_foo # instance = instance_foo
# roles = peer # roles = peer
# machines = jon # machines = jon
specificMachineSettings = filterInternals res.importedModulesEvaluated.self-A.config.result.allMachines.jon.passthru.instances.instance_foo.roles.peer.machines.jon.settings; specificMachineSettings = filterInternals res.importedModulesEvaluated.self-A.result.allMachines.jon.passthru.instances.instance_foo.roles.peer.machines.jon.settings;
hasRoleSettings = hasRoleSettings =
res.importedModulesEvaluated.self-A.config.result.allMachines.jon.passthru.instances.instance_foo.roles.peer res.importedModulesEvaluated.self-A.result.allMachines.jon.passthru.instances.instance_foo.roles.peer
? settings; ? settings;
# settings are specific. # settings are specific.
@@ -100,7 +100,7 @@ in
# instance = instance_foo # instance = instance_foo
# roles = peer # roles = peer
# machines = * # machines = *
specificRoleSettings = filterInternals res.importedModulesEvaluated.self-A.config.result.allMachines.jon.passthru.instances.instance_foo.roles.peer.settings; specificRoleSettings = filterInternals res.importedModulesEvaluated.self-A.result.allMachines.jon.passthru.instances.instance_foo.roles.peer.settings;
}; };
expected = { expected = {
hasMachineSettings = true; hasMachineSettings = true;

View File

@@ -47,7 +47,7 @@ in
(pkgs.nixosOptionsDoc { (pkgs.nixosOptionsDoc {
options = options =
(self.clanLib.evalService { (self.clanLib.evalService {
modules = [ ]; modules = [ { _docs_rendering = true; } ];
prefix = [ ]; prefix = [ ];
}).options; }).options;
warningsAreErrors = true; warningsAreErrors = true;