Compare commits

...

3 Commits

Author SHA1 Message Date
pinpox
2ef7864c9f Add ssl module example 2025-10-31 17:11:27 +01:00
pinpox
9c426dad76 Add zerotier-ip export 2025-10-31 16:11:40 +01:00
Johannes Kirschbauer
f9fc47093b Exports POC 2025-10-30 16:13:31 +01:00
29 changed files with 1026 additions and 312 deletions

View File

@@ -32,17 +32,15 @@
};
perInstance =
{
roles,
lib,
instanceName,
settings,
machine,
...
}:
{
exports.networking = {
# TODO add user space network support to clan-cli
peers = lib.mapAttrs (_name: machine: {
host.plain = machine.settings.host;
SSHOptions = map (_x: "-J x") machine.settings.jumphosts;
}) roles.default.machines;
exports."internet/${instanceName}/default/${machine.name}".networking = {
hosts = [ settings.host ];
};
};
};

View File

@@ -0,0 +1 @@
This a test README just to appease the eval warnings if we don't have one

View File

@@ -0,0 +1,111 @@
/*
Set up a CA chain for the clan. There will be one root CA for each instance
of the ssl service, then each host has its own host CA that is signed by the
instance-wide root CA.
Trusting the root CA, will result in also trusting the individual host CAs,
as they are signed by it.
Hosts can then use their respective host CAs to expose SSL secured services.
*/
{
exports,
config,
lib,
...
}:
{
_class = "clan.service";
manifest.name = "clan-core/ssl";
manifest.description = "Set up a CA infrastucture for your clan";
manifest.readme = builtins.readFile ./README.md;
# Generate a root CA for each instances of the ssl module.
exports = lib.mapAttrs' (instanceName: _: {
"ssl/${instanceName}///".vars.generators.ssl-root-ca =
{ config, ... }:
{
files.key = { };
files.cert.secret = false;
runtimeInputs = [
config.pkgs.pkgs.openssl
];
script = ''
# Generate CA private key (4096-bit RSA)
openssl genrsa -out "$out/key" 4096
# Generate self-signed CA certificate (valid for 10 years)
openssl req -new -x509 \
-key "$out/key" \
-out "$out/cert" \
-days 3650 \
-subj "/C=US/ST=State/L=City/O=Organization/OU=IT/CN=Root CA" \
-sha256
'';
};
}) config.instances;
roles.default = {
description = "Generate a host CA, signed by the root CA and trust the root CA";
perInstance =
{
instanceName,
machine,
...
}:
{
# Generate a host CA, which depends on (is signed by) the root CA
exports = {
"ssl/${instanceName}/default/${machine.name}/".vars.generators.ssl-host-ca =
{ config, ... }:
{
dependencies = {
ssl-root-ca = exports."ssl/${instanceName}///".vars.generators.ssl-root-ca;
};
files.key = { };
files.cert.secret = false;
runtimeInputs = [
config.pkgs.pkgs.openssl
];
script = ''
# Generate intermediate CA private key (4096-bit RSA)
openssl genrsa -out "$out/key" 4096
# Generate Certificate Signing Request (CSR) for intermediate CA
openssl req -new \
-key "$out/key" \
-out "$out/csr" \
-subj "/C=US/ST=State/L=City/O=Organization/OU=IT/CN=Host CA"
# Sign the CSR with the root CA to create the intermediate certificate
openssl x509 -req \
-in "$out/csr" \
-CA "$dependencies/ssl-root-ca/cert" \
-CAkey "$dependencies/ssl-root-ca/key" \
-CAcreateserial \
-out "$out/cert" \
-days 3650 \
-sha256 \
-extfile <(printf "basicConstraints=CA:TRUE\nkeyUsage=keyCertSign,cRLSign")
'';
};
};
nixosModule =
{ ... }:
{
# We trust the (public) root CA certificate on all machines with this role
security.pki.certificateFiles = [
exports."ssl/${instanceName}///".vars.generators.ssl-root-ca.files.cert.path
];
};
};
};
}

View File

@@ -0,0 +1,47 @@
{
self,
inputs,
lib,
...
}:
let
module = ./default.nix;
in
{
clan.modules = {
ssl = module;
};
perSystem =
{ ... }:
let
# Module that contains the tests
# This module adds:
# - legacyPackages.<system>.eval-tests-ssl
# - checks.<system>.eval-tests-ssl
# unit-test-module = (
# self.clanLib.test.flakeModules.makeEvalChecks {
# inherit module;
# inherit inputs;
# fileset = lib.fileset.unions [
# # The ssl service being tested
# ../../clanServices/ssl
# # Required modules
# ../../nixosModules/clanCore
# ];
# testName = "ssl";
# tests = ./tests/eval-tests.nix;
# # Optional arguments passed to the test
# testArgs = { };
# }
# );
in
{
# imports = [ unit-test-module ];
clan.nixosTests.ssl = {
imports = [ ./tests/vm/default.nix ];
clan.modules.ssl = module;
};
};
}

