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
64 changed files with 1224 additions and 704 deletions

View File

@@ -58,22 +58,20 @@
pkgs.buildPackages.xorg.lndir
pkgs.glibcLocales
pkgs.kbd.out
self.nixosConfigurations."test-flash-machine-${pkgs.stdenv.hostPlatform.system}".pkgs.perlPackages.ConfigIniFiles
self.nixosConfigurations."test-flash-machine-${pkgs.stdenv.hostPlatform.system}".pkgs.perlPackages.FileSlurp
self.nixosConfigurations."test-flash-machine-${pkgs.hostPlatform.system}".pkgs.perlPackages.ConfigIniFiles
self.nixosConfigurations."test-flash-machine-${pkgs.hostPlatform.system}".pkgs.perlPackages.FileSlurp
pkgs.bubblewrap
self.nixosConfigurations."test-flash-machine-${pkgs.stdenv.hostPlatform.system}".config.system.build.toplevel
self.nixosConfigurations."test-flash-machine-${pkgs.stdenv.hostPlatform.system}".config.system.build.diskoScript
self.nixosConfigurations."test-flash-machine-${pkgs.stdenv.hostPlatform.system}".config.system.build.diskoScript.drvPath
self.nixosConfigurations."test-flash-machine-${pkgs.hostPlatform.system}".config.system.build.toplevel
self.nixosConfigurations."test-flash-machine-${pkgs.hostPlatform.system}".config.system.build.diskoScript
self.nixosConfigurations."test-flash-machine-${pkgs.hostPlatform.system}".config.system.build.diskoScript.drvPath
]
++ builtins.map (i: i.outPath) (builtins.attrValues self.inputs);
closureInfo = pkgs.closureInfo { rootPaths = dependencies; };
in
{
# Skip flash test on aarch64-linux for now as it's too slow
checks =
lib.optionalAttrs (pkgs.stdenv.isLinux && pkgs.stdenv.hostPlatform.system != "aarch64-linux")
{
checks = lib.optionalAttrs (pkgs.stdenv.isLinux && pkgs.hostPlatform.system != "aarch64-linux") {
nixos-test-flash = self.clanLib.test.baseTest {
name = "flash";
nodes.target = {
@@ -102,7 +100,7 @@
machine.succeed("echo 'ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIIRWUusawhlIorx7VFeQJHmMkhl9X3QpnvOdhnV/bQNG root@target' > ./test_id_ed25519.pub")
# Some distros like to automount disks with spaces
machine.succeed('mkdir -p "/mnt/with spaces" && mkfs.ext4 /dev/vdc && mount /dev/vdc "/mnt/with spaces"')
machine.succeed("clan flash write --ssh-pubkey ./test_id_ed25519.pub --keymap de --language de_DE.UTF-8 --debug --flake ${self.checks.x86_64-linux.clan-core-for-checks} --yes --disk main /dev/vdc test-flash-machine-${pkgs.stdenv.hostPlatform.system}")
machine.succeed("clan flash write --ssh-pubkey ./test_id_ed25519.pub --keymap de --language de_DE.UTF-8 --debug --flake ${self.checks.x86_64-linux.clan-core-for-checks} --yes --disk main /dev/vdc test-flash-machine-${pkgs.hostPlatform.system}")
'';
} { inherit pkgs self; };
};

View File

@@ -160,9 +160,9 @@
closureInfo = pkgs.closureInfo {
rootPaths = [
privateInputs.clan-core-for-checks
self.nixosConfigurations."test-install-machine-${pkgs.stdenv.hostPlatform.system}".config.system.build.toplevel
self.nixosConfigurations."test-install-machine-${pkgs.stdenv.hostPlatform.system}".config.system.build.initialRamdisk
self.nixosConfigurations."test-install-machine-${pkgs.stdenv.hostPlatform.system}".config.system.build.diskoScript
self.nixosConfigurations."test-install-machine-${pkgs.hostPlatform.system}".config.system.build.toplevel
self.nixosConfigurations."test-install-machine-${pkgs.hostPlatform.system}".config.system.build.initialRamdisk
self.nixosConfigurations."test-install-machine-${pkgs.hostPlatform.system}".config.system.build.diskoScript
pkgs.stdenv.drvPath
pkgs.bash.drvPath
pkgs.buildPackages.xorg.lndir
@@ -215,7 +215,7 @@
# Prepare test flake and Nix store
flake_dir = prepare_test_flake(
temp_dir,
"${self.checks.${pkgs.stdenv.hostPlatform.system}.clan-core-for-checks}",
"${self.checks.${pkgs.hostPlatform.system}.clan-core-for-checks}",
"${closureInfo}"
)
@@ -296,7 +296,7 @@
# Prepare test flake and Nix store
flake_dir = prepare_test_flake(
temp_dir,
"${self.checks.${pkgs.stdenv.hostPlatform.system}.clan-core-for-checks}",
"${self.checks.${pkgs.hostPlatform.system}.clan-core-for-checks}",
"${closureInfo}"
)

View File

@@ -2,7 +2,7 @@
let
cli = self.packages.${pkgs.stdenv.hostPlatform.system}.clan-cli-full;
cli = self.packages.${pkgs.hostPlatform.system}.clan-cli-full;
ollama-model = pkgs.callPackage ./qwen3-4b-instruct.nix { };
in
@@ -53,7 +53,7 @@ in
pytest
pytest-xdist
(cli.pythonRuntime.pkgs.toPythonModule cli)
self.legacyPackages.${pkgs.stdenv.hostPlatform.system}.nixosTestLib
self.legacyPackages.${pkgs.hostPlatform.system}.nixosTestLib
]
))
];

View File

@@ -2,7 +2,7 @@
let
cli = self.packages.${pkgs.stdenv.hostPlatform.system}.clan-cli-full;
cli = self.packages.${pkgs.hostPlatform.system}.clan-cli-full;
in
{
name = "systemd-abstraction";

View File

@@ -115,9 +115,9 @@
let
closureInfo = pkgs.closureInfo {
rootPaths = [
self.packages.${pkgs.stdenv.hostPlatform.system}.clan-cli
self.checks.${pkgs.stdenv.hostPlatform.system}.clan-core-for-checks
self.clanInternals.machines.${pkgs.stdenv.hostPlatform.system}.test-update-machine.config.system.build.toplevel
self.packages.${pkgs.hostPlatform.system}.clan-cli
self.checks.${pkgs.hostPlatform.system}.clan-core-for-checks
self.clanInternals.machines.${pkgs.hostPlatform.system}.test-update-machine.config.system.build.toplevel
pkgs.stdenv.drvPath
pkgs.bash.drvPath
pkgs.buildPackages.xorg.lndir
@@ -132,7 +132,7 @@
imports = [ self.nixosModules.test-update-machine ];
};
extraPythonPackages = _p: [
self.legacyPackages.${pkgs.stdenv.hostPlatform.system}.nixosTestLib
self.legacyPackages.${pkgs.hostPlatform.system}.nixosTestLib
];
testScript = ''
@@ -154,7 +154,7 @@
# Prepare test flake and Nix store
flake_dir = prepare_test_flake(
temp_dir,
"${self.checks.${pkgs.stdenv.hostPlatform.system}.clan-core-for-checks}",
"${self.checks.${pkgs.hostPlatform.system}.clan-core-for-checks}",
"${closureInfo}"
)
(flake_dir / ".clan-flake").write_text("") # Ensure .clan-flake exists
@@ -226,7 +226,7 @@
"--to",
"ssh://root@192.168.1.1",
"--no-check-sigs",
f"${self.packages.${pkgs.stdenv.hostPlatform.system}.clan-cli}",
f"${self.packages.${pkgs.hostPlatform.system}.clan-cli}",
"--extra-experimental-features", "nix-command flakes",
],
check=True,
@@ -242,7 +242,7 @@
"-o", "UserKnownHostsFile=/dev/null",
"-o", "StrictHostKeyChecking=no",
f"root@192.168.1.1",
"${self.packages.${pkgs.stdenv.hostPlatform.system}.clan-cli}/bin/clan",
"${self.packages.${pkgs.hostPlatform.system}.clan-cli}/bin/clan",
"machines",
"update",
"--debug",
@@ -270,7 +270,7 @@
# Run clan update command
subprocess.run([
"${self.packages.${pkgs.stdenv.hostPlatform.system}.clan-cli-full}/bin/clan",
"${self.packages.${pkgs.hostPlatform.system}.clan-cli-full}/bin/clan",
"machines",
"update",
"--debug",
@@ -297,7 +297,7 @@
# Run clan update command with --build-host
subprocess.run([
"${self.packages.${pkgs.stdenv.hostPlatform.system}.clan-cli-full}/bin/clan",
"${self.packages.${pkgs.hostPlatform.system}.clan-cli-full}/bin/clan",
"machines",
"update",
"--debug",

View File

@@ -1,6 +1,3 @@
!!! Danger "Experimental"
This service is experimental and will change in the future.
This service sets up a certificate authority (CA) that can issue certificates to
other machines in your clan. For this the `ca` role is used.
It additionally provides a `default` role, that can be applied to all machines

View File

@@ -1,6 +1,3 @@
!!! Danger "Experimental"
This service is experimental and will change in the future.
This module enables hosting clan-internal services easily, which can be resolved
inside your VPN. This allows defining a custom top-level domain (e.g. `.clan`)
and exposing endpoints from a machine to others, which will be

View File

@@ -1,83 +1 @@
!!! Danger "Experimental"
This service is for demonstration purpose only and may change in the future.
The Hello-World Clan Service is a minimal example showing how to build and register your own service.
It serves as a reference implementation and is used in clan-core CI tests to ensure compatibility.
## What it demonstrates
- How to define a basic Clan-compatible service.
- How to structure your service for discovery and configuration.
- How Clan services interact with nixos.
## Testing
This service demonstrates two levels of testing to ensure quality and stability across releases:
1. **Unit & Integration Testing** — via [`nix-unit`](https://github.com/nix-community/nix-unit)
2. **End-to-End Testing** — via **NixOS VM tests**, which we extended to support **container virtualization** for better performance.
We highly advocate following the [Practical Testing Pyramid](https://martinfowler.com/articles/practical-test-pyramid.html):
* Write **unit tests** for core logic and invariants.
* Add **one or two end-to-end (E2E)** tests to confirm your service starts and behaves correctly in a real NixOS environment.
NixOS is **untyped** and frequently changes; tests are the safest way to ensure long-term stability of services.
```
/ \
/ \
/ E2E \
/-------\
/ \
/Integration\
/-------------\
/ \
/ Unit Tests \
-------------------
```
### nix-unit
We highly advocate the usage of
[nix-unit](https://github.com/nix-community/nix-unit)
Example in: tests/eval-tests.nix
If you use flake-parts you can use the [native integration](https://flake.parts/options/nix-unit.html)
If nix-unit succeeds you'r nixos evaluation should be mostly correct.
!!! Tip
- Ensure most used 'settings' and variants are tested.
- Think about some important edge-cases your system should handle.
### NixOS VM / Container Test
!!! Warning "Early Vars & clanTest"
The testing system around vars is experimental
`clanTest` is still experimental and enables container virtualization by default.
This is still early and might have some limitations.
Some minimal boilerplate is needed to use `clanTest`
```nix
nixosLib = import (inputs.nixpkgs + "/nixos/lib") { }
nixosLib.runTest (
{ ... }:
{
imports = [
self.modules.nixosTest.clanTest
# Example in tests/vm/default.nix
testModule
];
hostPkgs = pkgs;
# Uncomment if you don't want or cannot use containers
# test.useContainers = false;
}
)
```
This a test README just to appease the eval warnings if we don't have one

View File

@@ -8,7 +8,7 @@
{
_class = "clan.service";
manifest.name = "clan-core/hello-word";
manifest.description = "Minimal example clan service that greets the world";
manifest.description = "This is a test";
manifest.readme = builtins.readFile ./README.md;
# This service provides two roles: "morning" and "evening". Roles can be

View File

@@ -26,7 +26,7 @@ in
# The hello-world service being tested
../../clanServices/hello-world
# Required modules
../../nixosModules
../../nixosModules/clanCore
];
testName = "hello-world";
tests = ./tests/eval-tests.nix;

View File

@@ -4,7 +4,7 @@
...
}:
let
testClan = clanLib.clan {
testFlake = clanLib.clan {
self = { };
# Point to the folder of the module
# TODO: make this optional
@@ -33,20 +33,10 @@ let
};
in
{
/**
We highly advocate the usage of:
https://github.com/nix-community/nix-unit
If you use flake-parts you can use the native integration: https://flake.parts/options/nix-unit.html
*/
test_simple = {
# Allows inspection via the nix-repl
# Ignored by nix-unit; it only looks at 'expr' and 'expected'
inherit testClan;
config = testFlake.config;
# Assert that jon has the
# configured greeting in 'environment.etc.hello.text'
expr = testClan.config.nixosConfigurations.jon.config.environment.etc."hello".text;
expected = "Good evening World!";
expr = { };
expected = { };
};
}

View File

@@ -1,5 +1,8 @@
!!! Danger "Experimental"
This service is experimental and will change in the future.
🚧🚧🚧 Experimental 🚧🚧🚧
Use at your own risk.
We are still refining its interfaces, instability and breakages are expected.
---

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

@@ -1,6 +1,3 @@
!!! Danger "Experimental"
This service is experimental and will change in the future.
## Usage
```

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

@@ -1,5 +1,8 @@
!!! Danger "Experimental"
This service is experimental and will change in the future.
🚧🚧🚧 Experimental 🚧🚧🚧
Use at your own risk.
We are still refining its interfaces, instability and breakages are expected.
---

View File

@@ -1,5 +1,8 @@
!!! Danger "Experimental"
This service is experimental and will change in the future.
🚧🚧🚧 Experimental 🚧🚧🚧
Use at your own risk.
We are still refining its interfaces, instability and breakages are expected.
---

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,

12
devFlake/flake.lock generated
View File

@@ -105,11 +105,11 @@
},
"nixpkgs-dev": {
"locked": {
"lastModified": 1762328495,
"narHash": "sha256-IUZvw5kvLiExApP9+SK/styzEKSqfe0NPclu9/z85OQ=",
"lastModified": 1761748483,
"narHash": "sha256-v7fttCB5lJ22Ok7+N7ZbLhDeM89QIz9YWtQP4XN7xgA=",
"owner": "NixOS",
"repo": "nixpkgs",
"rev": "4c621660e393922cf68cdbfc40eb5a2d54d3989a",
"rev": "061c55856b29b8b9360e14231a0986c7f85f1130",
"type": "github"
},
"original": {
@@ -208,11 +208,11 @@
"nixpkgs": []
},
"locked": {
"lastModified": 1762366246,
"narHash": "sha256-3xc/f/ZNb5ma9Fc9knIzEwygXotA+0BZFQ5V5XovSOQ=",
"lastModified": 1761311587,
"narHash": "sha256-Msq86cR5SjozQGCnC6H8C+0cD4rnx91BPltZ9KK613Y=",
"owner": "numtide",
"repo": "treefmt-nix",
"rev": "a82c779ca992190109e431d7d680860e6723e048",
"rev": "2eddae033e4e74bf581c2d1dfa101f9033dbd2dc",
"type": "github"
},
"original": {

View File

@@ -51,7 +51,7 @@ Make sure you have the following:
**Note:** This creates a new directory in your current location
```shellSession
nix run "https://git.clan.lol/clan/clan-core/archive/main.tar.gz#clan-cli" --refresh -- flakes create
nix run https://git.clan.lol/clan/clan-core/archive/main.tar.gz#clan-cli --refresh -- flakes create
```
3. Enter a **name** in the prompt:

View File

@@ -150,61 +150,10 @@ Those are very similar to NixOS VM tests, as in they run virtualized nixos machi
As of now the container test driver is a downstream development in clan-core.
Basically everything stated under the NixOS VM tests sections applies here, except some limitations.
### Using Container Tests vs VM Tests
Limitations:
Container tests are **enabled by default** for all tests using the clan testing framework.
They offer significant performance advantages over VM tests:
- **Faster startup**
- **Lower resource usage**: No full kernel boot or hardware emulation overhead
To control whether a test uses containers or VMs, use the `clan.test.useContainers` option:
```nix
{
clan = {
directory = ./.;
test.useContainers = true; # Use containers (default)
# test.useContainers = false; # Use VMs instead
};
}
```
**When to use VM tests instead of container tests:**
- Testing kernel features, modules, or boot processes
- Testing hardware-specific features
- When you need full system isolation
### System Requirements for Container Tests
Container tests require the **`uid-range`** system feature** in the Nix sandbox.
This feature allows Nix to allocate a range of UIDs for containers to use, enabling `systemd-nspawn` containers to run properly inside the Nix build sandbox.
**Configuration:**
The `uid-range` feature requires the `auto-allocate-uids` setting to be enabled in your Nix configuration.
To verify or enable it, add to your `/etc/nix/nix.conf` or NixOS configuration:
```nix
settings.experimental-features = [
"auto-allocate-uids"
];
nix.settings.auto-allocate-uids = true;
nix.settings.system-features = [ "uid-range" ];
```
**Technical details:**
- Container tests set `requiredSystemFeatures = [ "uid-range" ];` in their derivation (see `lib/test/container-test-driver/driver-module.nix:98`)
- Without this feature, containers cannot properly manage user namespaces and will fail to start
### Limitations
- Cannot run in interactive mode, however while the container test runs, it logs a nsenter command that can be used to log into each of the containers.
- Early implementation and limited by features.
- Cannot run in interactive mode, however while the container test runs, it logs a nsenter command that can be used to log into each of the container.
- setuid binaries don't work
### Where to find examples for NixOS container tests

36
flake.lock generated
View File

@@ -31,11 +31,11 @@
]
},
"locked": {
"lastModified": 1762276996,
"narHash": "sha256-TtcPgPmp2f0FAnc+DMEw4ardEgv1SGNR3/WFGH0N19M=",
"lastModified": 1760701190,
"narHash": "sha256-y7UhnWlER8r776JsySqsbTUh2Txf7K30smfHlqdaIQw=",
"owner": "nix-community",
"repo": "disko",
"rev": "af087d076d3860760b3323f6b583f4d828c1ac17",
"rev": "3a9450b26e69dcb6f8de6e2b07b3fc1c288d85f5",
"type": "github"
},
"original": {
@@ -51,11 +51,11 @@
]
},
"locked": {
"lastModified": 1762040540,
"narHash": "sha256-z5PlZ47j50VNF3R+IMS9LmzI5fYRGY/Z5O5tol1c9I4=",
"lastModified": 1760948891,
"narHash": "sha256-TmWcdiUUaWk8J4lpjzu4gCGxWY6/Ok7mOK4fIFfBuU4=",
"owner": "hercules-ci",
"repo": "flake-parts",
"rev": "0010412d62a25d959151790968765a70c436598b",
"rev": "864599284fc7c0ba6357ed89ed5e2cd5040f0c04",
"type": "github"
},
"original": {
@@ -71,11 +71,11 @@
]
},
"locked": {
"lastModified": 1762304480,
"narHash": "sha256-ikVIPB/ea/BAODk6aksgkup9k2jQdrwr4+ZRXtBgmSs=",
"lastModified": 1761339987,
"narHash": "sha256-IUaawVwItZKi64IA6kF6wQCLCzpXbk2R46dHn8sHkig=",
"owner": "nix-darwin",
"repo": "nix-darwin",
"rev": "b8c7ac030211f18bd1f41eae0b815571853db7a2",
"rev": "7cd9aac79ee2924a85c211d21fafd394b06a38de",
"type": "github"
},
"original": {
@@ -99,11 +99,11 @@
},
"nixos-facter-modules": {
"locked": {
"lastModified": 1762264948,
"narHash": "sha256-iaRf6n0KPl9hndnIft3blm1YTAyxSREV1oX0MFZ6Tk4=",
"lastModified": 1761137276,
"narHash": "sha256-4lDjGnWRBLwqKQ4UWSUq6Mvxu9r8DSqCCydodW/Jsi8=",
"owner": "nix-community",
"repo": "nixos-facter-modules",
"rev": "fa695bff9ec37fd5bbd7ee3181dbeb5f97f53c96",
"rev": "70bcd64225d167c7af9b475c4df7b5abba5c7de8",
"type": "github"
},
"original": {
@@ -115,10 +115,10 @@
"nixpkgs": {
"locked": {
"lastModified": 315532800,
"narHash": "sha256-LDT9wuUZtjPfmviCcVWif5+7j4kBI2mWaZwjNNeg4eg=",
"rev": "a7fc11be66bdfb5cdde611ee5ce381c183da8386",
"narHash": "sha256-yDxtm0PESdgNetiJN5+MFxgubBcLDTiuSjjrJiyvsvM=",
"rev": "d7f52a7a640bc54c7bb414cca603835bf8dd4b10",
"type": "tarball",
"url": "https://releases.nixos.org/nixpkgs/nixpkgs-25.11pre887438.a7fc11be66bd/nixexprs.tar.xz"
"url": "https://releases.nixos.org/nixpkgs/nixpkgs-25.11pre871443.d7f52a7a640b/nixexprs.tar.xz"
},
"original": {
"type": "tarball",
@@ -181,11 +181,11 @@
]
},
"locked": {
"lastModified": 1762366246,
"narHash": "sha256-3xc/f/ZNb5ma9Fc9knIzEwygXotA+0BZFQ5V5XovSOQ=",
"lastModified": 1761311587,
"narHash": "sha256-Msq86cR5SjozQGCnC6H8C+0cD4rnx91BPltZ9KK613Y=",
"owner": "numtide",
"repo": "treefmt-nix",
"rev": "a82c779ca992190109e431d7d680860e6723e048",
"rev": "2eddae033e4e74bf581c2d1dfa101f9033dbd2dc",
"type": "github"
},
"original": {

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 = ''

View File

@@ -5,7 +5,7 @@
}:
{
# If we also need zfs, we can use the unstable version as we otherwise don't have a new enough kernel version
boot.zfs.package = pkgs.zfs_unstable or pkgs.zfsUnstable;
boot.zfs.package = pkgs.zfsUnstable;
# Enable bcachefs support
boot.supportedFilesystems.bcachefs = lib.mkDefault true;

View File

@@ -18,7 +18,7 @@ let
inputs.data-mesher.nixosModules.data-mesher
];
config = {
clan.core.clanPkgs = lib.mkDefault self.packages.${pkgs.stdenv.hostPlatform.system};
clan.core.clanPkgs = lib.mkDefault self.packages.${pkgs.hostPlatform.system};
};
};
in

View File

@@ -6,7 +6,7 @@
}:
let
isUnstable = config.boot.zfs.package == pkgs.zfs_unstable or pkgs.zfsUnstable;
isUnstable = config.boot.zfs.package == pkgs.zfsUnstable;
zfsCompatibleKernelPackages = lib.filterAttrs (
name: kernelPackages:
(builtins.match "linux_[0-9]+_[0-9]+" name) != null
@@ -30,5 +30,5 @@ let
in
{
# Note this might jump back and worth as kernel get added or removed.
boot.kernelPackages = lib.mkIf (lib.meta.availableOn pkgs.stdenv.hostPlatform pkgs.zfs) latestKernelPackage;
boot.kernelPackages = lib.mkIf (lib.meta.availableOn pkgs.hostPlatform pkgs.zfs) latestKernelPackage;
}

View File

@@ -4,7 +4,6 @@
padding: 8px;
flex-direction: column;
align-items: flex-start;
gap: 4px;
border-radius: 5px;
border: 1px solid var(--clr-border-def-2, #d8e8eb);

View File

@@ -1,13 +1,11 @@
import { onCleanup, onMount } from "solid-js";
import styles from "./ContextMenu.module.css";
import { Typography } from "../Typography/Typography";
import { Divider } from "../Divider/Divider";
import Icon from "../Icon/Icon";
export const Menu = (props: {
x: number;
y: number;
onSelect: (option: "move" | "delete") => void;
onSelect: (option: "move") => void;
close: () => void;
intersect: string[];
}) => {
@@ -56,31 +54,13 @@ export const Menu = (props: {
>
<Typography
hierarchy="label"
size="s"
weight="bold"
color={currentMachine() ? "primary" : "quaternary"}
>
Move
</Typography>
</li>
<Divider />
<li
class={styles.item}
aria-disabled={!currentMachine()}
onClick={() => {
console.log("Delete clicked", currentMachine());
props.onSelect("delete");
props.close();
}}
>
<Typography
hierarchy="label"
color={currentMachine() ? "primary" : "quaternary"}
>
<span class="flex items-center gap-2">
Delete
<Icon icon="Trash" font-size="inherit" />
</span>
</Typography>
</li>
</ul>
);
};

View File

@@ -71,7 +71,7 @@ const Machines = () => {
}
const result = ctx.machinesQuery.data;
return Object.keys(result).length > 0 ? result : [];
return Object.keys(result).length > 0 ? result : undefined;
};
return (
@@ -117,7 +117,7 @@ const Machines = () => {
}
>
<nav>
<For each={Object.entries(machines())}>
<For each={Object.entries(machines()!)}>
{([id, machine]) => (
<MachineRoute
clanURI={clanURI}

View File

@@ -206,8 +206,8 @@ const ClanSceneController = (props: RouteSectionProps) => {
<AddMachine
onCreated={async (id) => {
const promise = currentPromise();
await ctx.machinesQuery.refetch();
if (promise) {
await ctx.machinesQuery.refetch();
promise.resolve({ id });
setCurrentPromise(null);
}

View File

@@ -18,12 +18,12 @@ export class MachineManager {
private disposeRoot: () => void;
private machinePositionsSignal: Accessor<SceneData | undefined>;
private machinePositionsSignal: Accessor<SceneData>;
constructor(
scene: THREE.Scene,
registry: ObjectRegistry,
machinePositionsSignal: Accessor<SceneData | undefined>,
machinePositionsSignal: Accessor<SceneData>,
machinesQueryResult: MachinesQueryResult,
selectedIds: Accessor<Set<string>>,
setMachinePos: (id: string, position: [number, number] | null) => void,
@@ -39,9 +39,8 @@ export class MachineManager {
if (!machinesQueryResult.data) return;
const actualIds = Object.keys(machinesQueryResult.data);
const machinePositions = machinePositionsSignal() || {};
const machinePositions = machinePositionsSignal();
// Remove stale
for (const id of Object.keys(machinePositions)) {
if (!actualIds.includes(id)) {
console.log("Removing stale machine", id);
@@ -62,7 +61,8 @@ export class MachineManager {
// Effect 2: sync store → scene
//
createEffect(() => {
const positions = machinePositionsSignal() || {};
const positions = machinePositionsSignal();
if (!positions) return;
// Remove machines from scene
for (const [id, repr] of this.machines) {
@@ -103,7 +103,7 @@ export class MachineManager {
nextGridPos(): [number, number] {
const occupiedPositions = new Set(
Object.values(this.machinePositionsSignal() || {}).map((data) =>
Object.values(this.machinePositionsSignal()).map((data) =>
keyFromPos(data.position),
),
);

View File

@@ -32,9 +32,6 @@ import {
} from "./highlightStore";
import { createMachineMesh } from "./MachineRepr";
import { useClanContext } from "@/src/routes/Clan/Clan";
import client from "@api/clan/client";
import { navigateToClan } from "../hooks/clan";
import { useNavigate } from "@solidjs/router";
function intersectMachines(
event: MouseEvent,
@@ -103,7 +100,7 @@ export function CubeScene(props: {
onCreate: () => Promise<{ id: string }>;
selectedIds: Accessor<Set<string>>;
onSelect: (v: Set<string>) => void;
sceneStore: Accessor<SceneData | undefined>;
sceneStore: Accessor<SceneData>;
setMachinePos: (machineId: string, pos: [number, number] | null) => void;
isLoading: boolean;
clanURI: string;
@@ -134,6 +131,9 @@ export function CubeScene(props: {
let machineManager: MachineManager;
const [positionMode, setPositionMode] = createSignal<"grid" | "circle">(
"grid",
);
// Managed by controls
const [isDragging, setIsDragging] = createSignal(false);
@@ -142,6 +142,10 @@ export function CubeScene(props: {
// TODO: Unify this with actionRepr position
const [cursorPosition, setCursorPosition] = createSignal<[number, number]>();
const [cameraInfo, setCameraInfo] = createSignal({
position: { x: 0, y: 0, z: 0 },
spherical: { radius: 0, theta: 0, phi: 0 },
});
// Context menu state
const [contextOpen, setContextOpen] = createSignal(false);
const [menuPos, setMenuPos] = createSignal<{ x: number; y: number }>();
@@ -153,6 +157,7 @@ export function CubeScene(props: {
const BASE_SIZE = 0.9; // Height of the cube above the ground
const CUBE_SIZE = BASE_SIZE / 1.5; //
const BASE_HEIGHT = 0.05; // Height of the cube above the ground
const CUBE_Y = 0 + CUBE_SIZE / 2 + BASE_HEIGHT / 2; // Y position of the cube above the ground
const CUBE_SEGMENT_HEIGHT = CUBE_SIZE / 1;
const FLOOR_COLOR = 0xcdd8d9;
@@ -196,8 +201,6 @@ export function CubeScene(props: {
const grid = new THREE.GridHelper(1000, 1000 / 1, 0xe1edef, 0xe1edef);
const navigate = useNavigate();
onMount(() => {
// Scene setup
scene = new THREE.Scene();
@@ -308,12 +311,21 @@ export function CubeScene(props: {
bgCamera,
);
// controls.addEventListener("start", (e) => {
// setIsDragging(true);
// });
// controls.addEventListener("end", (e) => {
// setIsDragging(false);
// });
// Lighting
const ambientLight = new THREE.AmbientLight(0xd9f2f7, 0.72);
scene.add(ambientLight);
const directionalLight = new THREE.DirectionalLight(0xffffff, 3.5);
// scene.add(new THREE.DirectionalLightHelper(directionalLight));
// scene.add(new THREE.CameraHelper(camera));
const lightPos = new THREE.Spherical(
15,
initialSphericalCameraPosition.phi - Math.PI / 8,
@@ -400,6 +412,30 @@ export function CubeScene(props: {
actionMachine = createActionMachine();
scene.add(actionMachine);
// const spherical = new THREE.Spherical();
// spherical.setFromVector3(camera.position);
// Function to update camera info
const updateCameraInfo = () => {
const spherical = new THREE.Spherical();
spherical.setFromVector3(camera.position);
setCameraInfo({
position: {
x: Math.round(camera.position.x * 100) / 100,
y: Math.round(camera.position.y * 100) / 100,
z: Math.round(camera.position.z * 100) / 100,
},
spherical: {
radius: Math.round(spherical.radius * 100) / 100,
theta: Math.round(spherical.theta * 100) / 100,
phi: Math.round(spherical.phi * 100) / 100,
},
});
};
// Initial camera info update
updateCameraInfo();
createEffect(
on(ctx.worldMode, (mode) => {
if (mode === "create") {
@@ -625,8 +661,7 @@ export function CubeScene(props: {
});
const snapToGrid = (point: THREE.Vector3) => {
const store = props.sceneStore() || {};
if (!props.sceneStore) return;
// Snap to grid
const snapped = new THREE.Vector3(
Math.round(point.x / GRID_SIZE) * GRID_SIZE,
@@ -635,7 +670,7 @@ export function CubeScene(props: {
);
// Skip snapping if there's already a cube at this position
const positions = Object.entries(store);
const positions = Object.entries(props.sceneStore());
const intersects = positions.some(
([_id, p]) => p.position[0] === snapped.x && p.position[1] === snapped.z,
);
@@ -659,6 +694,7 @@ export function CubeScene(props: {
};
const onAddClick = (event: MouseEvent) => {
setPositionMode("grid");
ctx.setWorldMode("create");
renderLoop.requestRender();
};
@@ -670,6 +706,9 @@ export function CubeScene(props: {
if (!actionRepr) return;
actionRepr.visible = true;
// (actionRepr.material as THREE.MeshPhongMaterial).emissive.set(
// worldMode() === "create" ? CREATE_BASE_EMISSIVE : MOVE_BASE_EMISSIVE,
// );
// Calculate mouse position in normalized device coordinates
// (-1 to +1) for both components
@@ -697,31 +736,15 @@ export function CubeScene(props: {
}
}
};
const handleMenuSelect = async (mode: "move" | "delete") => {
const firstId = menuIntersection()[0];
if (!firstId) {
return;
}
const machine = machineManager.machines.get(firstId);
if (mode === "delete") {
console.log("deleting machine", firstId);
await client.post("delete_machine", {
body: {
machine: { flake: { identifier: props.clanURI }, name: firstId },
},
});
navigateToClan(navigate, props.clanURI);
ctx.machinesQuery.refetch();
ctx.serviceInstancesQuery.refetch();
return;
}
// Else "move" mode
const handleMenuSelect = (mode: "move") => {
ctx.setWorldMode(mode);
setHighlightGroups({ move: new Set(menuIntersection()) });
// Find the position of the first selected machine
// Set the actionMachine position to that
const firstId = menuIntersection()[0];
if (firstId) {
const machine = machineManager.machines.get(firstId);
if (machine && actionMachine) {
actionMachine.position.set(
machine.group.position.x,
@@ -730,6 +753,7 @@ export function CubeScene(props: {
);
setCursorPosition([machine.group.position.x, machine.group.position.z]);
}
}
};
createEffect(

View File

@@ -766,28 +766,6 @@ def test_prompt(
assert sops_store.get(my_generator, "prompt_persist").decode() == "prompt_persist"
@pytest.mark.with_core
def test_non_existing_dependency_raises_error(
monkeypatch: pytest.MonkeyPatch,
flake_with_sops: ClanFlake,
) -> None:
"""Ensure that a generator with a non-existing dependency raises a clear error."""
flake = flake_with_sops
config = flake.machines["my_machine"] = create_test_machine_config()
my_generator = config["clan"]["core"]["vars"]["generators"]["my_generator"]
my_generator["files"]["my_value"]["secret"] = False
my_generator["script"] = 'echo "$RANDOM" > "$out"/my_value'
my_generator["dependencies"] = ["non_existing_generator"]
flake.refresh()
monkeypatch.chdir(flake.path)
with pytest.raises(
ClanError,
match="Generator 'my_generator' on machine 'my_machine' depends on generator 'non_existing_generator', but 'non_existing_generator' does not exist",
):
cli.run(["vars", "generate", "--flake", str(flake.path), "my_machine"])
@pytest.mark.with_core
def test_shared_vars_must_never_depend_on_machine_specific_vars(
monkeypatch: pytest.MonkeyPatch,

View File

@@ -66,41 +66,6 @@ class Generator:
_public_store: "StoreBase | None" = None
_secret_store: "StoreBase | None" = None
@staticmethod
def validate_dependencies(
generator_name: str,
machine_name: str,
dependencies: list[str],
generators_data: dict[str, dict],
) -> list[GeneratorKey]:
"""Validate and build dependency keys for a generator.
Args:
generator_name: Name of the generator that has dependencies
machine_name: Name of the machine the generator belongs to
dependencies: List of dependency generator names
generators_data: Dictionary of all available generators for this machine
Returns:
List of GeneratorKey objects
Raises:
ClanError: If a dependency does not exist
"""
deps_list = []
for dep in dependencies:
if dep not in generators_data:
msg = f"Generator '{generator_name}' on machine '{machine_name}' depends on generator '{dep}', but '{dep}' does not exist. Please check your configuration."
raise ClanError(msg)
deps_list.append(
GeneratorKey(
machine=None if generators_data[dep]["share"] else machine_name,
name=dep,
)
)
return deps_list
@property
def key(self) -> GeneratorKey:
if self.share:
@@ -275,12 +240,15 @@ class Generator:
name=gen_name,
share=share,
files=files,
dependencies=cls.validate_dependencies(
gen_name,
machine_name,
gen_data["dependencies"],
generators_data,
),
dependencies=[
GeneratorKey(
machine=None
if generators_data[dep]["share"]
else machine_name,
name=dep,
)
for dep in gen_data["dependencies"]
],
migrate_fact=gen_data.get("migrateFact"),
validation_hash=gen_data.get("validationHash"),
prompts=prompts,

View File

@@ -245,7 +245,7 @@ class SecretStore(StoreBase):
output_dir / "activation" / generator.name / file.name
)
out_file.parent.mkdir(parents=True, exist_ok=True)
out_file.write_bytes(file.value)
out_file.write_bytes(self.get(generator, file.name))
if "partitioning" in phases:
for generator in vars_generators:
for file in generator.files:
@@ -254,7 +254,7 @@ class SecretStore(StoreBase):
output_dir / "partitioning" / generator.name / file.name
)
out_file.parent.mkdir(parents=True, exist_ok=True)
out_file.write_bytes(file.value)
out_file.write_bytes(self.get(generator, file.name))
hash_data = self.generate_hash(machine)
if hash_data:

View File

@@ -246,7 +246,7 @@ class SecretStore(StoreBase):
)
# chmod after in case it doesn't have u+w
target_path.touch(mode=0o600)
target_path.write_bytes(file.value)
target_path.write_bytes(self.get(generator, file.name))
target_path.chmod(file.mode)
if "partitioning" in phases:
@@ -260,7 +260,7 @@ class SecretStore(StoreBase):
)
# chmod after in case it doesn't have u+w
target_path.touch(mode=0o600)
target_path.write_bytes(file.value)
target_path.write_bytes(self.get(generator, file.name))
target_path.chmod(file.mode)
@override

View File

@@ -211,7 +211,7 @@ class ClanSelectError(ClanError):
def __str__(self) -> str:
if self.description:
return f"{self.msg} Reason: {self.description}. Use flag '--debug' to see full nix trace."
return f"{self.msg} Reason: {self.description}"
return self.msg
def __repr__(self) -> str:

View File

@@ -59,7 +59,9 @@ def upload_sources(machine: Machine, ssh: Host, upload_inputs: bool) -> str:
if not has_path_inputs and not upload_inputs:
# Just copy the flake to the remote machine, we can substitute other inputs there.
path = flake_data["path"]
remote_url = f"ssh-ng://{remote_url_base}"
if machine._class_ == "darwin":
remote_program_params = "?remote-program=bash -lc 'exec nix-daemon --stdio'"
remote_url = f"ssh-ng://{remote_url_base}{remote_program_params}"
cmd = nix_command(
[
"copy",

View File

@@ -17,7 +17,7 @@
runCommand,
setuptools,
webkitgtk_6_0,
wrapGAppsHook3,
wrapGAppsHook,
python,
lib,
stdenv,
@@ -87,7 +87,7 @@ buildPythonApplication rec {
nativeBuildInputs = [
setuptools
copyDesktopItems
wrapGAppsHook3
wrapGAppsHook
gobject-introspection
];