View File

@@ -0,0 +1,23 @@
{
name = "ssl";
clan = {
directory = ./.;
inventory = {
machines.peer1 = { };
machines.peer2 = { };
instances."test" = {
module.name = "ssl";
module.input = "self";
roles.default.machines.peer1 = { };
};
};
};
testScript =
{ ... }:
''
start_all()
'';
}

View File

@@ -74,13 +74,20 @@
# TODO make it nicer @lassulus, @picnoir wants microlens
# Get a list of all exported IPs from all VPN modules
exportedPeerIPs = builtins.foldl' (
acc: e:
if e == { } then
acc
else
acc ++ (lib.flatten (builtins.filter (s: s != "") (lib.attrValues (select' "peers.*.plain" e))))
) [ ] (lib.attrValues (select' "instances.*.networking.?peers.*.host.?plain" exports));
# exportedPeerIPs = builtins.foldl' (
# acc: e:
# if e == { } then
# acc
# else
# acc ++ (lib.flatten (builtins.filter (s: s != "") (lib.attrValues (select' "peers.*.plain" e))))
# ) [ ] (lib.attrValues (select' "*.networking.?peers.*.host.?plain" exports));
# exports."internet/${instanceName}/default/${machine.name}".networking = {
# hosts = [ settings.host ];
# };
# exportedPeerIPs = (select' "*".networking.hosts exports);
exportedPeerIPs = lib.flatten (builtins.attrValues (select' "*.networking.hosts" exports));
# Construct a list of peers in yggdrasil format
exportedPeers = lib.flatten (map mkPeers exportedPeerIPs);

View File

@@ -21,9 +21,16 @@
# Peers are set form exports of the internet service
instances."internet" = {
module.name = "internet";
roles.default.machines.peer1.settings.host = "peer1";
roles.default.machines.peer2.settings.host = "peer2";
roles.default.machines.peer1.settings.host = "peer1-internet";
roles.default.machines.peer2.settings.host = "peer2-internet";
};
instances."zerotier" = {
module.name = "zerotier";
roles.controller.machines.peer1 = { };
roles.peer.machines.peer2 = { };
};
};
};

View File

@@ -0,0 +1 @@
../../../../../../sops/machines/peer1

View File

@@ -0,0 +1,18 @@
{
"data": "ENC[AES256_GCM,data:ZkirPKTvLpV3+aMklbRIkafGCMISIRrqgFu8B0A1nQEdeqRR0bexoRuzLopuj95mqPKYHWT9ArF8zDqVW9t4UgazTgprK/coFlKk/2wO8dO2JmVcFlGZou2Hz6JVvt8xuELU350lpF+o4k1xmAqswqaRQyqgAIvVDnym/jZPj9hBZpSXr/IcUnH4cXcNv51Xt82Zvo132RoaU1warlNk1p3dr1DRHU56KtEwhkj9YxoIcS4K4BaEl9L87REXnFEBu5p8FeO1f3bp/ZFOxL7bYKROFHYhK4mIlSTVmYJg4a1CP0M7v842xm83C37Y6xgN8SltC/ld9TuxBNVhfzmHHotpBXvAbwxkCJE6ChJI,iv:M4jqMRvbjODcWGjJUMc3ys4Tra0KBwVXOVMoeXcAXuQ=,tag:irDJqWEeXlIXOv/DMZWlGQ==,type:str]",
"sops": {
"age": [
{
"recipient": "age1p8trv2dmpanl3gnzj294c4t5uysu7d6rfjncp5lmn6redyda8fns6p7kca",
"enc": "-----BEGIN AGE ENCRYPTED FILE-----\nYWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSBwVGJGWlZOb05QL3AzSzFM\nUG4vV3RFK2RjVEhVd2QzQ3pTMUl0UmFLaURnCkRORDBuK0xUM1pYSFRFZXlpK1Na\nUHp6b3pWeEl0SkF2ZERaa3gyczh0RlkKLS0tIHFoanBkS1Jhc3ovQlJFV0lCQVpY\nUEUrcmZlbkhQa0lac3pqenBXWkpDZTgKNQ6Lu4L6zHKTN4pe2T3eg7lvTeZQ2/mf\nD33YfN15W/yuOb+LzVTwSj6wPgQuSaVRlgbCm/t1adzTnUZmruWxuA==\n-----END AGE ENCRYPTED FILE-----\n"
},
{
"recipient": "age1qm0p4vf9jvcnn43s6l4prk8zn6cx0ep9gzvevxecv729xz540v8qa742eg",
"enc": "-----BEGIN AGE ENCRYPTED FILE-----\nYWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSA1YVMrSEkybzJpdXVHQWtP\nMzQ2QXZmQXJNL05ORDRobWZmQmdrTWtiVDJZCk9Wckg4eVJiU21BcFQ4MDhjTzlw\nVnh6b25NM3ZSNXRIQUEwd0RaSjg1MW8KLS0tICtqVWxpN09CSC9kcUdvRmw1RmRh\nOHlWQXEwYWFPY2VsM0Q0RzJyL2FWNUUK3f7t64UBdGtzxo0upCugNvA2vKUXL6gb\n0CJq4MG1s+lgFpvenRlozsaG3I8IxPHkFWuTA6OuUCCwaJqb0eT4ZA==\n-----END AGE ENCRYPTED FILE-----\n"
}
],
"lastmodified": "2025-10-31T14:34:48Z",
"mac": "ENC[AES256_GCM,data:+mMvTo1+4f9rQm1U6td5Sx7NYeuKJQeXcTpFOooAV8wt75XX2VhX059/S3krFJ8vIsMUqQ0PqPLipCNTaTi8cxkqHfsVQEGCcALGtisk5bnHWgipnFoaO6Ao9TKkmFBcQo9za9+Z40stNIzThOHWaZonvp9KWIVj92CFic62UT8=,iv:HhVf1rhN6Ocp6Bif1oXQScJUe4ndFw3Rv/obVYDx5aA=,tag:9M5iMVcj3ore3DQtwdJuMQ==,type:str]",
"version": "3.11.0"
}
}

View File

@@ -0,0 +1 @@
../../../../../../sops/users/admin

View File

@@ -0,0 +1 @@
fd06:8020:2351:b57:2899:9306:8020:2351

View File

@@ -0,0 +1 @@
../../../../../../sops/machines/peer2

View File

@@ -0,0 +1,18 @@
{
"data": "ENC[AES256_GCM,data:gzHNCz/yRXD9sXRvqpGC18ZUF1JLvpBO44klfRjl6WzCPHLrC9Mp6cGFa+U3CZL2i/0JGKOtQGH+82Ra6oAkOiWEcSRN/xmAmcZaoVPTnvZ2tF7vvlRfR5hq+p/ZQw4+Y4V1TIuYj2dLNrVIIGYmWSabqI0mgVTTjyRsDJSB4YgqGTYismvZ9QXICSDxwROIrC2xl0Xx+MYWhxR1PVJ3B1HbJ8KEQCuBVq46Wki/INe0bD+ODlxCv9GCGPgaNjMwACOwQXo5WGP9zSDq2HEkTeg5YUmX1o1G6LwkG2fY/Hr5XMiLGU6G0remP/WbCOoLRXdB/Luevg/rTlQ/dNDawPARsbZZSjLmk/BHUOUJ,iv:zPeIyZi2ckbEcbX4FFhyN3ryWf4eoRu4XIafeAje28E=,tag:8/Vn0m+/wMGY706fYX55Vg==,type:str]",
"sops": {
"age": [
{
"recipient": "age107mprppm3r9u7f26e6t5mhtdny0h5ugfmfjy8kac2tw9nrh9a3ksex0xca",
"enc": "-----BEGIN AGE ENCRYPTED FILE-----\nYWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSBDYlU4cG1KYXZodFJYYXNo\ndjhNbUFzNEhySzI2NmduR0EwOUhENFRZN3o4CmtSNG5ObkM2bDJXaXk1QlFVWURK\nV1lRa1VVV0hNZlh0eVJpVHFqU3FXMzgKLS0tIFhtUjZnZVdMczNFVUMrL2Q0b1Rz\nRFlzTUFXVWZwM2gwRW1LTzd0a2lhQTAKHyakwS8kB4Gg4Vjs3PJsbF3VHzJjAbOR\nR+y6op3zPjQpr5QfsRn4MoES/ViGDPZWLYxXUSMctGVDxIfgdZxP9A==\n-----END AGE ENCRYPTED FILE-----\n"
},
{
"recipient": "age1qm0p4vf9jvcnn43s6l4prk8zn6cx0ep9gzvevxecv729xz540v8qa742eg",
"enc": "-----BEGIN AGE ENCRYPTED FILE-----\nYWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSA1dHBaY015Q2J2NlRyRGEy\nRGtRcm1YckhYSm5mbU5GaGFaTjhRa1UraWpRCnFWSDBSYURFS21QYUYxVXdKdGVi\nY1hiN3c3eTlJUWo2dXZXUk9TN3g3ZVkKLS0tIGJneUlaMU1KeVVBcXN5L3FIMjNP\nYkpWTVA3d2k1a3Y5Yk9kUUF3SFo2V2sKGLQYVmX8HnDqX5K/tdbfgYnpVmaTArIY\nuhw+CtrXmEHhksZqgGCcjEoCz7cDMzMA42kVdqh/OfFzJNxrRfJjPA==\n-----END AGE ENCRYPTED FILE-----\n"
}
],
"lastmodified": "2025-10-31T14:59:28Z",
"mac": "ENC[AES256_GCM,data:MWpzOKUYXkmw2DX6YsN5pPIF9Y6GZ4rPnwq3uaOnFm40SOXPN2/JXSL7E9bGgaBeboUbChNwiGmBBRQX+7d2Te/NoItJAPw4YJTtquA+Rb7+sgPUoL6kYP7YZfjw1Z2hi61YMYXZH0/q4tBx6SNukt7o/uRYLu2LjyO09251uO4=,iv:YVXr5u2xwVEOlG+xYguAO1ZsCXvMx6rhXBV24CkFPv8=,tag:AOK4Pi2YYx4w0je9gALDLw==,type:str]",
"version": "3.11.0"
}
}

View File

@@ -0,0 +1 @@
../../../../../../sops/users/admin

View File

@@ -0,0 +1 @@
fd06:8020:2351:b57:2899:9340:7f3b:e1b3

View File

@@ -1,5 +1,6 @@
{
clanLib,
directory,
...
}:
{
@@ -16,21 +17,23 @@
instanceName,
roles,
lib,
machine,
...
}:
{
exports.networking = {
priority = lib.mkDefault 900;
# TODO add user space network support to clan-cli
module = "clan_lib.network.zerotier";
peers = lib.mapAttrs (name: _machine: {
host.var = {
machine = name;
exports."internet/${instanceName}/peer/${machine.name}".networking = {
hosts = lib.flatten [
(clanLib.vars.getPublicValue {
flake = directory;
machine = machine.name;
generator = "zerotier";
file = "zerotier-ip";
# default = throw "kaputt";
})
];
};
}) roles.peer.machines;
};
nixosModule =
{
config,

View File

@@ -40,6 +40,9 @@ lib.fix (
# TODO: Flatten our lib functions like this:
resolveModule = clanLib.callLib ./resolve-module { };
# Functions to help define exports
exports = clanLib.callLib ./exports.nix { };
fs = {
inherit (builtins) pathExists readDir;
};

88
lib/exports.nix Normal file
View File

@@ -0,0 +1,88 @@
{ lib }:
let
/**
Creates a scope string for global exports
At least one of serviceName or machineName must be set.
The scope string has the format:
"/SERVICE/INSTANCE/ROLE/MACHINE"
If the parameter is not set, the corresponding part is left empty.
Semantically this means "all".
Examples:
mkScope { serviceName = "A"; }
-> "/A///"
mkScope { machineName = "jon"; }
-> "///jon"
mkScope { serviceName = "A"; instanceName = "i1"; roleName = "peer"; machineName = "jon"; }
-> "/A/i1/peer/jon"
*/
mkScope =
{
serviceName ? "",
instanceName ? "",
roleName ? "",
machineName ? "",
}:
let
parts = [
serviceName
instanceName
roleName
machineName
];
checkedParts = lib.map (
part:
lib.throwIf (builtins.match ".?/.?" part != null) ''
clanLib.exports.mkScope: ${part} cannot contain the "/" character
''
) parts;
in
lib.throwIf ((serviceName == "" && machineName == "")) ''
clanLib.exports.mkScope requires at least 'serviceName' or 'machineName' to be set
In case your use case requires neither
'' (lib.join "/" checkedParts);
/**
Parses a scope string into its components
Returns an attribute set with the keys:
- serviceName
- instanceName
- roleName
- machineName
Example:
parseScope "A/i1/peer/jon"
->
{
serviceName = "A";
instanceName = "i1";
roleName = "peer";
machineName = "jon";
}
*/
parseScope =
scopeStr:
let
parts = lib.splitString "/" scopeStr;
checkedParts = lib.throwIf (lib.length parts != 4) ''
clanLib.exports.parseScope: invalid scope string format, expected 4 parts separated by 3 "/"
'' (parts);
in
{
serviceName = lib.elemAt 0 checkedParts;
instanceName = lib.elemAt 1 checkedParts;
roleName = lib.elemAt 2 checkedParts;
machineName = lib.elemAt 3 checkedParts;
};
in
{
inherit mkScope parseScope;
}

View File

@@ -103,6 +103,11 @@ rec {
inherit lib;
clan-core = self;
};
# Run: nix-unit --extra-experimental-features flakes --flake .#legacyPackages.x86_64-linux.evalTests-build-clan
legacyPackages.eval-exports = import ./new_exports.nix {
inherit lib;
clan-core = self;
};
checks = {
eval-lib-build-clan = pkgs.runCommand "tests" { nativeBuildInputs = [ pkgs.nix-unit ]; } ''
export HOME="$(realpath .)"

View File

@@ -2,6 +2,7 @@
{
# TODO: consume directly from clan.config
directory,
exports,
}:
{
lib,
@@ -17,10 +18,10 @@ in
{
# TODO: merge these options into clan options
options = {
exportsModule = mkOption {
type = types.deferredModule;
readOnly = true;
};
# exportsModule = mkOption {
# type = types.deferredModule;
# readOnly = true;
# };
mappedServices = mkOption {
visible = false;
type = attrsWith {
@@ -28,9 +29,11 @@ in
elemType = submoduleWith {
class = "clan.service";
specialArgs = {
directory = directory;
clanLib = specialArgs.clanLib;
exports = config.exports;
inherit
exports
directory
;
};
modules = [
(
@@ -51,34 +54,13 @@ in
default = { };
};
exports = mkOption {
type = submoduleWith {
modules = [
{
options = {
instances = lib.mkOption {
default = { };
# instances.<instanceName>...
type = types.attrsOf (submoduleWith {
modules = [
config.exportsModule
];
});
};
# instances.<machineName>...
machines = lib.mkOption {
default = { };
type = types.attrsOf (submoduleWith {
modules = [
config.exportsModule
];
});
};
};
}
]
++ lib.mapAttrsToList (_: service: service.exports) config.mappedServices;
};
default = { };
type = types.lazyAttrsOf types.deferredModule;
# collect exports from all services
# zipAttrs is needed until we use the record type.
default = lib.zipAttrsWith (_name: values: { imports = values; }) (
lib.mapAttrsToList (_name: service: service.exports) config.mappedServices
);
};
};
}

View File

@@ -504,7 +504,7 @@ in
staticModules = [
({
options.exports = mkOption {
type = types.deferredModule;
type = types.lazyAttrsOf types.deferredModule;
default = { };
description = ''
!!! Danger "Experimental Feature"
@@ -634,8 +634,16 @@ in
type = types.deferredModuleWith {
staticModules = [
({
# exports."///".generator.name = { _file ... import = []; _type = }
# exports."///".networking = { _file ... import = []; }
# generators."///".name = { name, ...}: { _file ... import = [];}
# networks."///" = { _file ... import = []; }
# { _file ... import = []; }
# { _file ... import = []; }
options.exports = mkOption {
type = types.deferredModule;
type = types.lazyAttrsOf types.deferredModule;
default = { };
description = ''
!!! Danger "Experimental Feature"
@@ -767,79 +775,38 @@ in
```
'';
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;
type = types.lazyAttrsOf (
types.deferredModuleWith {
# staticModules = [];
# lib.concatLists (
# lib.concatLists (
# lib.mapAttrsToList (
# _roleName: role:
# lib.mapAttrsToList (
# _instanceName: instance: lib.mapAttrsToList (_machineName: v: v.exports) instance.allMachines
# ) role.allInstances
# ) config.result.allRoles
# )
# )
# ++
}
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;
}
];
};
);
# # 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
@@ -1024,5 +991,39 @@ in
}
) config.result.allMachines;
};
debug = mkOption {
default = lib.zipAttrsWith (_name: values: { imports = values; }) (
lib.mapAttrsToList (_machineName: machine: machine.exports) config.result.allMachines
);
};
};
imports = [
{
# collect exports from all machines
# zipAttrs is needed until we use the record type.
exports = lib.zipAttrsWith (_name: values: { imports = values; }) (
lib.mapAttrsToList (_machineName: machine: machine.exports) config.result.allMachines
);
}
{
# collect exports from all instances, roles and machines
# zipAttrs is needed until we use the record type.
exports = lib.zipAttrsWith (_name: values: { imports = values; }) (
lib.concatLists (
lib.concatLists (
lib.mapAttrsToList (
_roleName: role:
lib.mapAttrsToList (
_instanceName: instance: lib.mapAttrsToList (_machineName: v: v.exports) instance.allMachines
) role.allInstances
) config.result.allRoles
)
)
);
}
];
}

221
lib/new_exports.nix Normal file
View File

@@ -0,0 +1,221 @@
{
clan-core,
lib,
}:
# TODO: TEST: define a clan without machines
{
test_simple =
let
eval = clan-core.clanLib.clan {
exports."///".foo = lib.mkForce eval.config.exports."///".bar;
directory = ./.;
self = {
clan = eval.config;
inputs = { };
};
machines.jon = { };
machines.sara = { };
exportsModule =
{ lib, ... }:
{
options.foo = lib.mkOption {
type = lib.types.number;
default = 0;
};
options.bar = lib.mkOption {
type = lib.types.number;
default = 0;
};
};
####### Service module "A"
modules.service-A =
{ ... }:
{
# config.exports
manifest.name = "A";
roles.default = {
# TODO: Remove automapping
# Currently exports are automapped
# scopes "/service=A/instance=hello/role=default/machine=jon"
# perInstance.exports.foo = 7;
# New style:
# Explizit scope
# perInstance.exports."service=A/instance=hello/role=default/machine=jon".foo = 7;
perInstance =
{ instanceName, machine, exports, ... }:
{
exports."A/${instanceName}/default/${machine.name}" = {
foo = 7;
# define export depending on B
bar = exports."B/B/default/${machine.name}".foo + 35;
};
# exports."A/${instanceName}/default/${machine.name}".
# default behavior
# exports = scope.mkExports { foo = 7; };
# We want to export things for different scopes from this scope;
# If this scope is used.
#
# Explicit scope; different from the function scope above
# exports = clanLib.scopedExport {
# # Different role export
# role = "peer";
# serviceName = config.manifest.name;
# inherit instanceName machineName;
# } { foo = 7; };
};
};
perMachine =
{ ... }:
{
#
# exports = scope.mkExports { foo = 7; };
# exports."A///${machine.name}".foo = 42;
# exports."B///".foo = 42;
};
# scope "/service=A/instance=??/role=??/machine=jon"
# perMachine.exports.foo = 42;
# scope "/service=A/instance=??/role=??/machine=??"
# exports."///".foo = 10;
};
####### Service module "A"
modules.service-B =
{ exports, ... }:
{
# config.exports
manifest.name = "B";
roles.default = {
# TODO: Remove automapping
# Currently exports are automapped
# scopes "/service=A/instance=hello/role=default/machine=jon"
# perInstance.exports.foo = 7;
# New style:
# Explizit scope
# perInstance.exports."service=A/instance=hello/role=default/machine=jon".foo = 7;
perInstance =
{ instanceName, machine, ... }:
{
# TODO: Test non-existing scope
# define export depending on A
exports."B/${instanceName}/default/${machine.name}".foo = exports."///".foo + exports."A/A/default/${machine.name}".foo;
# exports."B/B/default/jon".foo = exports."A/A/default/jon".foo;
# default behavior
# exports = scope.mkExports { foo = 7; };
# We want to export things for different scopes from this scope;
# If this scope is used.
#
# Explicit scope; different from the function scope above
# exports = clanLib.scopedExport {
# # Different role export
# role = "peer";
# serviceName = config.manifest.name;
# inherit instanceName machineName;
# } { foo = 7; };
};
};
perMachine =
{ ... }:
{
# exports = scope.mkExports { foo = 7; };
# exports."A///${machine.name}".foo = 42;
# exports."B///".foo = 42;
};
# scope "/service=A/instance=??/role=??/machine=jon"
# perMachine.exports.foo = 42;
# scope "/service=A/instance=??/role=??/machine=??"
exports."///".foo = 10;
};
#######
inventory = {
instances.A = {
module.name = "service-A";
module.input = "self";
roles.default.tags = [ "all" ];
};
instances.B = {
module.name = "service-B";
module.input = "self";
roles.default.tags = [ "all" ];
};
};
# <- inventory
#
# -> exports
/**
Current state
{
instances = {
hello = { networking = null; };
};
machines = {
jon = { networking = null; };
};
}
*/
/**
Target state: (Flat attribute set)
tdlr;
# roles / instance level definitions may not exist on their own
# role and instance names are completely arbitrary.
# For example what does it mean: this is a export for all "peer" roles of all service-instances? That would be magic on the roleName.
# Or exports for all instances with name "ifoo" ? That would be magic on the instanceName.
# Practical combinations
# always include either the service name or the machine name
exports = {
# Clan level (1)
"///" networks generators
# Service anchored (8) : min 1 instance is needed ; machines may not exist
"A///" <- service specific
"A/instance//" <- instance of a service
"A//peer/" <- role of a service
"A/instance/peer/" <- instance+role of a service
"A///machine" <- machine of a service
"A/instance//machine" <- machine + instance of a service
"A//role/machine" <- machine + role of a service
"A/instance/role/machine" <- machine + role + instance of a service
# Machine anchored (1 or 2)
"///jon" <- this machine
"A///jon" <- role on a machine (dupped with service anchored)
# Unpractical; probably not needed (5)
"//peer/jon" <- role on a machine
"/instance//jon" <- role on a machine
"/instance//" <- instance: All "foo" instances everywhere?
"//role/" <- role: All "peer" roles everywhere?
"/instance/role/" <- instance role: Applies to all services, whose instance name has "ifoo" and role is "peer" (double magic)
# TODO: lazyattrs poc
}
*/
};
in
{
inherit eval;
expr = eval;
expected = 42;
};
}

View File

@@ -1,4 +1,21 @@
{ lib, ... }:
let
inherit (lib)
mapAttrs
attrNames
showOption
setDefaultModuleLocation
mkOptionType
isAttrs
filterAttrs
intersectAttrs
mapAttrsToList
mkOptionDefault
zipAttrsWith
seq
fix
;
in
{
/**
A custom type for deferred modules that guarantee to be JSON serializable.
@@ -12,7 +29,7 @@
- Enforces that the definition is JSON serializable
- Disallows nested imports
*/
uniqueDeferredSerializableModule = lib.fix (
uniqueDeferredSerializableModule = fix (
self:
let
checkDef =
@@ -23,19 +40,18 @@
def;
in
# Essentially the "raw" type, but with a custom name and check
lib.mkOptionType {
mkOptionType {
name = "deferredModule";
description = "deferred custom module. Must be JSON serializable.";
descriptionClass = "noun";
# Unfortunately, tryEval doesn't catch JSON errors
check = value: lib.seq (builtins.toJSON value) (lib.isAttrs value);
check = value: seq (builtins.toJSON value) (isAttrs value);
merge = lib.options.mergeUniqueOption {
message = "------";
merge = loc: defs: {
imports = map (
def:
lib.seq (checkDef loc def) lib.setDefaultModuleLocation
"${def.file}, via option ${lib.showOption loc}"
seq (checkDef loc def) setDefaultModuleLocation "${def.file}, via option ${showOption loc}"
def.value
) defs;
};
@@ -48,4 +64,113 @@
};
}
);
/**
New submodule type that allows merging at the attribute level.
:::note
'record' type adopted from https://github.com/NixOS/nixpkgs/pull/334680
:::
It applies additional constraints to immediate child options:
- No support for 'readOnly'
- No support for 'apply'
- No support for type-merging: That means the modules options must be pre-declared directly.
*/
record =
{
optional ? { },
required ? { },
wildcardType ? null,
}:
mkOptionType {
name = "record";
description =
if wildcardType == null then "record" else "open record of ${wildcardType.description}";
descriptionClass = if wildcardType == null then "noun" else "composite";
check = isAttrs;
merge.v2 =
{ loc, defs }:
let
pushPositions = map (
def:
mapAttrs (_n: v: {
inherit (def) file;
value = v;
}) def.value
);
# Checks
intersection = intersectAttrs optional required;
optionalDefault = filterAttrs (_: opt: opt ? default) optional;
# Definitions + option defaults
allDefs =
defs
++ (mapAttrsToList (name: opt: {
file = (builtins.unsafeGetAttrPos name required).file or "<unknown-file>";
value = {
${name} = mkOptionDefault opt.default;
};
}) (filterAttrs (_n: opt: opt ? default) required));
merged = zipAttrsWith (
name: defs:
let
elemType = optional.${name}.type or required.${name}.type or wildcardType;
in
lib.modules.mergeDefinitions (loc ++ [ name ]) elemType defs
) (pushPositions allDefs);
in
{
headError =
if intersection != { } then
{
message = "The following attributes of '${showOption loc}' are both declared in 'optional' and in 'required': ${lib.concatStringsSep ", " (attrNames intersection)}";
}
else if optionalDefault != { } then
{
message = "The following attributes of '${showOption loc}' are declared in 'optional' cannot have a default value: ${lib.concatStringsSep ", " (attrNames optionalDefault)}";
}
else
null;
# TODO: expose fields, fieldValues and extraValues
valueMeta = {
attrs = mapAttrs (_n: v: v.checkedAndMerged.valueMeta) merged;
};
value = mapAttrs (
name: v:
let
elemType = optional.${name}.type or required.${name}.type or wildcardType;
in
if required ? ${name} then
# Non-optional, lazy ?
v.mergedValue
else
# Optional, lazy
v.optionalValue.value or elemType.emptyValue.value or v.mergedValue
) merged;
};
nestedTypes = lib.optionalAttrs (wildcardType != null) {
inherit wildcardType;
};
getSubOptions =
prefix:
# Since this type doesn't support type merging, we can safely use the original attrs to display documentation.
mapAttrs (
name: opt:
(
opt
// {
loc = prefix ++ [ name ];
inherit name;
declarations = [
(builtins.unsafeGetAttrPos name optional).file or (builtins.unsafeGetAttrPos name required).file
or "<unknown-file>"
];
}
)
) (optional // required);
};
}

View File

@@ -0,0 +1,44 @@
{ lib, clanLib, ... }:
let
inherit (lib) evalModules mkOption;
inherit (clanLib.types) record;
in
{
test_simple =
let
eval = evalModules {
modules = [
{
options.foo = mkOption {
type = record { };
default = { };
};
}
];
};
in
{
inherit eval;
expr = eval.config.foo;
expected = { };
};
test_wildcard =
let
eval = evalModules {
modules = [
{
options.foo = mkOption {
type = record { };
default = { };
};
}
];
};
in
{
inherit eval;
expr = eval.config.foo;
expected = { };
};
}

View File

@@ -1,92 +1,5 @@
{ lib, clanLib, ... }:
let
evalSettingsModule =
m:
lib.evalModules {
modules = [
{
options.foo = lib.mkOption {
type = clanLib.types.uniqueDeferredSerializableModule;
};
}
m
];
};
in
{
test_simple =
let
eval = evalSettingsModule {
foo = { };
};
in
{
inherit eval;
expr = eval.config.foo;
expected = {
# Foo has imports
# This can only ever be one module due to the type of foo
imports = [
{
# This is the result of 'setDefaultModuleLocation'
# Which also returns exactly one module
_file = "<unknown-file>, via option foo";
imports = [
{ }
];
}
];
};
};
test_no_nested_imports =
let
eval = evalSettingsModule {
foo = {
imports = [ ];
};
};
in
{
inherit eval;
expr = eval.config.foo;
expectedError = {
type = "ThrownError";
message = "*nested imports";
};
};
test_no_function_modules =
let
eval = evalSettingsModule {
foo =
{ ... }:
{
};
};
in
{
inherit eval;
expr = eval.config.foo;
expectedError = {
type = "TypeError";
message = "cannot convert a function to JSON";
};
};
test_non_attrs_module =
let
eval = evalSettingsModule {
foo = "foo.nix";
};
in
{
inherit eval;
expr = eval.config.foo;
expectedError = {
type = "ThrownError";
message = ".*foo.* is not of type";
};
};
unique = import ./unique_tests.nix { inherit lib clanLib; };
record = import ./record_tests.nix { inherit lib clanLib; };
}

View File

@@ -0,0 +1,92 @@
{ lib, clanLib, ... }:
let
evalSettingsModule =
m:
lib.evalModules {
modules = [
{
options.foo = lib.mkOption {
type = clanLib.types.uniqueDeferredSerializableModule;
};
}
m
];
};
in
{
test_not_defined =
let
eval = evalSettingsModule {
foo = { };
};
in
{
inherit eval;
expr = eval.config.foo;
expected = {
# Foo has imports
# This can only ever be one module due to the type of foo
imports = [
{
# This is the result of 'setDefaultModuleLocation'
# Which also returns exactly one module
_file = "<unknown-file>, via option foo";
imports = [
{ }
];
}
];
};
};
test_no_nested_imports =
let
eval = evalSettingsModule {
foo = {
imports = [ ];
};
};
in
{
inherit eval;
expr = eval.config.foo;
expectedError = {
type = "ThrownError";
message = "*nested imports";
};
};
test_no_function_modules =
let
eval = evalSettingsModule {
foo =
{ ... }:
{
};
};
in
{
inherit eval;
expr = eval.config.foo;
expectedError = {
type = "TypeError";
message = "cannot convert a function to JSON";
};
};
test_non_attrs_module =
let
eval = evalSettingsModule {
foo = "foo.nix";
};
in
{
inherit eval;
expr = eval.config.foo;
expectedError = {
type = "ThrownError";
message = ".*foo.* is not of type";
};
};
}

View File

@@ -111,11 +111,11 @@ in
};
modules = [
(import ../../lib/inventory/distributed-service/all-services-wrapper.nix {
inherit (clanConfig) directory;
inherit (clanConfig) directory exports;
})
# Dependencies
{
exportsModule = clanConfig.exportsModule;
# exportsModule = clanConfig.exportsModule;
}
{
# TODO: Rename to "allServices"

View File

@@ -110,9 +110,7 @@ in
# TODO: make this writable by moving the options from inventoryClass into clan.
exports = lib.mkOption {
readOnly = true;
visible = false;
internal = true;
type = types.lazyAttrsOf (types.submoduleWith { modules = [ config.exportsModule ]; });
};
exportsModule = lib.mkOption {
@@ -120,11 +118,8 @@ in
visible = false;
type = types.deferredModule;
default = {
options.networking = lib.mkOption {
default = null;
type = lib.types.nullOr (
lib.types.submodule {
options = {
options.networking = {
priority = lib.mkOption {
type = lib.types.int;
default = 1000;
@@ -146,58 +141,63 @@ in
'';
};
# should we call this machines? hosts?
peers = lib.mkOption {
# <name>
type = lib.types.attrsOf (
lib.types.submodule (
{ name, ... }:
{
options = {
name = lib.mkOption {
type = lib.types.str;
default = name;
};
SSHOptions = lib.mkOption {
hosts = lib.mkOption {
type = lib.types.listOf lib.types.str;
default = [ ];
};
host = lib.mkOption {
description = '''';
type = lib.types.attrTag {
plain = lib.mkOption {
type = lib.types.str;
description = ''
a plain value, which can be read directly from the config
'';
};
var = lib.mkOption {
type = lib.types.submodule {
options = {
machine = lib.mkOption {
type = lib.types.str;
example = "jon";
};
generator = lib.mkOption {
type = lib.types.str;
example = "tor-ssh";
};
file = lib.mkOption {
type = lib.types.str;
example = "hostname";
};
};
};
};
};
};
};
}
)
);
};
};
}
);
# peers = lib.mkOption {
#
# # <name>
# type = lib.types.attrsOf (
# lib.types.submodule (
# { name, ... }:
# {
# options = {
# name = lib.mkOption {
# type = lib.types.str;
# default = name;
# };
# SSHOptions = lib.mkOption {
# type = lib.types.listOf lib.types.str;
# default = [ ];
# };
#
# host = lib.mkOption {
# description = '''';
# type = lib.types.attrTag {
# plain = lib.mkOption {
# type = lib.types.str;
# description = ''
# a plain value, which can be read directly from the config
# '';
# };
# var = lib.mkOption {
# type = lib.types.submodule {
# options = {
# machine = lib.mkOption {
# type = lib.types.str;
# example = "jon";
# };
# generator = lib.mkOption {
# type = lib.types.str;
# example = "tor-ssh";
# };
# file = lib.mkOption {
# type = lib.types.str;
# example = "hostname";
# };
# };
# };
# };
# };
# };
# };
# }
# )
# );
# };
};
};
description = ''