Compare commits

..

4 Commits

Author SHA1 Message Date
pinpox
bdaff0a8a4 Add favicon 2025-10-28 10:09:14 +01:00
pinpox
fabbfcaab6 fix template 2025-10-28 01:01:07 +01:00
pinpox
98cfaac849 Add prometheus console 2025-10-26 21:54:14 +01:00
pinpox
decb91a529 clanServices/monitoring: add prometheus role 2025-10-26 12:09:05 +01:00
77 changed files with 1449 additions and 1195 deletions

View File

@@ -58,53 +58,51 @@
pkgs.buildPackages.xorg.lndir pkgs.buildPackages.xorg.lndir
pkgs.glibcLocales pkgs.glibcLocales
pkgs.kbd.out pkgs.kbd.out
self.nixosConfigurations."test-flash-machine-${pkgs.stdenv.hostPlatform.system}".pkgs.perlPackages.ConfigIniFiles self.nixosConfigurations."test-flash-machine-${pkgs.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.FileSlurp
pkgs.bubblewrap pkgs.bubblewrap
self.nixosConfigurations."test-flash-machine-${pkgs.stdenv.hostPlatform.system}".config.system.build.toplevel self.nixosConfigurations."test-flash-machine-${pkgs.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.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.diskoScript.drvPath
] ]
++ builtins.map (i: i.outPath) (builtins.attrValues self.inputs); ++ builtins.map (i: i.outPath) (builtins.attrValues self.inputs);
closureInfo = pkgs.closureInfo { rootPaths = dependencies; }; closureInfo = pkgs.closureInfo { rootPaths = dependencies; };
in in
{ {
# Skip flash test on aarch64-linux for now as it's too slow # Skip flash test on aarch64-linux for now as it's too slow
checks = checks = lib.optionalAttrs (pkgs.stdenv.isLinux && pkgs.hostPlatform.system != "aarch64-linux") {
lib.optionalAttrs (pkgs.stdenv.isLinux && pkgs.stdenv.hostPlatform.system != "aarch64-linux") nixos-test-flash = self.clanLib.test.baseTest {
{ name = "flash";
nixos-test-flash = self.clanLib.test.baseTest { nodes.target = {
name = "flash"; virtualisation.emptyDiskImages = [ 4096 ];
nodes.target = { virtualisation.memorySize = 4096;
virtualisation.emptyDiskImages = [ 4096 ];
virtualisation.memorySize = 4096;
virtualisation.useNixStoreImage = true; virtualisation.useNixStoreImage = true;
virtualisation.writableStore = true; virtualisation.writableStore = true;
environment.systemPackages = [ self.packages.${pkgs.system}.clan-cli ]; environment.systemPackages = [ self.packages.${pkgs.system}.clan-cli ];
environment.etc."install-closure".source = "${closureInfo}/store-paths"; environment.etc."install-closure".source = "${closureInfo}/store-paths";
nix.settings = { nix.settings = {
substituters = lib.mkForce [ ]; substituters = lib.mkForce [ ];
hashed-mirrors = null; hashed-mirrors = null;
connect-timeout = lib.mkForce 3; connect-timeout = lib.mkForce 3;
flake-registry = ""; flake-registry = "";
experimental-features = [ experimental-features = [
"nix-command" "nix-command"
"flakes" "flakes"
]; ];
}; };
};
testScript = ''
start_all()
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}")
'';
} { inherit pkgs self; };
}; };
testScript = ''
start_all()
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.hostPlatform.system}")
'';
} { inherit pkgs self; };
};
}; };
} }

View File

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

View File

@@ -2,7 +2,7 @@
let 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 { }; ollama-model = pkgs.callPackage ./qwen3-4b-instruct.nix { };
in in
@@ -53,7 +53,7 @@ in
pytest pytest
pytest-xdist pytest-xdist
(cli.pythonRuntime.pkgs.toPythonModule cli) (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 let
cli = self.packages.${pkgs.stdenv.hostPlatform.system}.clan-cli-full; cli = self.packages.${pkgs.hostPlatform.system}.clan-cli-full;
in in
{ {
name = "systemd-abstraction"; name = "systemd-abstraction";

View File

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

View File

@@ -1,83 +1 @@
!!! Danger "Experimental" This a test README just to appease the eval warnings if we don't have one
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;
}
)
```

View File

@@ -8,7 +8,7 @@
{ {
_class = "clan.service"; _class = "clan.service";
manifest.name = "clan-core/hello-word"; 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; manifest.readme = builtins.readFile ./README.md;
# This service provides two roles: "morning" and "evening". Roles can be # This service provides two roles: "morning" and "evening". Roles can be

View File

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

View File

@@ -4,7 +4,7 @@
... ...
}: }:
let let
testClan = clanLib.clan { testFlake = clanLib.clan {
self = { }; self = { };
# Point to the folder of the module # Point to the folder of the module
# TODO: make this optional # TODO: make this optional
@@ -33,20 +33,10 @@ let
}; };
in 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 = { test_simple = {
# Allows inspection via the nix-repl config = testFlake.config;
# Ignored by nix-unit; it only looks at 'expr' and 'expected'
inherit testClan;
# Assert that jon has the expr = { };
# configured greeting in 'environment.etc.hello.text' expected = { };
expr = testClan.config.nixosConfigurations.jon.config.environment.etc."hello".text;
expected = "Good evening World!";
}; };
} }

View File

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

View File

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

View File

@@ -0,0 +1,24 @@
{ lib }:
lib.mapAttrsToList
(name: opts: {
alert = name;
expr = opts.condition;
for = opts.time or "2m";
labels = { };
annotations.description = opts.description;
})
{
# TODO Remove this alert, just for testing
"Filesystem > = 10%" = {
condition = ''disk_used_percent{fstype!~"tmpfs|vfat|devtmpfs|efivarfs"} > 10'';
time = "1m";
description = "{{$labels.instance}} device {{$labels.device}} on {{$labels.path}} got less than 90% space left on its filesystem.";
};
filesystem_full_80percent = {
condition = ''disk_used_percent{fstype!~"tmpfs|vfat|devtmpfs|efivarfs"} > 80'';
time = "1m";
description = "{{$labels.instance}} device {{$labels.device}} on {{$labels.path}} got less than 20% space left on its filesystem.";
};
}

View File

@@ -24,5 +24,48 @@
}; };
}; };
imports = [ ./telegraf.nix ]; roles.prometheus = {
description = "Prometheus monitoring daemon. Will collect metrics from all hosts with the telegraf role";
interface =
{ lib, ... }:
{
options.webExternalUrl = lib.mkOption {
type = lib.types.nullOr lib.types.str;
default = null;
example = "https://prometheus.tld";
description = "The URL under which Prometheus is externally reachable";
};
};
};
imports = [
./telegraf.nix
./prometheus.nix
];
perMachine.nixosModule =
{ pkgs, ... }:
{
clan.core.vars.generators."prometheus" = {
share = true;
files.password.restartUnits = [
"telegraf.service"
"prometheus.service"
];
files.password-env.restartUnits = [ "telegraf.service" ];
runtimeInputs = [
pkgs.coreutils
pkgs.xkcdpass
];
script = ''
xkcdpass --numwords 6 --delimiter - --count 1 | tr -d "\n" > $out/password
printf 'BASIC_AUTH_PWD=%s\n' "$(cat $out/password)" > $out/password-env
'';
};
};
} }

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

View File

@@ -0,0 +1,11 @@
<svg xmlns="http://www.w3.org/2000/svg" version="1.1" xmlns:xlink="http://www.w3.org/1999/xlink" xmlns:svgjs="http://svgjs.dev/svgjs" width="1000" height="1000"><g clip-path="url(#SvgjsClipPath1007)"><rect width="1000" height="1000" fill="#ffffff"></rect><g transform="matrix(5.132341080724394,0,0,5.132341080724394,217.38764012391061,149.97935090550055)"><svg xmlns="http://www.w3.org/2000/svg" version="1.1" xmlns:xlink="http://www.w3.org/1999/xlink" xmlns:svgjs="http://svgjs.dev/svgjs" width="110.13" height="136.39"><svg id="Layer_1" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 110.13 136.39">
<defs>
<style>
.cls-1 {
fill: #231f20;
}
</style>
<clipPath id="SvgjsClipPath1007"><rect width="1000" height="1000" x="0" y="0" rx="350" ry="350"></rect></clipPath></defs>
<path class="cls-1" d="M88.27,30.81h16.69c1.77,0,3.21-1.44,3.21-3.21v-12.84c0-1.77-1.44-3.21-3.21-3.21h-5.26c-1.7,0-3.08-1.38-3.08-3.08V3.21c0-1.77-1.44-3.21-3.21-3.21h-47.49c-1.77,0-3.21,1.44-3.21,3.21v5.26c0,1.7-1.38,3.08-3.08,3.08h-5.26c-1.77,0-3.21,1.44-3.21,3.21v5.26c0,1.7-1.38,3.08-3.08,3.08h-5.26c-1.77,0-3.21,1.44-3.21,3.21,0,0-.77-1.95-.77,34.47,0,32.56.77,29.7.77,29.7,0,1.77,1.44,3.21,3.21,3.21h5.26c1.7,0,3.08,1.38,3.08,3.08v5.39c0,1.7,1.38,3.08,3.08,3.08h5.39c1.7,0,3.08,1.38,3.08,3.08v5.26c0,1.77,1.44,3.21,3.21,3.21h46.21c1.77,0,3.21-1.44,3.21-3.21v-5.26c0-1.7,1.38-3.08,3.08-3.08h8.5c1.77,0,3.21-1.44,3.21-3.21v-15.3c0-1.77-1.44-3.21-3.21-3.21h-19.93c-1.77,0-3.21,1.44-3.21,3.21v7.73c0,1.7-1.38,3.08-3.08,3.08h-23.36c-1.7,0-3.08-1.38-3.08-3.08v-7.83c0-1.77-1.44-3.21-3.21-3.21h-7.83c-1.7,0-2.66.25-3.08-3.08-.13-1.07-.2-2.38-.3-4.13-.25-4.41-.47-2.64-.47-15.89,0-18.52.48-23.85.77-26.42s1.38-3.08,3.08-3.08h7.83c1.77,0,3.21-1.44,3.21-3.21v-5.26c0-1.7,1.38-3.08,3.08-3.08h24.65c1.7,0,3.08,1.38,3.08,3.08v5.26c0,1.77,1.44,3.21,3.21,3.21Z"></path>
<path class="cls-1" d="M28.49,113.03h-3.79c-.74,0-1.33-.6-1.33-1.33v-3.79c0-1.47-1.19-2.67-2.67-2.67h-10.24c-1.47,0-2.67,1.19-2.67,2.67v3.79c0,.74-.6,1.33-1.33,1.33h-3.79c-1.47,0-2.67,1.19-2.67,2.67v10.24c0,1.47,1.19,2.67,2.67,2.67h3.79c.74,0,1.33.6,1.33,1.33v3.79c0,1.47,1.19,2.67,2.67,2.67h10.24c1.47,0,2.67-1.19,2.67-2.67v-3.79c0-.74.6-1.33,1.33-1.33h3.79c1.47,0,2.67-1.19,2.67-2.67v-10.24c0-1.47-1.19-2.67-2.67-2.67Z"></path>
</svg></svg></g></g></svg>

After

Width:  |  Height:  |  Size: 2.3 KiB

View File

@@ -0,0 +1,249 @@
<!DOCTYPE html> <html>
<head>
<meta charset="utf-8">
<title>Clan status</title>
<link rel="icon" type="image/png" href="favicon-48x48.png" sizes="48x48" />
<link rel="icon" type="image/svg+xml" href="favicon.svg" />
<link rel="shortcut icon" href="favicon.ico" />
<link rel="apple-touch-icon" sizes="180x180" href="apple-touch-icon.png" />
<meta name="viewport" content="width=device-width, initial-scale=1">
<style>
:root {
--dark: rgb(22, 35, 36);
--light: rgb(229, 231, 235);
}
body {
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
margin: 0;
padding: 20px;
background: var(--dark);
}
.container {
max-width: 1400px;
margin: 0 auto;
background: var(--light);
padding: 30px;
border-radius: 8px;
box-shadow: 0 1px 3px rgba(0,0,0,0.1);
}
h1 {
margin-top: 0;
color: #333;
border-bottom: 2px solid var(--dark);
padding-bottom: 10px;
}
h2 {
color: #555;
margin-top: 30px;
}
table {
width: 100%;
border-collapse: collapse;
margin: 20px 0;
}
th {
background: var(--dark);
color: var(--light);
padding: 12px;
text-align: left;
font-weight: 600;
}
td {
padding: 10px 12px;
border-bottom: 1px solid #ddd;
}
tr:hover {
background: var(--light);
}
.status-up {
color: #28a745;
font-weight: bold;
}
.status-down {
color: #dc3545;
font-weight: bold;
}
.alert-success {
background: #d4edda;
color: #155724;
padding: 12px;
border-radius: 4px;
border: 1px solid #c3e6cb;
}
.grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(400px, 1fr));
gap: 20px;
margin: 20px 0;
}
.card {
border: 1px solid #ddd;
border-radius: 4px;
padding: 15px;
}
.metric-value {
font-size: 1.2em;
font-weight: bold;
color: var(--dark);
}
</style>
</head>
<body>
<div class="container">
<h1>Clan Status</h1>
<h2>Instances</h2>
<table>
<thead>
<tr>
<th>Host</th>
<th>Status</th>
<th>CPU Usage</th>
<th>Memory Usage</th>
<th>Disk Usage</th>
</tr>
</thead>
<tbody>
{{ range query "up" | sortByLabel "instance" }}
{{ $hostname := reReplaceAll "\\..*" "" .Labels.instance }}
<tr>
<td>{{ $hostname }}</td>
<td>
{{ if eq .Value 1.0 }}
<span class="status-up">UP</span>
{{ else }}
<span class="status-down">DOWN</span>
{{ end }}
</td>
<td>
{{ $cpuQuery := query (printf "100 - cpu_usage_idle{cpu=\"cpu-total\",host=\"%s\"}" $hostname) }}
{{ if $cpuQuery }}
{{ with $cpuQuery | first }}
<span class="metric-value">{{ . | value | printf "%.1f" }}%</span>
{{ end }}
{{ else }}
N/A
{{ end }}
</td>
<td>
{{ $memQuery := query (printf "(1 - (mem_available{host=\"%s\"} / mem_total{host=\"%s\"})) * 100" $hostname $hostname) }}
{{ if $memQuery }}
{{ with $memQuery | first }}
<span class="metric-value">{{ . | value | printf "%.1f" }}%</span>
{{ end }}
{{ else }}
N/A
{{ end }}
</td>
<td>
{{ $diskQuery := query (printf "(1 - (disk_free{host=\"%s\",path=\"/\"} / disk_total{host=\"%s\",path=\"/\"})) * 100" $hostname $hostname) }}
{{ if $diskQuery }}
{{ with $diskQuery | first }}
<span class="metric-value">{{ . | value | printf "%.1f" }}%</span>
{{ end }}
{{ else }}
N/A
{{ end }}
</td>
</tr>
{{ end }}
</tbody>
</table>
<h2>Services</h2>
<table>
<thead>
<tr>
<th>Service</th>
<th>Host</th>
<th>State</th>
</tr>
</thead>
<tbody>
<!-- <tr> -->
<!-- <td>Vaultwarden</td> -->
<!-- <td>kiwi</td> -->
<!-- <td> -->
<!-- <span class="status-up">UP</span> -->
<!-- </td> -->
<!-- </tr> -->
</tbody>
</table>
<!-- <h2>NixOS Systems</h2> -->
<!-- <table> -->
<!-- <thead> -->
<!-- <tr> -->
<!-- <th>Host</th> -->
<!-- <th>Booted System</th> -->
<!-- <th>Current System</th> -->
<!-- <th>Booted Kernel</th> -->
<!-- <th>Current Kernel</th> -->
<!-- </tr> -->
<!-- </thead> -->
<!-- <tbody> -->
<!-- {{ range query "nixos_systems_present" | sortByLabel "host" }} -->
<!-- <tr> -->
<!-- <td>{{ .Labels.host }}</td> -->
<!-- <td style="font-family: monospace; font-size: 0.85em;">{{ .Labels.booted_system }}</td> -->
<!-- <td style="font-family: monospace; font-size: 0.85em;">{{ .Labels.current_system }}</td> -->
<!-- <td>{{ .Labels.booted_kernel }}</td> -->
<!-- <td>{{ .Labels.current_kernel }}</td> -->
<!-- </tr> -->
<!-- {{ end }} -->
<!-- </tbody> -->
<!-- </table> -->
<h2>Failed Systemd Units</h2>
{{ $failedUnits := query "systemd_units_sub_code{sub=\"failed\"}" }}
{{ if $failedUnits }}
<table>
<thead>
<tr>
<th>Host</th>
<th>Unit</th>
</tr>
</thead>
<tbody>
{{ range $failedUnits | sortByLabel "host" }}
<tr>
<td>{{ .Labels.host }}</td>
<td style="color: #dc3545;">{{ .Labels.name }}</td>
</tr>
{{ end }}
</tbody>
</table>
{{ else }}
<div class="alert-success">No failed systemd units</div>
{{ end }}
<h2>Active Alerts</h2>
{{ with query "ALERTS{alertstate=\"firing\"}" }}
<table>
<thead>
<tr>
<th>Host</th>
<th>Alert</th>
<th>Value</th>
</tr>
</thead>
<tbody>
{{ range . }}
<tr>
<td>{{ or .Labels.host .Labels.instance }}</td>
<td>{{ .Labels.alertname }}</td>
<td>{{ .Value }}</td>
</tr>
{{ end }}
</tbody>
</table>
{{ else }}
<div class="alert-success">No active alerts</div>
{{ end }}
</div>
</body>
</html>

View File

@@ -0,0 +1,80 @@
:root {
--dark: rgb(22, 35, 36);
--light: rgb(229, 231, 235);
}
body {
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
margin: 0;
padding: 20px;
background: var(--dark);
}
.container {
max-width: 1400px;
margin: 0 auto;
background: var(--light);
padding: 30px;
border-radius: 8px;
box-shadow: 0 1px 3px rgba(0,0,0,0.1);
}
h1 {
margin-top: 0;
color: #333;
border-bottom: 2px solid var(--dark);
padding-bottom: 10px;
}
h2 {
color: #555;
margin-top: 30px;
}
table {
width: 100%;
border-collapse: collapse;
margin: 20px 0;
}
th {
background: var(--dark);
color: var(--light);
padding: 12px;
text-align: left;
font-weight: 600;
}
td {
padding: 10px 12px;
border-bottom: 1px solid #ddd;
}
tr:hover {
background: var(--light);
}
.status-up {
color: #28a745;
font-weight: bold;
}
.status-down {
color: #dc3545;
font-weight: bold;
}
.alert-success {
background: #d4edda;
color: #155724;
padding: 12px;
border-radius: 4px;
border: 1px solid #c3e6cb;
}
.grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(400px, 1fr));
gap: 20px;
margin: 20px 0;
}
.card {
border: 1px solid #ddd;
border-radius: 4px;
padding: 15px;
}
.metric-value {
font-size: 1.2em;
font-weight: bold;
color: var(--dark);
}

View File

@@ -0,0 +1,83 @@
{
roles.prometheus.perInstance =
{
settings,
instanceName,
roles,
...
}:
{
nixosModule =
{
config,
lib,
pkgs,
...
}:
{
systemd.services.prometheus = {
serviceConfig = {
LoadCredential = "password:${config.clan.core.vars.generators.prometheus.files.password.path}";
BindReadOnlyPaths = "%d/password:/etc/prometheus/password";
};
};
services.prometheus = {
enable = true;
# TODO what do we set here? do we even need something?
# TODO this should be a export
# "https://prometheus.${config.clan.core.settings.tld}";
webExternalUrl = settings.webExternalUrl;
# Configure console templates and libraries paths
extraFlags = [
"--storage.tsdb.retention.time=30d"
"--web.console.templates=${./prometheus-consoles}"
"--web.console.libraries=${./prometheus-consoles}"
];
ruleFiles = [
(pkgs.writeText "prometheus-rules.yml" (
builtins.toJSON {
groups = [
{
name = "alerting-rules";
rules = import ./alert-rules.nix { inherit lib; };
}
];
}
))
];
scrapeConfigs = [
{
job_name = "telegraf";
scrape_interval = "60s";
metrics_path = "/metrics";
basic_auth.username = "prometheus";
basic_auth.password_file = "/etc/prometheus/password";
static_configs = [
{
# Scrape all machines with the `telegraf` role
# https://prometheus:<password>@<host>.<tld>:9273/metrics
# scheme = "https";
# scheme = "http";
targets = map (m: "${m}.${config.clan.core.settings.tld}:9273") (
lib.attrNames roles.telegraf.machines
);
labels.type = instanceName;
}
];
}
];
};
};
};
}

View File

@@ -1,128 +1,32 @@
{ {
roles.telegraf.perInstance = roles.telegraf.perInstance =
{ settings, ... }: { ... }:
{ {
nixosModule = nixosModule =
{ {
config, config,
pkgs, pkgs,
lib,
... ...
}: }:
let
auth_user = "prometheus";
in
{ {
warnings =
lib.optionals (settings.allowAllInterfaces != null) [
"monitoring.settings.allowAllInterfaces is deprecated and and has no effect. Please remove it from your inventory."
"The monitoring service will now always listen on all interfaces over https."
]
++ (lib.optionals (settings.interfaces != null) [
"monitoring.settings.interfaces is deprecated and and has no effect. Please remove it from your inventory."
"The monitoring service will now always listen on all interfaces over https."
]);
networking.firewall.allowedTCPPorts = [ networking.firewall.allowedTCPPorts = [ 9273 ];
9273
9990
];
clan.core.vars.generators."telegraf-certs" = {
files.crt = {
restartUnits = [ "telegraf.service" ];
deploy = true;
secret = false;
};
files.key = {
mode = "0600";
restartUnits = [ "telegraf.service" ];
};
runtimeInputs = [
pkgs.openssl
];
# TODO: Implement automated certificate rotation instead of using a 100-year expiration
script = ''
openssl req -x509 -nodes -newkey rsa:4096 \
-days 36500 \
-keyout "$out"/key \
-out "$out"/crt \
-subj "/C=US/ST=CA/L=San Francisco/O=Example Corp/OU=IT/CN=example.com"
'';
};
clan.core.vars.generators."telegraf" = {
files.password.restartUnits = [ "telegraf.service" ];
files.password-env.restartUnits = [ "telegraf.service" ];
files.miniserve-auth.restartUnits = [ "telegraf.service" ];
dependencies = [ "telegraf-certs" ];
runtimeInputs = [
pkgs.coreutils
pkgs.xkcdpass
pkgs.mkpasswd
];
script = ''
PASSWORD=$(xkcdpass --numwords 4 --delimiter - --count 1 | tr -d "\n")
echo "BASIC_AUTH_PWD=$PASSWORD" > "$out"/password-env
echo "${auth_user}:$PASSWORD" > "$out"/miniserve-auth
echo "$PASSWORD" | tr -d "\n" > "$out"/password
'';
};
systemd.services.telegraf-json = {
enable = true;
wantedBy = [ "multi-user.target" ];
after = [ "telegraf.service" ];
requires = [ "telegraf.service" ];
serviceConfig = {
LoadCredential = [
"auth_file_path:${config.clan.core.vars.generators.telegraf.files.miniserve-auth.path}"
"telegraf_crt_path:${config.clan.core.vars.generators.telegraf-certs.files.crt.path}"
"telegraf_key_path:${config.clan.core.vars.generators.telegraf-certs.files.key.path}"
];
Environment = [
"AUTH_FILE_PATH=%d/auth_file_path"
"CRT_PATH=%d/telegraf_crt_path"
"KEY_PATH=%d/telegraf_key_path"
];
Restart = "on-failure";
User = "telegraf";
Group = "telegraf";
RuntimeDirectory = "telegraf-www";
};
script = "${pkgs.miniserve}/bin/miniserve -p 9990 /run/telegraf-www --auth-file \"$AUTH_FILE_PATH\" --tls-cert \"$CRT_PATH\" --tls-key \"$KEY_PATH\"";
};
systemd.services.telegraf = {
serviceConfig = {
LoadCredential = [
"telegraf_crt_path:${config.clan.core.vars.generators.telegraf-certs.files.crt.path}"
"telegraf_key_path:${config.clan.core.vars.generators.telegraf-certs.files.key.path}"
];
Environment = [
"CRT_PATH=%d/telegraf_crt_path"
"KEY_PATH=%d/telegraf_key_path"
];
};
};
services.telegraf = { services.telegraf = {
enable = true; enable = true;
environmentFiles = [ environmentFiles = [ config.clan.core.vars.generators.prometheus.files.password-env.path ];
(builtins.toString config.clan.core.vars.generators.telegraf.files.password-env.path)
];
extraConfig = { extraConfig = {
agent.interval = "60s"; agent.interval = "60s";
inputs = { inputs = {
# More input plugins available at:
# https://github.com/influxdata/telegraf/tree/master/plugins/inputs
diskio = { }; diskio = { };
disk = { };
cpu = { };
processes = { };
kernel_vmstat = { }; kernel_vmstat = { };
system = { }; system = { };
mem = { }; mem = { };
@@ -147,20 +51,12 @@
} }
]; ];
}; };
# sadly there doesn'T seem to exist a telegraf http_client output plugin # sadly there doesn't seem to exist a telegraf http_client output plugin
outputs.prometheus_client = { outputs.prometheus_client = {
listen = ":9273"; listen = ":9273";
metric_version = 2; metric_version = 2;
basic_username = "${auth_user}"; basic_username = "prometheus";
basic_password = "$${BASIC_AUTH_PWD}"; basic_password = "$${BASIC_AUTH_PWD}";
tls_cert = "$${CRT_PATH}";
tls_key = "$${KEY_PATH}";
};
outputs.file = {
files = [ "/run/telegraf-www/telegraf.json" ];
data_format = "json";
json_timestamp_units = "1s";
}; };
}; };
}; };

View File

@@ -22,7 +22,6 @@ in
../../clanServices/syncthing ../../clanServices/syncthing
# Required modules # Required modules
../../nixosModules/clanCore ../../nixosModules/clanCore
../../nixosModules/machineModules
# Dependencies like clan-cli # Dependencies like clan-cli
../../pkgs/clan-cli ../../pkgs/clan-cli
]; ];

View File

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

View File

@@ -41,14 +41,14 @@ let
# In this case it is 'self-zerotier-redux' # In this case it is 'self-zerotier-redux'
# This is usually only used internally, but we can use it to test the evaluation of service module in isolation # This is usually only used internally, but we can use it to test the evaluation of service module in isolation
# evaluatedService = # evaluatedService =
# testFlake.clanInternals.inventoryClass.distributedServices.servicesEval.config.mappedServices.self-zerotier-redux.config; # testFlake.clanInternals.inventoryClass.distributedServices.importedModulesEvaluated.self-zerotier-redux.config;
in in
{ {
test_simple = { test_simple = {
inherit testFlake; inherit testFlake;
expr = expr =
testFlake.config.clan.clanInternals.inventoryClass.distributedServices.servicesEval.config.mappedServices.self-wifi.config; testFlake.config.clan.clanInternals.inventoryClass.distributedServices.importedModulesEvaluated.self-wifi.config;
expected = 1; expected = 1;
# expr = { # expr = {

View File

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

View File

@@ -21,7 +21,6 @@ in
../../clanServices/zerotier ../../clanServices/zerotier
# Required modules # Required modules
../../nixosModules/clanCore ../../nixosModules/clanCore
../../nixosModules/machineModules
# Dependencies like clan-cli # Dependencies like clan-cli
../../pkgs/clan-cli ../../pkgs/clan-cli
]; ];

12
devFlake/flake.lock generated
View File

@@ -105,11 +105,11 @@
}, },
"nixpkgs-dev": { "nixpkgs-dev": {
"locked": { "locked": {
"lastModified": 1762168314, "lastModified": 1761458099,
"narHash": "sha256-+DX6mIF47gRGoK0mqkTg1Jmcjcup0CAXJFHVkdUx8YA=", "narHash": "sha256-XeAdn1NidGKXSwlepyjH+n58hsCDqbpx1M8sdDM2Ggc=",
"owner": "NixOS", "owner": "NixOS",
"repo": "nixpkgs", "repo": "nixpkgs",
"rev": "94fc102d2c15d9c1a861e59de550807c65358e1b", "rev": "d8cc1036c65d3c9468a91443a75b51276279ac61",
"type": "github" "type": "github"
}, },
"original": { "original": {
@@ -128,11 +128,11 @@
] ]
}, },
"locked": { "locked": {
"lastModified": 1761730856, "lastModified": 1760652422,
"narHash": "sha256-t1i5p/vSWwueZSC0Z2BImxx3BjoUDNKyC2mk24krcMY=", "narHash": "sha256-C88Pgz38QIl9JxQceexqL2G7sw9vodHWx1Uaq+NRJrw=",
"owner": "NuschtOS", "owner": "NuschtOS",
"repo": "search", "repo": "search",
"rev": "e29de6db0cb3182e9aee75a3b1fd1919d995d85b", "rev": "3ebeebe8b6a49dfb11f771f761e0310f7c48d726",
"type": "github" "type": "github"
}, },
"original": { "original": {

View File

@@ -51,7 +51,7 @@ Make sure you have the following:
**Note:** This creates a new directory in your current location **Note:** This creates a new directory in your current location
```shellSession ```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: 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. 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. 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. - 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.
They offer significant performance advantages over VM tests: - setuid binaries don't work
- **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.
### Where to find examples for NixOS container tests ### Where to find examples for NixOS container tests

24
flake.lock generated
View File

@@ -31,11 +31,11 @@
] ]
}, },
"locked": { "locked": {
"lastModified": 1761899396, "lastModified": 1760701190,
"narHash": "sha256-XOpKBp6HLzzMCbzW50TEuXN35zN5WGQREC7n34DcNMM=", "narHash": "sha256-y7UhnWlER8r776JsySqsbTUh2Txf7K30smfHlqdaIQw=",
"owner": "nix-community", "owner": "nix-community",
"repo": "disko", "repo": "disko",
"rev": "6f4cf5abbe318e4cd1e879506f6eeafd83f7b998", "rev": "3a9450b26e69dcb6f8de6e2b07b3fc1c288d85f5",
"type": "github" "type": "github"
}, },
"original": { "original": {
@@ -51,11 +51,11 @@
] ]
}, },
"locked": { "locked": {
"lastModified": 1762040540, "lastModified": 1760948891,
"narHash": "sha256-z5PlZ47j50VNF3R+IMS9LmzI5fYRGY/Z5O5tol1c9I4=", "narHash": "sha256-TmWcdiUUaWk8J4lpjzu4gCGxWY6/Ok7mOK4fIFfBuU4=",
"owner": "hercules-ci", "owner": "hercules-ci",
"repo": "flake-parts", "repo": "flake-parts",
"rev": "0010412d62a25d959151790968765a70c436598b", "rev": "864599284fc7c0ba6357ed89ed5e2cd5040f0c04",
"type": "github" "type": "github"
}, },
"original": { "original": {
@@ -71,11 +71,11 @@
] ]
}, },
"locked": { "locked": {
"lastModified": 1762039661, "lastModified": 1760721282,
"narHash": "sha256-oM5BwAGE78IBLZn+AqxwH/saqwq3e926rNq5HmOulkc=", "narHash": "sha256-aAHphQbU9t/b2RRy2Eb8oMv+I08isXv2KUGFAFn7nCo=",
"owner": "nix-darwin", "owner": "nix-darwin",
"repo": "nix-darwin", "repo": "nix-darwin",
"rev": "c3c8c9f2a5ed43175ac4dc030308756620e6e4e4", "rev": "c3211fcd0c56c11ff110d346d4487b18f7365168",
"type": "github" "type": "github"
}, },
"original": { "original": {
@@ -115,10 +115,10 @@
"nixpkgs": { "nixpkgs": {
"locked": { "locked": {
"lastModified": 315532800, "lastModified": 315532800,
"narHash": "sha256-LDT9wuUZtjPfmviCcVWif5+7j4kBI2mWaZwjNNeg4eg=", "narHash": "sha256-yDxtm0PESdgNetiJN5+MFxgubBcLDTiuSjjrJiyvsvM=",
"rev": "a7fc11be66bdfb5cdde611ee5ce381c183da8386", "rev": "d7f52a7a640bc54c7bb414cca603835bf8dd4b10",
"type": "tarball", "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": { "original": {
"type": "tarball", "type": "tarball",

View File

@@ -39,10 +39,32 @@ in
}; };
modules = [ modules = [
clan-core.modules.clan.default clan-core.modules.clan.default
{
checks.minNixpkgsVersion = {
assertion = lib.versionAtLeast nixpkgs.lib.version "25.11";
message = ''
Nixpkgs version: ${nixpkgs.lib.version} is incompatible with clan-core. (>= 25.11 is recommended)
---
Your version of 'nixpkgs' seems too old for clan-core.
Please read: https://docs.clan.lol/guides/nixpkgs-flake-input
You can ignore this check by setting:
clan.checks.minNixpkgsVersion.ignore = true;
---
'';
};
}
]; ];
}; };
# Important: !This logic needs to be kept in sync with lib.clan function! apply =
apply = config: clan-core.lib.checkConfig config.checks config; config:
lib.deepSeq (lib.mapAttrs (
id: check:
if check.ignore || check.assertion then
null
else
throw "clan.checks.${id} failed with message\n${check.message}"
) config.checks) config;
}; };
# Mapped flake toplevel outputs # Mapped flake toplevel outputs

View File

@@ -1,19 +0,0 @@
{ lib, ... }:
/**
Function to assert clan configuration checks.
Arguments:
- 'checks' attribute of clan configuration
- Any: the returned configuration (can be anything, is just passed through)
*/
checks:
lib.deepSeq (
lib.mapAttrs (
id: check:
if check.ignore || check.assertion then
null
else
throw "clan.checks.${id} failed with message\n${check.message}"
) checks
)

View File

@@ -33,23 +33,20 @@
let let
nixpkgs = self.inputs.nixpkgs or clan-core.inputs.nixpkgs; nixpkgs = self.inputs.nixpkgs or clan-core.inputs.nixpkgs;
nix-darwin = self.inputs.nix-darwin or clan-core.inputs.nix-darwin; nix-darwin = self.inputs.nix-darwin or clan-core.inputs.nix-darwin;
configuration = (
lib.evalModules {
class = "clan";
specialArgs = {
inherit
self
;
inherit
nixpkgs
nix-darwin
;
};
modules = [
clan-core.modules.clan.default
m
];
}
);
in in
clan-core.clanLib.checkConfig configuration.config.checks configuration lib.evalModules {
class = "clan";
specialArgs = {
inherit
self
;
inherit
nixpkgs
nix-darwin
;
};
modules = [
clan-core.modules.clan.default
m
];
}

View File

@@ -16,8 +16,6 @@ lib.fix (
*/ */
callLib = file: args: import file ({ inherit lib clanLib; } // args); callLib = file: args: import file ({ inherit lib clanLib; } // args);
checkConfig = clanLib.callLib ./clan/checkConfig.nix { };
evalService = clanLib.callLib ./evalService.nix { }; evalService = clanLib.callLib ./evalService.nix { };
# ------------------------------------ # ------------------------------------
# ClanLib functions # ClanLib functions

View File

@@ -53,12 +53,7 @@ in
}; };
}; };
}).clan }).clan
{ { config.directory = rootPath; };
directory = rootPath;
self = {
inputs.nixpkgs.lib.version = "25.11";
};
};
in in
{ {
inherit vclan; inherit vclan;
@@ -99,12 +94,7 @@ in
}; };
}; };
}).clan }).clan
{ { config.directory = rootPath; };
directory = rootPath;
self = {
inputs.nixpkgs.lib.version = "25.11";
};
};
in in
{ {
inherit vclan; inherit vclan;

View File

@@ -2,7 +2,11 @@
lib, lib,
clanLib, clanLib,
}: }:
let
services = clanLib.callLib ./distributed-service/inventory-adapter.nix { };
in
{ {
inherit (services) mapInstances;
inventoryModule = { inventoryModule = {
_file = "clanLib.inventory.module"; _file = "clanLib.inventory.module";
imports = [ imports = [

View File

@@ -28,15 +28,19 @@ in
elemType = submoduleWith { elemType = submoduleWith {
class = "clan.service"; class = "clan.service";
specialArgs = { specialArgs = {
exports = config.exports;
directory = directory; directory = directory;
clanLib = specialArgs.clanLib; clanLib = specialArgs.clanLib;
exports = config.exports;
}; };
modules = [ modules = [
( (
{ name, ... }: { name, ... }:
{ {
_module.args._ctx = [ name ]; _module.args._ctx = [ name ];
_module.args.clanLib = specialArgs.clanLib;
_module.args.exports = config.exports;
_module.args.directory = directory;
} }
) )
./service-module.nix ./service-module.nix

View File

@@ -21,7 +21,6 @@ in
../../../flakeModules ../../../flakeModules
../../../lib ../../../lib
../../../nixosModules/clanCore ../../../nixosModules/clanCore
../../../nixosModules/machineModules
../../../machines ../../../machines
../../../inventory.json ../../../inventory.json
../../../modules ../../../modules

View File

@@ -0,0 +1,171 @@
# Adapter function between the inventory.instances and the clan.service module
#
# Data flow:
# - inventory.instances -> Adapter -> clan.service module -> Service Resources (i.e. NixosModules per Machine, Vars per Service, etc.)
#
# What this file does:
#
# - Resolves the [Module] to an actual module-path and imports it.
# - Groups together all the same modules into a single import and creates all instances for it.
# - Resolves the inventory tags into machines. Tags don't exist at the service level.
# Also combines the settings for 'machines' and 'tags'.
{
lib,
clanLib,
...
}:
{
mapInstances =
{
# This is used to resolve the module imports from 'flake.inputs'
flakeInputs,
# The clan inventory
inventory,
directory,
clanCoreModules,
prefix ? [ ],
exportsModule,
}:
let
# machineHasTag = machineName: tagName: lib.elem tagName inventory.machines.${machineName}.tags;
# map the instances into the module
importedModuleWithInstances = lib.mapAttrs (
instanceName: instance:
let
resolvedModule = clanLib.resolveModule {
moduleSpec = instance.module;
inherit flakeInputs clanCoreModules;
};
# Every instance includes machines via roles
# :: { client :: ... }
instanceRoles = lib.mapAttrs (
roleName: role:
let
resolvedMachines = clanLib.inventory.resolveTags {
members = {
# Explicit members
machines = lib.attrNames role.machines;
# Resolved Members
tags = lib.attrNames role.tags;
};
inherit (inventory) machines;
inherit instanceName roleName;
};
in
# instances.<instanceName>.roles.<roleName> =
# Remove "tags", they are resolved into "machines"
(removeAttrs role [ "tags" ])
// {
machines = lib.genAttrs resolvedMachines.machines (
machineName:
let
machineSettings = instance.roles.${roleName}.machines.${machineName}.settings or { };
in
# TODO: tag settings
# Wait for this feature until option introspection for 'settings' is done.
# This might get too complex to handle otherwise.
# settingsViaTags = lib.filterAttrs (
# tagName: _: machineHasTag machineName tagName
# ) instance.roles.${roleName}.tags;
{
# TODO: Do we want to wrap settings with
# setDefaultModuleLocation "inventory.instances.${instanceName}.roles.${roleName}.tags.${tagName}";
settings = {
imports = [
machineSettings
]; # ++ lib.attrValues (lib.mapAttrs (_tagName: v: v.settings) settingsViaTags);
};
}
);
}
) instance.roles;
in
{
inherit (instance) module;
inherit resolvedModule instanceRoles;
}
) inventory.instances or { };
# Group the instances by the module they resolve to
# This is necessary to evaluate the module in a single pass
# :: { <module.input>_<module.name> :: [ { name, value } ] }
# Since 'perMachine' needs access to all the instances we should include them as a whole
grouped = lib.foldlAttrs (
acc: instanceName: instance:
let
inputName = if instance.module.input == null then "<clan-core>" else instance.module.input;
id = inputName + "-" + instance.module.name;
in
acc
// {
${id} = acc.${id} or [ ] ++ [
{
inherit instanceName instance;
}
];
}
) { } importedModuleWithInstances;
# servicesEval.config.mappedServices.self-A.result.final.jon.nixosModule
allMachines = lib.mapAttrs (machineName: _: {
# This is the list of nixosModules for each machine
machineImports = lib.foldlAttrs (
acc: _module_ident: serviceModule:
acc ++ [ serviceModule.result.final.${machineName}.nixosModule or { } ]
) [ ] servicesEval.config.mappedServices;
}) inventory.machines or { };
evalServices =
{ modules, prefix }:
lib.evalModules {
class = "clan";
specialArgs = {
inherit clanLib;
_ctx = prefix;
};
modules = [
(import ./all-services-wrapper.nix { inherit directory; })
]
++ modules;
};
servicesEval = evalServices {
inherit prefix;
modules = [
{
inherit exportsModule;
mappedServices = lib.mapAttrs (_module_ident: instances: {
imports = [
# Import the resolved module.
# i.e. clan.modules.admin
{
options.module = lib.mkOption {
type = lib.types.raw;
default = (builtins.head instances).instance.module;
};
}
(builtins.head instances).instance.resolvedModule
] # Include all the instances that correlate to the resolved module
++ (builtins.map (v: {
instances.${v.instanceName}.roles = v.instance.instanceRoles;
}) instances);
}) grouped;
}
];
};
importedModulesEvaluated = servicesEval.config.mappedServices;
in
{
inherit
servicesEval
importedModuleWithInstances
# Exposed for testing
grouped
allMachines
importedModulesEvaluated
;
};
}

View File

@@ -81,7 +81,6 @@ let
applySettings = applySettings =
instanceName: instance: instanceName: instance:
lib.mapAttrs (roleName: role: { lib.mapAttrs (roleName: role: {
settings = config.instances.${instanceName}.roles.${roleName}.finalSettings.config;
machines = lib.mapAttrs (machineName: _v: { machines = lib.mapAttrs (machineName: _v: {
settings = settings =
config.instances.${instanceName}.roles.${roleName}.machines.${machineName}.finalSettings.config; config.instances.${instanceName}.roles.${roleName}.machines.${machineName}.finalSettings.config;
@@ -159,29 +158,6 @@ in
( (
{ name, ... }@role: { name, ... }@role:
{ {
options.finalSettings = mkOption {
default = evalMachineSettings instance.name role.name null role.config.settings { };
type = types.raw;
description = ''
Final evaluated settings of the curent-machine
This contains the merged and evaluated settings of the role interface,
the role settings and the machine settings.
Type: 'configuration' as returned by 'lib.evalModules'
'';
apply = lib.warn ''
=== WANRING ===
'roles.<roleName>.settings' do not contain machine specific settings.
Prefer `machines.<machineName>.settings` instead. (i.e `perInstance: roles.<roleName>.machines.<machineName>.settings`)
If you have a use-case that requires access to the original role settings without machine overrides.
Contact us via matrix (https://matrix.to/#/#clan:clan.lol) or file an issue: https://git.clan.lol
This feature will be removed in the next release
'';
};
# instances.{instanceName}.roles.{roleName}.machines # instances.{instanceName}.roles.{roleName}.machines
options.machines = mkOption { options.machines = mkOption {
description = '' description = ''
@@ -883,11 +859,7 @@ in
instanceRes.nixosModule instanceRes.nixosModule
] ]
++ (map ( ++ (map (
s: s: if builtins.typeOf s == "string" then "${directory}/${s}" else s
if builtins.typeOf s == "string" then
lib.warn "String types for 'extraModules' will be deprecated - ${s}" "${directory}/${s}"
else
lib.setDefaultModuleLocation "via inventory.instances.${instanceName}.roles.${roleName}" s
) instanceCfg.roles.${roleName}.extraModules); ) instanceCfg.roles.${roleName}.extraModules);
}; };
} }

View File

@@ -4,53 +4,63 @@
... ...
}: }:
let let
inherit (lib)
evalModules
;
flakeInputsFixture = { evalInventory =
upstream.clan.modules = { m:
uzzi = { (evalModules {
_class = "clan.service"; # Static modules
manifest = { modules = [
name = "uzzi-from-upstream"; clanLib.inventory.inventoryModule
}; {
}; _file = "test file";
}; tags.all = [ ];
}; tags.nixos = [ ];
tags.darwin = [ ];
}
{
modules.test = { };
}
m
];
}).config;
createTestClan = callInventoryAdapter =
testClan: inventoryModule:
let let
res = clanLib.clan ({ inventory = evalInventory inventoryModule;
# Static / mocked flakeInputsFixture = {
specialArgs = { self.clan.modules = inventoryModule.modules or { };
clan-core = { # Example upstream module
clan.modules = { }; upstream.clan.modules = {
uzzi = {
_class = "clan.service";
manifest = {
name = "uzzi-from-upstream";
};
}; };
}; };
self.inputs = flakeInputsFixture // { };
self.clan = res.config;
};
directory = ./.;
exportsModule = { };
imports = [
testClan
];
});
in in
res; clanLib.inventory.mapInstances {
directory = ./.;
clanCoreModules = { };
flakeInputs = flakeInputsFixture;
inherit inventory;
exportsModule = { };
};
in in
{ {
extraModules = import ./extraModules.nix { inherit clanLib; }; extraModules = import ./extraModules.nix { inherit clanLib; };
exports = import ./exports.nix { inherit lib clanLib; }; exports = import ./exports.nix { inherit lib clanLib; };
settings = import ./settings.nix { inherit lib createTestClan; }; settings = import ./settings.nix { inherit lib callInventoryAdapter; };
specialArgs = import ./specialArgs.nix { inherit lib createTestClan; }; specialArgs = import ./specialArgs.nix { inherit lib callInventoryAdapter; };
resolve_module_spec = import ./import_module_spec.nix { resolve_module_spec = import ./import_module_spec.nix { inherit lib callInventoryAdapter; };
inherit lib createTestClan;
};
test_simple = test_simple =
let let
res = createTestClan { res = callInventoryAdapter {
# Authored module # Authored module
# A minimal module looks like this # A minimal module looks like this
# It isn't exactly doing anything but it's a valid module that produces an output # It isn't exactly doing anything but it's a valid module that produces an output
@@ -61,7 +71,7 @@ in
}; };
}; };
# User config # User config
inventory.instances."instance_foo" = { instances."instance_foo" = {
module = { module = {
name = "simple-module"; name = "simple-module";
}; };
@@ -71,7 +81,7 @@ in
{ {
# Test that the module is mapped into the output # Test that the module is mapped into the output
# We might change the attribute name in the future # We might change the attribute name in the future
expr = res.config._services.mappedServices ? "<clan-core>-simple-module"; expr = res.importedModulesEvaluated ? "<clan-core>-simple-module";
expected = true; expected = true;
inherit res; inherit res;
}; };
@@ -82,7 +92,7 @@ in
# All instances should be included within one evaluation to make all of them available # All instances should be included within one evaluation to make all of them available
test_module_grouping = test_module_grouping =
let let
res = createTestClan { res = callInventoryAdapter {
# Authored module # Authored module
# A minimal module looks like this # A minimal module looks like this
# It isn't exactly doing anything but it's a valid module that produces an output # It isn't exactly doing anything but it's a valid module that produces an output
@@ -102,19 +112,18 @@ in
perMachine = { }: { }; perMachine = { }: { };
}; };
# User config # User config
inventory.instances."instance_foo" = { instances."instance_foo" = {
module = { module = {
name = "A"; name = "A";
}; };
}; };
inventory.instances."instance_bar" = { instances."instance_bar" = {
module = { module = {
name = "B"; name = "B";
}; };
}; };
inventory.instances."instance_baz" = { instances."instance_baz" = {
module = { module = {
name = "A"; name = "A";
}; };
@@ -124,16 +133,16 @@ in
{ {
# Test that the module is mapped into the output # Test that the module is mapped into the output
# We might change the attribute name in the future # We might change the attribute name in the future
expr = lib.attrNames res.config._services.mappedServices; expr = lib.mapAttrs (_n: v: builtins.length v) res.grouped;
expected = [ expected = {
"<clan-core>-A" "<clan-core>-A" = 2;
"<clan-core>-B" "<clan-core>-B" = 1;
]; };
}; };
test_creates_all_instances = test_creates_all_instances =
let let
res = createTestClan { res = callInventoryAdapter {
# Authored module # Authored module
# A minimal module looks like this # A minimal module looks like this
# It isn't exactly doing anything but it's a valid module that produces an output # It isn't exactly doing anything but it's a valid module that produces an output
@@ -145,24 +154,22 @@ in
perMachine = { }: { }; perMachine = { }: { };
}; };
inventory = { instances."instance_foo" = {
instances."instance_foo" = { module = {
module = { name = "A";
name = "A"; input = "self";
input = "self";
};
}; };
instances."instance_bar" = { };
module = { instances."instance_bar" = {
name = "A"; module = {
input = "self"; name = "A";
}; input = "self";
}; };
instances."instance_zaza" = { };
module = { instances."instance_zaza" = {
name = "B"; module = {
input = null; name = "B";
}; input = null;
}; };
}; };
}; };
@@ -170,7 +177,7 @@ in
{ {
# Test that the module is mapped into the output # Test that the module is mapped into the output
# We might change the attribute name in the future # We might change the attribute name in the future
expr = lib.attrNames res.config._services.mappedServices.self-A.instances; expr = lib.attrNames res.importedModulesEvaluated.self-A.instances;
expected = [ expected = [
"instance_bar" "instance_bar"
"instance_foo" "instance_foo"
@@ -180,7 +187,7 @@ in
# Membership via roles # Membership via roles
test_add_machines_directly = test_add_machines_directly =
let let
res = createTestClan { res = callInventoryAdapter {
# Authored module # Authored module
# A minimal module looks like this # A minimal module looks like this
# It isn't exactly doing anything but it's a valid module that produces an output # It isn't exactly doing anything but it's a valid module that produces an output
@@ -195,40 +202,38 @@ in
# perMachine = {}: {}; # perMachine = {}: {};
}; };
inventory = { machines = {
machines = { jon = { };
jon = { }; sara = { };
sara = { }; hxi = { };
hxi = { }; };
instances."instance_foo" = {
module = {
name = "A";
input = "self";
}; };
instances."instance_foo" = { roles.peer.machines.jon = { };
module = { };
name = "A"; instances."instance_bar" = {
input = "self"; module = {
}; name = "A";
roles.peer.machines.jon = { }; input = "self";
}; };
instances."instance_bar" = { roles.peer.machines.sara = { };
module = { };
name = "A"; instances."instance_zaza" = {
input = "self"; module = {
}; name = "B";
roles.peer.machines.sara = { }; input = null;
};
instances."instance_zaza" = {
module = {
name = "B";
input = null;
};
roles.peer.tags.all = { };
}; };
roles.peer.tags.all = { };
}; };
}; };
in in
{ {
# Test that the module is mapped into the output # Test that the module is mapped into the output
# We might change the attribute name in the future # We might change the attribute name in the future
expr = lib.attrNames res.config._services.mappedServices.self-A.result.allMachines; expr = lib.attrNames res.importedModulesEvaluated.self-A.result.allMachines;
expected = [ expected = [
"jon" "jon"
"sara" "sara"
@@ -238,7 +243,7 @@ in
# Membership via tags # Membership via tags
test_add_machines_via_tags = test_add_machines_via_tags =
let let
res = createTestClan { res = callInventoryAdapter {
# Authored module # Authored module
# A minimal module looks like this # A minimal module looks like this
# It isn't exactly doing anything but it's a valid module that produces an output # It isn't exactly doing anything but it's a valid module that produces an output
@@ -252,37 +257,35 @@ in
# perMachine = {}: {}; # perMachine = {}: {};
}; };
inventory = { machines = {
machines = { jon = {
jon = { tags = [ "foo" ];
tags = [ "foo" ];
};
sara = {
tags = [ "foo" ];
};
hxi = { };
}; };
instances."instance_foo" = { sara = {
module = { tags = [ "foo" ];
name = "A";
input = "self";
};
roles.peer.tags.foo = { };
}; };
instances."instance_zaza" = { hxi = { };
module = { };
name = "B"; instances."instance_foo" = {
input = null; module = {
}; name = "A";
roles.peer.tags.all = { }; input = "self";
}; };
roles.peer.tags.foo = { };
};
instances."instance_zaza" = {
module = {
name = "B";
input = null;
};
roles.peer.tags.all = { };
}; };
}; };
in in
{ {
# Test that the module is mapped into the output # Test that the module is mapped into the output
# We might change the attribute name in the future # We might change the attribute name in the future
expr = lib.attrNames res.config._services.mappedServices.self-A.result.allMachines; expr = lib.attrNames res.importedModulesEvaluated.self-A.result.allMachines;
expected = [ expected = [
"jon" "jon"
"sara" "sara"
@@ -290,9 +293,6 @@ in
}; };
machine_imports = import ./machine_imports.nix { inherit lib clanLib; }; machine_imports = import ./machine_imports.nix { inherit lib clanLib; };
per_machine_args = import ./per_machine_args.nix { inherit lib createTestClan; }; per_machine_args = import ./per_machine_args.nix { inherit lib callInventoryAdapter; };
per_instance_args = import ./per_instance_args.nix { per_instance_args = import ./per_instance_args.nix { inherit lib callInventoryAdapter; };
inherit lib;
callInventoryAdapter = createTestClan;
};
} }

View File

@@ -1,4 +1,4 @@
{ createTestClan, ... }: { callInventoryAdapter, ... }:
let let
# Authored module # Authored module
# A minimal module looks like this # A minimal module looks like this
@@ -23,13 +23,10 @@ let
resolve = resolve =
spec: spec:
createTestClan { callInventoryAdapter {
inherit modules; inherit modules machines;
inventory = { instances."instance_foo" = {
inherit machines; module = spec;
instances."instance_foo" = {
module = spec;
};
}; };
}; };
in in
@@ -39,16 +36,25 @@ in
(resolve { (resolve {
name = "A"; name = "A";
input = "self"; input = "self";
}).config._services.mappedServices.self-A.manifest.name; }).importedModuleWithInstances.instance_foo.resolvedModule;
expected = "network"; expected = {
_class = "clan.service";
manifest = {
name = "network";
};
};
}; };
test_import_remote_module_by_name = { test_import_remote_module_by_name = {
expr = expr =
(resolve { (resolve {
name = "uzzi"; name = "uzzi";
input = "upstream"; input = "upstream";
}).config._services.mappedServices.upstream-uzzi.manifest.name; }).importedModuleWithInstances.instance_foo.resolvedModule;
expected = "uzzi-from-upstream"; expected = {
_class = "clan.service";
manifest = {
name = "uzzi-from-upstream";
};
};
}; };
} }

View File

@@ -58,43 +58,39 @@ let
sara = { }; sara = { };
}; };
res = callInventoryAdapter { res = callInventoryAdapter {
inherit modules; inherit modules machines;
instances."instance_foo" = {
inventory = { module = {
inherit machines; name = "A";
instances."instance_foo" = { input = "self";
module = {
name = "A";
input = "self";
};
roles.peer.machines.jon = {
settings.timeout = lib.mkForce "foo-peer-jon";
};
roles.peer = {
settings.timeout = "foo-peer";
};
roles.controller.machines.jon = { };
}; };
instances."instance_bar" = { roles.peer.machines.jon = {
module = { settings.timeout = lib.mkForce "foo-peer-jon";
name = "A";
input = "self";
};
roles.peer.machines.jon = {
settings.timeout = "bar-peer-jon";
};
}; };
# TODO: move this into a seperate test. roles.peer = {
# Seperate out the check that this module is never imported settings.timeout = "foo-peer";
# import the module "B" (undefined)
# All machines have this instance
instances."instance_zaza" = {
module = {
name = "B";
input = null;
};
roles.peer.tags.all = { };
}; };
roles.controller.machines.jon = { };
};
instances."instance_bar" = {
module = {
name = "A";
input = "self";
};
roles.peer.machines.jon = {
settings.timeout = "bar-peer-jon";
};
};
# TODO: move this into a seperate test.
# Seperate out the check that this module is never imported
# import the module "B" (undefined)
# All machines have this instance
instances."instance_zaza" = {
module = {
name = "B";
input = null;
};
roles.peer.tags.all = { };
}; };
}; };
@@ -109,10 +105,9 @@ in
{ {
# settings should evaluate # settings should evaluate
test_per_instance_arguments = { test_per_instance_arguments = {
inherit res;
expr = { expr = {
instanceName = instanceName =
res.config._services.mappedServices.self-A.result.allRoles.peer.allInstances."instance_foo".allMachines.jon.passthru.instanceName; res.importedModulesEvaluated.self-A.result.allRoles.peer.allInstances."instance_foo".allMachines.jon.passthru.instanceName;
# settings are specific. # settings are specific.
# Below we access: # Below we access:
@@ -120,11 +115,11 @@ in
# roles = peer # roles = peer
# machines = jon # machines = jon
settings = settings =
res.config._services.mappedServices.self-A.result.allRoles.peer.allInstances.instance_foo.allMachines.jon.passthru.settings; res.importedModulesEvaluated.self-A.result.allRoles.peer.allInstances.instance_foo.allMachines.jon.passthru.settings;
machine = machine =
res.config._services.mappedServices.self-A.result.allRoles.peer.allInstances.instance_foo.allMachines.jon.passthru.machine; res.importedModulesEvaluated.self-A.result.allRoles.peer.allInstances.instance_foo.allMachines.jon.passthru.machine;
roles = roles =
res.config._services.mappedServices.self-A.result.allRoles.peer.allInstances.instance_foo.allMachines.jon.passthru.roles; res.importedModulesEvaluated.self-A.result.allRoles.peer.allInstances.instance_foo.allMachines.jon.passthru.roles;
}; };
expected = { expected = {
instanceName = "instance_foo"; instanceName = "instance_foo";
@@ -142,7 +137,6 @@ in
settings = { }; settings = { };
}; };
}; };
settings = { };
}; };
peer = { peer = {
machines = { machines = {
@@ -152,9 +146,6 @@ in
}; };
}; };
}; };
settings = {
timeout = "foo-peer";
};
}; };
}; };
settings = { settings = {
@@ -165,9 +156,9 @@ in
# TODO: Cannot be tested like this anymore # TODO: Cannot be tested like this anymore
test_per_instance_settings_vendoring = { test_per_instance_settings_vendoring = {
x = res.config._services.mappedServices.self-A; x = res.importedModulesEvaluated.self-A;
expr = expr =
res.config._services.mappedServices.self-A.result.allRoles.peer.allInstances.instance_foo.allMachines.jon.passthru.vendoredSettings; res.importedModulesEvaluated.self-A.result.allRoles.peer.allInstances.instance_foo.allMachines.jon.passthru.vendoredSettings;
expected = { expected = {
timeout = "config.thing"; timeout = "config.thing";
}; };

View File

@@ -1,4 +1,4 @@
{ lib, createTestClan }: { lib, callInventoryAdapter }:
let let
# Authored module # Authored module
# A minimal module looks like this # A minimal module looks like this
@@ -39,40 +39,36 @@ let
jon = { }; jon = { };
sara = { }; sara = { };
}; };
res = createTestClan { res = callInventoryAdapter {
inherit modules; inherit modules machines;
inventory = { instances."instance_foo" = {
module = {
inherit machines; name = "A";
instances."instance_foo" = { input = "self";
module = {
name = "A";
input = "self";
};
roles.peer.machines.jon = {
settings.timeout = lib.mkForce "foo-peer-jon";
};
roles.peer = {
settings.timeout = "foo-peer";
};
}; };
instances."instance_bar" = { roles.peer.machines.jon = {
module = { settings.timeout = lib.mkForce "foo-peer-jon";
name = "A";
input = "self";
};
roles.peer.machines.jon = {
settings.timeout = "bar-peer-jon";
};
}; };
instances."instance_zaza" = { roles.peer = {
module = { settings.timeout = "foo-peer";
name = "B";
input = null;
};
roles.peer.tags.all = { };
}; };
}; };
instances."instance_bar" = {
module = {
name = "A";
input = "self";
};
roles.peer.machines.jon = {
settings.timeout = "bar-peer-jon";
};
};
instances."instance_zaza" = {
module = {
name = "B";
input = null;
};
roles.peer.tags.all = { };
};
}; };
in in
@@ -83,7 +79,7 @@ in
inherit res; inherit res;
expr = { expr = {
hasMachineSettings = hasMachineSettings =
res.config._services.mappedServices.self-A.result.allMachines.jon.passthru.instances.instance_foo.roles.peer.machines.jon res.importedModulesEvaluated.self-A.result.allMachines.jon.passthru.instances.instance_foo.roles.peer.machines.jon
? settings; ? settings;
# settings are specific. # settings are specific.
@@ -92,10 +88,10 @@ in
# roles = peer # roles = peer
# machines = jon # machines = jon
specificMachineSettings = specificMachineSettings =
res.config._services.mappedServices.self-A.result.allMachines.jon.passthru.instances.instance_foo.roles.peer.machines.jon.settings; res.importedModulesEvaluated.self-A.result.allMachines.jon.passthru.instances.instance_foo.roles.peer.machines.jon.settings;
hasRoleSettings = hasRoleSettings =
res.config._services.mappedServices.self-A.result.allMachines.jon.passthru.instances.instance_foo.roles.peer res.importedModulesEvaluated.self-A.result.allMachines.jon.passthru.instances.instance_foo.roles.peer
? settings; ? settings;
# settings are specific. # settings are specific.
@@ -104,25 +100,20 @@ in
# roles = peer # roles = peer
# machines = * # machines = *
specificRoleSettings = specificRoleSettings =
res.config._services.mappedServices.self-A.result.allMachines.jon.passthru.instances.instance_foo.roles.peer; res.importedModulesEvaluated.self-A.result.allMachines.jon.passthru.instances.instance_foo.roles.peer;
}; };
expected = { expected = rec {
hasMachineSettings = true; hasMachineSettings = true;
hasRoleSettings = true; hasRoleSettings = false;
specificMachineSettings = { specificMachineSettings = {
timeout = "foo-peer-jon"; timeout = "foo-peer-jon";
}; };
specificRoleSettings = { specificRoleSettings = {
machines = { machines = {
jon = { jon = {
settings = { settings = specificMachineSettings;
timeout = "foo-peer-jon";
};
}; };
}; };
settings = {
timeout = "foo-peer";
};
}; };
}; };
}; };

View File

@@ -1,6 +1,6 @@
{ createTestClan, lib, ... }: { callInventoryAdapter, lib, ... }:
let let
res = createTestClan { res = callInventoryAdapter {
modules."A" = { modules."A" = {
_class = "clan.service"; _class = "clan.service";
manifest = { manifest = {
@@ -21,31 +21,28 @@ let
}; };
}; };
}; };
inventory = { machines = {
jon = { };
machines = { sara = { };
jon = { }; };
sara = { }; instances."instance_foo" = {
module = {
name = "A";
input = "self";
}; };
instances."instance_foo" = { # Settings for both jon and sara
module = { roles.peer.settings = {
name = "A"; timeout = 40;
input = "self";
};
# Settings for both jon and sara
roles.peer.settings = {
timeout = 40;
};
# Jon overrides timeout
roles.peer.machines.jon = {
settings.timeout = lib.mkForce 42;
};
roles.peer.machines.sara = { };
}; };
# Jon overrides timeout
roles.peer.machines.jon = {
settings.timeout = lib.mkForce 42;
};
roles.peer.machines.sara = { };
}; };
}; };
config = res.config._services.mappedServices.self-A; config = res.servicesEval.config.mappedServices.self-A;
# #
applySettings = applySettings =

View File

@@ -1,6 +1,6 @@
{ createTestClan, lib, ... }: { callInventoryAdapter, lib, ... }:
let let
res = createTestClan { res = callInventoryAdapter {
modules."A" = m: { modules."A" = m: {
_class = "clan.service"; _class = "clan.service";
config = { config = {
@@ -14,21 +14,19 @@ let
default = m; default = m;
}; };
}; };
inventory = { machines = {
machines = { jon = { };
jon = { }; };
}; instances."instance_foo" = {
instances."instance_foo" = { module = {
module = { name = "A";
name = "A"; input = "self";
input = "self";
};
roles.peer.machines.jon = { };
}; };
roles.peer.machines.jon = { };
}; };
}; };
specialArgs = lib.attrNames res.config._services.mappedServices.self-A.test.specialArgs; specialArgs = lib.attrNames res.servicesEval.config.mappedServices.self-A.test.specialArgs;
in in
{ {
test_simple = { test_simple = {

View File

@@ -212,36 +212,6 @@ in
}; };
}; };
test_clan_check_simple_fail =
let
eval = clan {
checks.constFail = {
assertion = false;
message = "This is a constant failure";
};
};
in
{
result = eval;
expr = eval.config;
expectedError.type = "ThrownError";
expectedError.msg = "This is a constant failure";
};
test_clan_check_simple_pass =
let
eval = clan {
checks.constFail = {
assertion = true;
message = "This is a constant success";
};
};
in
{
result = eval;
expr = lib.seq eval.config 42;
expected = 42;
};
test_get_var_machine = test_get_var_machine =
let let
varsLib = import ./vars.nix { }; varsLib = import ./vars.nix { };

View File

@@ -1,16 +0,0 @@
{ lib, nixpkgs, ... }:
{
checks.minNixpkgsVersion = {
assertion = lib.versionAtLeast nixpkgs.lib.version "25.11";
message = ''
Nixpkgs version: ${nixpkgs.lib.version} is incompatible with clan-core. (>= 25.11 is recommended)
---
Your version of 'nixpkgs' seems too old for clan-core.
Please read: https://docs.clan.lol/guides/nixpkgs-flake-input
You can ignore this check by setting:
clan.checks.minNixpkgsVersion.ignore = true;
---
'';
};
}

View File

@@ -1,14 +1,3 @@
/**
Root 'clan' Module
Defines lib.clan and flake-parts.clan options
and all common logic for the 'clan' module.
- has Class _class = "clan"
- _module.args.clan-core: reference to clan-core flake
- _module.args.clanLib: reference to lib.clan function
*/
{ clan-core }: { clan-core }:
{ {
_class = "clan"; _class = "clan";
@@ -17,9 +6,7 @@
inherit (clan-core) clanLib; inherit (clan-core) clanLib;
}; };
imports = [ imports = [
./top-level-interface.nix
./module.nix ./module.nix
./distributed-services.nix ./interface.nix
./checks.nix
]; ];
} }

View File

@@ -1,163 +0,0 @@
{
lib,
clanLib,
config,
clan-core,
...
}:
let
inherit (lib) mkOption types;
# Keep a reference to top-level
clanConfig = config;
inventory = clanConfig.inventory;
flakeInputs = clanConfig.self.inputs;
clanCoreModules = clan-core.clan.modules;
grouped = lib.foldlAttrs (
acc: instanceName: instance:
let
inputName = if instance.module.input == null then "<clan-core>" else instance.module.input;
id = inputName + "-" + instance.module.name;
in
acc
// {
${id} = acc.${id} or [ ] ++ [
{
inherit instanceName instance;
}
];
}
) { } importedModuleWithInstances;
importedModuleWithInstances = lib.mapAttrs (
instanceName: instance:
let
resolvedModule = clanLib.resolveModule {
moduleSpec = instance.module;
inherit flakeInputs clanCoreModules;
};
# Every instance includes machines via roles
# :: { client :: ... }
instanceRoles = lib.mapAttrs (
roleName: role:
let
resolvedMachines = clanLib.inventory.resolveTags {
members = {
# Explicit members
machines = lib.attrNames role.machines;
# Resolved Members
tags = lib.attrNames role.tags;
};
inherit (inventory) machines;
inherit instanceName roleName;
};
in
# instances.<instanceName>.roles.<roleName> =
# Remove "tags", they are resolved into "machines"
(removeAttrs role [ "tags" ])
// {
machines = lib.genAttrs resolvedMachines.machines (
machineName:
let
machineSettings = instance.roles.${roleName}.machines.${machineName}.settings or { };
in
# TODO: tag settings
# Wait for this feature until option introspection for 'settings' is done.
# This might get too complex to handle otherwise.
# settingsViaTags = lib.filterAttrs (
# tagName: _: machineHasTag machineName tagName
# ) instance.roles.${roleName}.tags;
{
# TODO: Do we want to wrap settings with
# setDefaultModuleLocation "inventory.instances.${instanceName}.roles.${roleName}.tags.${tagName}";
settings = {
imports = [
machineSettings
]; # ++ lib.attrValues (lib.mapAttrs (_tagName: v: v.settings) settingsViaTags);
};
}
);
}
) instance.roles;
in
{
inherit (instance) module;
inherit resolvedModule instanceRoles;
}
) inventory.instances or { };
in
{
_class = "clan";
options._services = mkOption {
visible = false;
description = ''
All service instances
!!! Danger "Internal API"
Do not rely on this API yet.
- Will be renamed to just 'services' in the future.
Once the name can be claimed again.
- Structure will change.
API will be declared as public after beeing simplified.
'';
type = types.submoduleWith {
# TODO: Remove specialArgs
specialArgs = {
inherit clanLib;
};
modules = [
(import ../../lib/inventory/distributed-service/all-services-wrapper.nix {
inherit (clanConfig) directory;
})
# Dependencies
{
exportsModule = clanConfig.exportsModule;
}
{
# TODO: Rename to "allServices"
# All services
mappedServices = lib.mapAttrs (_module_ident: instances: {
imports = [
# Import the resolved module.
# i.e. clan.modules.admin
{
options.module = lib.mkOption {
type = lib.types.raw;
default = (builtins.head instances).instance.module;
};
}
(builtins.head instances).instance.resolvedModule
] # Include all the instances that correlate to the resolved module
++ (builtins.map (v: {
instances.${v.instanceName}.roles = v.instance.instanceRoles;
}) instances);
}) grouped;
}
];
};
default = { };
};
options._allMachines = mkOption {
internal = true;
type = types.raw;
default = lib.mapAttrs (machineName: _: {
# This is the list of nixosModules for each machine
machineImports = lib.foldlAttrs (
acc: _module_ident: serviceModule:
acc ++ [ serviceModule.result.final.${machineName}.nixosModule or { } ]
) [ ] config._services.mappedServices;
}) inventory.machines or { };
};
config = {
clanInternals.inventoryClass.machines = config._allMachines;
# clanInternals.inventoryClass.distributedServices = config._services;
# Exports from distributed services
exports = config._services.exports;
};
}

View File

@@ -3,16 +3,12 @@
lib, lib,
clanModule, clanModule,
clanLib, clanLib,
clan-core,
}: }:
let let
eval = lib.evalModules { eval = lib.evalModules {
modules = [ modules = [
clanModule clanModule
]; ];
specialArgs = {
self = clan-core;
};
}; };
evalDocs = pkgs.nixosOptionsDoc { evalDocs = pkgs.nixosOptionsDoc {

View File

@@ -12,7 +12,6 @@ in
}: }:
let let
jsonDocs = import ./eval-docs.nix { jsonDocs = import ./eval-docs.nix {
clan-core = self;
inherit inherit
pkgs pkgs
lib lib

View File

@@ -100,7 +100,7 @@ let
_: machine: _: machine:
machine.extendModules { machine.extendModules {
modules = [ modules = [
(lib.modules.importApply ../../nixosModules/machineModules/overridePkgs.nix { (lib.modules.importApply ../machineModules/overridePkgs.nix {
pkgs = pkgsFor.${system}; pkgs = pkgsFor.${system};
}) })
]; ];
@@ -167,9 +167,6 @@ in
{ ... }@args: { ... }@args:
let let
_class = _class =
# _class was added in https://github.com/NixOS/nixpkgs/pull/395141
# Clan relies on it to determine which modules to load
# people need to use at least that version of nixpkgs
args._class or (throw '' args._class or (throw ''
Your version of nixpkgs is incompatible with the latest clan. Your version of nixpkgs is incompatible with the latest clan.
Please update nixpkgs input to the latest nixos-unstable or nixpkgs-unstable. Please update nixpkgs input to the latest nixos-unstable or nixpkgs-unstable.
@@ -179,7 +176,7 @@ in
in in
{ {
imports = [ imports = [
(lib.modules.importApply ../../nixosModules/machineModules/forName.nix { (lib.modules.importApply ../machineModules/forName.nix {
inherit (config.inventory) meta; inherit (config.inventory) meta;
inherit inherit
name name
@@ -219,22 +216,12 @@ in
inherit nixosConfigurations; inherit nixosConfigurations;
inherit darwinConfigurations; inherit darwinConfigurations;
exports = config.clanInternals.inventoryClass.distributedServices.servicesEval.config.exports;
clanInternals = { clanInternals = {
inventoryClass = inventoryClass =
let let
flakeInputs = config.self.inputs; flakeInputs = config.self.inputs;
# Compute the relative directory path
selfStr = toString config.self;
dirStr = toString directory;
relativeDirectory =
if selfStr == dirStr then
""
else if lib.hasPrefix selfStr dirStr then
lib.removePrefix (selfStr + "/") dirStr
else
# This shouldn't happen in normal usage, but can occur when
# the flake is copied (e.g., in tests). Fall back to empty string.
"";
in in
{ {
_module.args = { _module.args = {
@@ -243,18 +230,25 @@ in
imports = [ imports = [
../inventoryClass/default.nix ../inventoryClass/default.nix
{ {
inherit inherit inventory directory flakeInputs;
inventory
directory
flakeInputs
relativeDirectory
;
exportsModule = config.exportsModule; exportsModule = config.exportsModule;
} }
( (
{ ... }: { config, ... }:
{ {
staticModules = clan-core.clan.modules; staticModules = clan-core.clan.modules;
distributedServices = clanLib.inventory.mapInstances {
inherit (config)
inventory
directory
flakeInputs
exportsModule
;
clanCoreModules = clan-core.clan.modules;
prefix = [ "distributedServices" ];
};
machines = config.distributedServices.allMachines;
} }
) )
]; ];

View File

@@ -1,28 +1,3 @@
/**
The templates submodule
'clan.templates'
Different kinds supported:
- clan templates: 'clan.templates.clan'
- disko templates: 'clan.templates.disko'
- machine templates: 'clan.templates.machine'
A template has the form:
```nix
{
description: string; # short summary what the template contains
path: path; # path to the template
}
```
The clan API copies the template from the given 'path'
into a target folder. For example,
`./machines/<machine-name>` for 'machine' templates.
*/
{ {
lib, lib,
... ...

View File

@@ -67,6 +67,9 @@ in
type = types.raw; type = types.raw;
}; };
distributedServices = mkOption {
type = types.raw;
};
inventory = mkOption { inventory = mkOption {
type = types.raw; type = types.raw;
}; };
@@ -78,14 +81,6 @@ in
directory = mkOption { directory = mkOption {
type = types.path; type = types.path;
}; };
relativeDirectory = mkOption {
type = types.str;
internal = true;
description = ''
The relative directory path from the flake root to the clan directory.
Empty string if directory equals the flake root.
'';
};
machines = mkOption { machines = mkOption {
type = types.attrsOf (submodule ({ type = types.attrsOf (submodule ({
options = { options = {

View File

@@ -44,6 +44,12 @@ in
description = '' description = ''
List of additionally imported `.nix` expressions. List of additionally imported `.nix` expressions.
Supported types:
- **Strings**: Interpreted relative to the 'directory' passed to `lib.clan`.
- **Paths**: should be relative to the current file.
- **Any**: Nix expression must be serializable to JSON.
!!! Note !!! Note
**The import only happens if the machine is part of the service or role.** **The import only happens if the machine is part of the service or role.**
@@ -68,7 +74,7 @@ in
``` ```
''; '';
default = [ ]; default = [ ];
type = types.listOf types.raw; type = types.listOf types.deferredModule;
}; };
}; };
} }

View File

@@ -3,7 +3,6 @@
directory, directory,
meta, meta,
}: }:
# The following is a nixos/darwin module
{ {
_class, _class,
lib, lib,

View File

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

View File

@@ -30,5 +30,5 @@ let
in in
{ {
# Note this might jump back and worth as kernel get added or removed. # 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

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

View File

@@ -246,7 +246,7 @@ class SecretStore(StoreBase):
) )
# chmod after in case it doesn't have u+w # chmod after in case it doesn't have u+w
target_path.touch(mode=0o600) 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) target_path.chmod(file.mode)
if "partitioning" in phases: if "partitioning" in phases:
@@ -260,7 +260,7 @@ class SecretStore(StoreBase):
) )
# chmod after in case it doesn't have u+w # chmod after in case it doesn't have u+w
target_path.touch(mode=0o600) 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) target_path.chmod(file.mode)
@override @override

View File

@@ -243,11 +243,7 @@ API.register(get_system_file)
if "oneOf" not in return_type: if "oneOf" not in return_type:
msg = ( msg = (
f"Return type of function '{name}' is not a union type. Expected a union of Success and Error types." f"Return type of function '{name}' is not a union type. Expected a union of Success and Error types."
# If the SuccessData type is unsupported it was dropped by Union narrowing. # @DavHau: no idea wy exactly this leads to the "oneOf" ot being present, but this should help
# This is probably an antifeature
# Introduced because run_generator wanted to use:
# Callable[[Generator], dict[str, str]]
# In its function signature.
"Hint: When using dataclasses as return types, ensure they don't contain public fields with non-serializable types" "Hint: When using dataclasses as return types, ensure they don't contain public fields with non-serializable types"
) )
raise JSchemaTypeError(msg) raise JSchemaTypeError(msg)

View File

@@ -156,28 +156,14 @@ def vm_state_dir(flake_url: str, vm_name: str) -> Path:
def machines_dir(flake: "Flake") -> Path: def machines_dir(flake: "Flake") -> Path:
# Determine the base path
if flake.is_local: if flake.is_local:
base_path = flake.path return flake.path / "machines"
else:
store_path = flake.store_path
if store_path is None:
msg = "Invalid flake object. Doesn't have a store path"
raise ClanError(msg)
base_path = Path(store_path)
# Get the clan directory configuration from Nix store_path = flake.store_path
# This is computed in Nix where store paths are consistent if store_path is None:
# Returns "" if no custom directory is set msg = "Invalid flake object. Doesn't have a store path"
# Fall back to "" if the option doesn't exist (backwards compatibility) raise ClanError(msg)
try: return Path(store_path) / "machines"
clan_dir = flake.select("clanInternals.inventoryClass.relativeDirectory")
except ClanError:
# Option doesn't exist in older clan-core versions
# Assume no custom directory
clan_dir = ""
return base_path / clan_dir / "machines"
def specific_machine_dir(machine: "MachineSpecProtocol") -> Path: def specific_machine_dir(machine: "MachineSpecProtocol") -> Path:

View File

@@ -211,7 +211,7 @@ class ClanSelectError(ClanError):
def __str__(self) -> str: def __str__(self) -> str:
if self.description: 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 return self.msg
def __repr__(self) -> str: def __repr__(self) -> str:

View File

@@ -5,18 +5,15 @@ from pathlib import Path
from typing import TYPE_CHECKING from typing import TYPE_CHECKING
from unittest.mock import MagicMock, patch from unittest.mock import MagicMock, patch
import clan_lib.llm.llm_types
import pytest import pytest
from clan_lib.flake.flake import Flake from clan_lib.flake.flake import Flake
from clan_lib.llm.llm_types import ModelConfig
from clan_lib.llm.orchestrator import get_llm_turn from clan_lib.llm.orchestrator import get_llm_turn
from clan_lib.llm.service import create_llm_model, run_llm_service from clan_lib.llm.service import create_llm_model, run_llm_service
from clan_lib.service_runner import create_service_manager from clan_lib.service_runner import create_service_manager
if TYPE_CHECKING: if TYPE_CHECKING:
from clan_lib.llm.llm_types import ChatResult from clan_lib.llm.llm_types import ChatResult
from clan_lib.llm.schemas import SessionState from clan_lib.llm.schemas import ChatMessage, SessionState
import platform
def get_current_mode(session_state: "SessionState") -> str: def get_current_mode(session_state: "SessionState") -> str:
@@ -171,80 +168,28 @@ def llm_service() -> Iterator[None]:
service_manager.stop_service("ollama") service_manager.stop_service("ollama")
@pytest.mark.service_runner def execute_multi_turn_workflow(
@pytest.mark.usefixtures("mock_nix_shell", "llm_service") user_request: str,
def test_full_conversation_flow(mock_flake: MagicMock) -> None: flake: Flake | MagicMock,
"""Test the complete conversation flow by manually calling get_llm_turn at each step. conversation_history: list["ChatMessage"] | None = None,
provider: str = "ollama",
session_state: "SessionState | None" = None,
) -> "ChatResult":
"""Execute the multi-turn workflow, auto-executing all pending operations.
This test verifies: This simulates the behavior of the CLI auto-execute loop in workflow.py.
- State transitions through discovery -> readme_fetch -> service_selection -> final_decision
- Each step returns the correct next_action
- Conversation history is preserved across turns
- Session state is correctly maintained
""" """
flake = mock_flake
trace_file = Path("~/.ollama/container_test_llm_trace.json").expanduser()
trace_file.unlink(missing_ok=True) # Start fresh
provider = "ollama"
# Override DEFAULT_MODELS with 4-minute timeouts for container tests
clan_lib.llm.llm_types.DEFAULT_MODELS = {
"ollama": ModelConfig(
name="qwen3:4b-instruct",
provider="ollama",
timeout=300, # set inference timeout to 5 minutes as CI may be slow
temperature=0, # set randomness to 0 for consistent test results
),
}
# ========== STEP 1: Initial request (should return next_action for discovery) ==========
print_separator("STEP 1: Initial Request", char="=", width=80)
result = get_llm_turn( result = get_llm_turn(
user_request="What VPN options do I have?", user_request=user_request,
flake=flake, flake=flake,
conversation_history=conversation_history,
provider=provider, # type: ignore[arg-type] provider=provider, # type: ignore[arg-type]
session_state=session_state,
execute_next_action=False, execute_next_action=False,
trace_file=trace_file,
) )
# Should have next_action for discovery phase # Auto-execute any pending operations
assert result.next_action is not None, "Should have next_action for discovery" while result.next_action:
assert result.next_action["type"] == "discovery"
assert result.requires_user_response is False
assert len(result.proposed_instances) == 0
assert "pending_discovery" in result.session_state
print(f" Next Action: {result.next_action['type']}")
print(f" Description: {result.next_action['description']}")
print_meta_info(result, turn=1, phase="Initial Request")
# ========== STEP 2: Execute discovery (should return next_action for readme_fetch) ==========
print_separator("STEP 2: Execute Discovery", char="=", width=80)
result = get_llm_turn(
user_request="",
flake=flake,
conversation_history=list(result.conversation_history),
provider=provider, # type: ignore[arg-type]
session_state=result.session_state,
execute_next_action=True,
trace_file=trace_file,
)
# Should have next_action for readme fetch OR a clarifying question
if result.next_action:
assert result.next_action["type"] == "fetch_readmes"
assert "pending_readme_fetch" in result.session_state
print(f" Next Action: {result.next_action['type']}")
print(f" Description: {result.next_action['description']}")
else:
# LLM asked a clarifying question
assert result.requires_user_response is True
assert len(result.assistant_message) > 0
print(f" Assistant Message: {result.assistant_message[:100]}...")
print_meta_info(result, turn=2, phase="Discovery Executed")
# ========== STEP 3: Execute readme fetch (if applicable) ==========
if result.next_action and result.next_action["type"] == "fetch_readmes":
print_separator("STEP 3: Execute Readme Fetch", char="=", width=80)
result = get_llm_turn( result = get_llm_turn(
user_request="", user_request="",
flake=flake, flake=flake,
@@ -252,74 +197,187 @@ def test_full_conversation_flow(mock_flake: MagicMock) -> None:
provider=provider, # type: ignore[arg-type] provider=provider, # type: ignore[arg-type]
session_state=result.session_state, session_state=result.session_state,
execute_next_action=True, execute_next_action=True,
trace_file=trace_file,
) )
# Should have next_action for service selection return result
assert result.next_action is not None
assert result.next_action["type"] == "service_selection"
assert "pending_service_selection" in result.session_state
print(f" Next Action: {result.next_action['type']}")
print(f" Description: {result.next_action['description']}")
print_meta_info(result, turn=3, phase="Readme Fetch Executed")
if platform.machine() == "aarch64":
pytest.skip(
"aarch64 detected: skipping readme/service-selection and final step for performance reasons"
)
# ========== STEP 4: Execute service selection ========== @pytest.mark.service_runner
print_separator("STEP 4: Execute Service Selection", char="=", width=80) @pytest.mark.usefixtures("mock_nix_shell", "llm_service")
result = get_llm_turn( def test_full_conversation_flow(mock_flake: MagicMock) -> None:
user_request="I want ZeroTier.", """Comprehensive test that exercises the complete conversation flow with the actual LLM service.
This test simulates a realistic multi-turn conversation that covers:
- Discovery phase: Initial request and LLM gathering information
- Service selection phase: User choosing from available options
- Final decision phase: Configuring the selected service with specific parameters
- State transitions: pending_service_selection -> pending_final_decision -> completion
- Conversation history preservation across all turns
- Error handling and edge cases
"""
flake = mock_flake
# ========== TURN 1: Discovery Phase - Initial vague request ==========
print_separator("TURN 1: Discovery Phase", char="=", width=80)
result = execute_multi_turn_workflow(
user_request="What VPN options do I have?",
flake=flake,
provider="ollama",
)
# Verify discovery phase behavior
assert result.requires_user_response is True, (
"Should require user response in discovery"
)
assert len(result.conversation_history) >= 2, (
"Should have user + assistant messages"
)
assert result.conversation_history[0]["role"] == "user"
assert result.conversation_history[0]["content"] == "What VPN options do I have?"
assert result.conversation_history[-1]["role"] == "assistant"
assert len(result.assistant_message) > 0, "Assistant should provide a response"
# After multi-turn execution, we may have either:
# - pending_service_selection (if LLM provided options and is waiting for choice)
# - pending_final_decision (if LLM directly selected a service)
# - no pending state (if LLM asked a clarifying question)
# No instances yet
assert len(result.proposed_instances) == 0
assert result.error is None
print_chat_exchange(
"What VPN options do I have?", result.assistant_message, result.session_state
)
print_meta_info(result, turn=1, phase="Discovery")
# ========== TURN 2: Service Selection Phase - User makes a choice ==========
print_separator("TURN 2: Service Selection", char="=", width=80)
user_msg_2 = "I'll use ZeroTier please"
result = execute_multi_turn_workflow(
user_request=user_msg_2,
flake=flake,
conversation_history=list(result.conversation_history),
provider="ollama",
session_state=result.session_state,
)
# Verify conversation history growth and preservation
assert len(result.conversation_history) > 2, "History should grow"
assert result.conversation_history[0]["content"] == "What VPN options do I have?"
assert result.conversation_history[2]["content"] == "I'll use ZeroTier please"
# Should either ask for configuration details or provide direct config
# Most likely will ask for more details (pending_final_decision)
if result.requires_user_response:
# LLM is asking for configuration details
assert len(result.assistant_message) > 0
# Should transition to final decision phase
if "pending_final_decision" not in result.session_state:
# Might still be in service selection asking clarifications
assert "pending_service_selection" in result.session_state
else:
# LLM provided configuration immediately (less likely)
assert len(result.proposed_instances) > 0
assert result.proposed_instances[0]["module"]["name"] == "zerotier"
print_chat_exchange(user_msg_2, result.assistant_message, result.session_state)
print_meta_info(result, turn=2, phase="Service Selection")
# ========== Continue conversation until we reach final decision or completion ==========
max_turns = 10
turn_count = 2
while result.requires_user_response and turn_count < max_turns:
turn_count += 1
# Determine appropriate response based on current state
if "pending_service_selection" in result.session_state:
# Still selecting service
user_request = "Yes, ZeroTier"
phase = "Service Selection (continued)"
elif "pending_final_decision" in result.session_state:
# Configuring the service
user_request = "Set up gchq-local as controller, qube-email as moon, and wintux as peer"
phase = "Final Configuration"
else:
# Generic continuation
user_request = "Yes, that sounds good. Use gchq-local as controller."
phase = "Continuing Conversation"
print_separator(f"TURN {turn_count}: {phase}", char="=", width=80)
result = execute_multi_turn_workflow(
user_request=user_request,
flake=flake, flake=flake,
conversation_history=list(result.conversation_history), conversation_history=list(result.conversation_history),
provider=provider, # type: ignore[arg-type] provider="ollama",
session_state=result.session_state, session_state=result.session_state,
execute_next_action=True,
trace_file=trace_file,
) )
# Should either have next_action for final_decision OR a clarifying question # Verify conversation history continues to grow
if result.next_action: assert len(result.conversation_history) == (turn_count * 2), (
assert result.next_action["type"] == "final_decision" f"History should have {turn_count * 2} messages (turn {turn_count})"
assert "pending_final_decision" in result.session_state )
print(f" Next Action: {result.next_action['type']}")
print(f" Description: {result.next_action['description']}")
else:
# LLM asked a clarifying question during service selection
assert result.requires_user_response is True
assert len(result.assistant_message) > 0
print(f" Assistant Message: {result.assistant_message[:100]}...")
print_meta_info(result, turn=4, phase="Service Selection Executed")
# ========== STEP 5: Execute final decision (if applicable) ========== # Verify history preservation
if result.next_action and result.next_action["type"] == "final_decision": assert (
print_separator("STEP 5: Execute Final Decision", char="=", width=80) result.conversation_history[0]["content"] == "What VPN options do I have?"
result = get_llm_turn( )
user_request="",
flake=flake,
conversation_history=list(result.conversation_history),
provider=provider, # type: ignore[arg-type]
session_state=result.session_state,
execute_next_action=True,
trace_file=trace_file,
)
# Should either have proposed_instances OR ask a clarifying question print_chat_exchange(
if result.proposed_instances: user_request, result.assistant_message, result.session_state
assert len(result.proposed_instances) > 0 )
assert result.next_action is None print_meta_info(result, turn=turn_count, phase=phase)
print(f" Proposed Instances: {len(result.proposed_instances)}")
for inst in result.proposed_instances:
print(f" - {inst['module']['name']}")
else:
# LLM asked a clarifying question
assert result.requires_user_response is True
assert len(result.assistant_message) > 0
print(f" Assistant Message: {result.assistant_message[:100]}...")
print_meta_info(result, turn=5, phase="Final Decision Executed")
# Verify conversation history has grown # Check for completion
assert len(result.conversation_history) > 0 if not result.requires_user_response:
assert result.conversation_history[0]["content"] == "What VPN options do I have?" print_separator("CONVERSATION COMPLETED", char="=", width=80)
break
# ========== Final Verification ==========
print_separator("FINAL VERIFICATION", char="=", width=80)
assert turn_count < max_turns, f"Conversation took too many turns ({turn_count})"
# If conversation completed, verify we have valid configuration
if not result.requires_user_response:
assert len(result.proposed_instances) > 0, (
"Should have at least one proposed instance"
)
instance = result.proposed_instances[0]
# Verify instance structure
assert "module" in instance
assert "name" in instance["module"]
assert instance["module"]["name"] in [
"zerotier",
"wireguard",
"yggdrasil",
"mycelium",
]
# Should not be in pending state anymore
assert "pending_service_selection" not in result.session_state
assert "pending_final_decision" not in result.session_state
assert result.error is None, f"Should not have error: {result.error}"
print_separator("FINAL SUMMARY", char="-", width=80, double=False)
print(" Status: SUCCESS")
print(f" Module Name: {instance['module']['name']}")
print(f" Total Turns: {turn_count}")
print(f" Final History Length: {len(result.conversation_history)} messages")
if "roles" in instance:
roles_list = ", ".join(instance["roles"].keys())
print(f" Configuration Roles: {roles_list}")
print(" Errors: None")
print("-" * 80)
else:
# Conversation didn't complete but should have made progress
assert len(result.conversation_history) > 2
assert result.error is None
print_separator("FINAL SUMMARY", char="-", width=80, double=False)
print(" Status: IN PROGRESS")
print(f" Total Turns: {turn_count}")
print(f" Current State: {list(result.session_state.keys())}")
print(f" History Length: {len(result.conversation_history)} messages")
print("-" * 80)

View File

@@ -149,7 +149,6 @@ def call_openai_api(
trace_file: Path | None = None, trace_file: Path | None = None,
stage: str = "unknown", stage: str = "unknown",
trace_metadata: dict[str, Any] | None = None, trace_metadata: dict[str, Any] | None = None,
temperature: float | None = None,
) -> OpenAIChatCompletionResponse: ) -> OpenAIChatCompletionResponse:
"""Call the OpenAI API for chat completion. """Call the OpenAI API for chat completion.
@@ -161,7 +160,6 @@ def call_openai_api(
trace_file: Optional path to write trace entries for debugging trace_file: Optional path to write trace entries for debugging
stage: Stage name for trace entries (default: "unknown") stage: Stage name for trace entries (default: "unknown")
trace_metadata: Optional metadata to include in trace entries trace_metadata: Optional metadata to include in trace entries
temperature: Sampling temperature (default: None = use API default)
Returns: Returns:
The parsed JSON response from the API The parsed JSON response from the API
@@ -180,8 +178,6 @@ def call_openai_api(
"messages": messages, "messages": messages,
"tools": list(tools), "tools": list(tools),
} }
if temperature is not None:
payload["temperature"] = temperature
_debug_log_request("openai", messages, tools) _debug_log_request("openai", messages, tools)
url = "https://api.openai.com/v1/chat/completions" url = "https://api.openai.com/v1/chat/completions"
headers = { headers = {
@@ -260,7 +256,6 @@ def call_claude_api(
trace_file: Path | None = None, trace_file: Path | None = None,
stage: str = "unknown", stage: str = "unknown",
trace_metadata: dict[str, Any] | None = None, trace_metadata: dict[str, Any] | None = None,
temperature: float | None = None,
) -> OpenAIChatCompletionResponse: ) -> OpenAIChatCompletionResponse:
"""Call the Claude API (via OpenAI-compatible endpoint) for chat completion. """Call the Claude API (via OpenAI-compatible endpoint) for chat completion.
@@ -273,7 +268,6 @@ def call_claude_api(
trace_file: Optional path to write trace entries for debugging trace_file: Optional path to write trace entries for debugging
stage: Stage name for trace entries (default: "unknown") stage: Stage name for trace entries (default: "unknown")
trace_metadata: Optional metadata to include in trace entries trace_metadata: Optional metadata to include in trace entries
temperature: Sampling temperature (default: None = use API default)
Returns: Returns:
The parsed JSON response from the API The parsed JSON response from the API
@@ -299,8 +293,6 @@ def call_claude_api(
"messages": messages, "messages": messages,
"tools": list(tools), "tools": list(tools),
} }
if temperature is not None:
payload["temperature"] = temperature
_debug_log_request("claude", messages, tools) _debug_log_request("claude", messages, tools)
url = f"{base_url}chat/completions" url = f"{base_url}chat/completions"
@@ -380,7 +372,6 @@ def call_ollama_api(
stage: str = "unknown", stage: str = "unknown",
max_tokens: int | None = None, max_tokens: int | None = None,
trace_metadata: dict[str, Any] | None = None, trace_metadata: dict[str, Any] | None = None,
temperature: float | None = None,
) -> OllamaChatResponse: ) -> OllamaChatResponse:
"""Call the Ollama API for chat completion. """Call the Ollama API for chat completion.
@@ -393,7 +384,6 @@ def call_ollama_api(
stage: Stage name for trace entries (default: "unknown") stage: Stage name for trace entries (default: "unknown")
max_tokens: Maximum number of tokens to generate (default: None = unlimited) max_tokens: Maximum number of tokens to generate (default: None = unlimited)
trace_metadata: Optional metadata to include in trace entries trace_metadata: Optional metadata to include in trace entries
temperature: Sampling temperature (default: None = use API default)
Returns: Returns:
The parsed JSON response from the API The parsed JSON response from the API
@@ -409,14 +399,9 @@ def call_ollama_api(
"tools": list(tools), "tools": list(tools),
} }
# Add options for max_tokens and temperature if specified # Add max_tokens limit if specified
options: dict[str, int | float] = {}
if max_tokens is not None: if max_tokens is not None:
options["num_predict"] = max_tokens payload["options"] = {"num_predict": max_tokens} # type: ignore[typeddict-item]
if temperature is not None:
options["temperature"] = temperature
if options:
payload["options"] = options # type: ignore[typeddict-item]
_debug_log_request("ollama", messages, tools) _debug_log_request("ollama", messages, tools)
url = "http://localhost:11434/api/chat" url = "http://localhost:11434/api/chat"

View File

@@ -73,21 +73,19 @@ class ModelConfig:
name: The model identifier/name name: The model identifier/name
provider: The LLM provider provider: The LLM provider
timeout: Request timeout in seconds (default: 120) timeout: Request timeout in seconds (default: 120)
temperature: Sampling temperature for the model (default: None = use API default)
""" """
name: str name: str
provider: Literal["openai", "ollama", "claude"] provider: Literal["openai", "ollama", "claude"]
timeout: int = 120 timeout: int = 120
temperature: float | None = None
# Default model configurations for each provider # Default model configurations for each provider
DEFAULT_MODELS: dict[Literal["openai", "ollama", "claude"], ModelConfig] = { DEFAULT_MODELS: dict[Literal["openai", "ollama", "claude"], ModelConfig] = {
"openai": ModelConfig(name="gpt-4o", provider="openai", timeout=60), "openai": ModelConfig(name="gpt-4o", provider="openai", timeout=60),
"claude": ModelConfig(name="claude-sonnet-4-5", provider="claude", timeout=60), "claude": ModelConfig(name="claude-sonnet-4-5", provider="claude", timeout=60),
"ollama": ModelConfig(name="qwen3:4b-instruct", provider="ollama", timeout=180), "ollama": ModelConfig(name="qwen3:4b-instruct", provider="ollama", timeout=120),
} }

View File

@@ -100,7 +100,6 @@ def get_llm_discovery_phase(
trace_file=trace_file, trace_file=trace_file,
stage="discovery", stage="discovery",
trace_metadata=trace_metadata, trace_metadata=trace_metadata,
temperature=model_config.temperature,
) )
function_calls, message_content = parse_openai_response( function_calls, message_content = parse_openai_response(
openai_response, provider="openai" openai_response, provider="openai"
@@ -114,7 +113,6 @@ def get_llm_discovery_phase(
trace_file=trace_file, trace_file=trace_file,
stage="discovery", stage="discovery",
trace_metadata=trace_metadata, trace_metadata=trace_metadata,
temperature=model_config.temperature,
) )
function_calls, message_content = parse_openai_response( function_calls, message_content = parse_openai_response(
claude_response, provider="claude" claude_response, provider="claude"
@@ -129,7 +127,6 @@ def get_llm_discovery_phase(
stage="discovery", stage="discovery",
max_tokens=300, # Limit output for discovery phase (get_readme calls or short question) max_tokens=300, # Limit output for discovery phase (get_readme calls or short question)
trace_metadata=trace_metadata, trace_metadata=trace_metadata,
temperature=model_config.temperature,
) )
function_calls, message_content = parse_ollama_response( function_calls, message_content = parse_ollama_response(
ollama_response, provider="ollama" ollama_response, provider="ollama"
@@ -252,7 +249,6 @@ def get_llm_service_selection(
trace_file=trace_file, trace_file=trace_file,
stage="select_service", stage="select_service",
trace_metadata=trace_metadata, trace_metadata=trace_metadata,
temperature=model_config.temperature,
) )
function_calls, message_content = parse_openai_response( function_calls, message_content = parse_openai_response(
openai_response, provider="openai" openai_response, provider="openai"
@@ -266,7 +262,6 @@ def get_llm_service_selection(
trace_file=trace_file, trace_file=trace_file,
stage="select_service", stage="select_service",
trace_metadata=trace_metadata, trace_metadata=trace_metadata,
temperature=model_config.temperature,
) )
function_calls, message_content = parse_openai_response( function_calls, message_content = parse_openai_response(
claude_response, provider="claude" claude_response, provider="claude"
@@ -281,7 +276,6 @@ def get_llm_service_selection(
stage="select_service", stage="select_service",
max_tokens=600, # Allow space for summary max_tokens=600, # Allow space for summary
trace_metadata=trace_metadata, trace_metadata=trace_metadata,
temperature=model_config.temperature,
) )
function_calls, message_content = parse_ollama_response( function_calls, message_content = parse_ollama_response(
ollama_response, provider="ollama" ollama_response, provider="ollama"
@@ -453,7 +447,6 @@ def get_llm_final_decision(
trace_file=trace_file, trace_file=trace_file,
stage="final_decision", stage="final_decision",
trace_metadata=trace_metadata, trace_metadata=trace_metadata,
temperature=model_config.temperature,
) )
function_calls, message_content = parse_openai_response( function_calls, message_content = parse_openai_response(
openai_response, provider="openai" openai_response, provider="openai"
@@ -469,7 +462,6 @@ def get_llm_final_decision(
trace_file=trace_file, trace_file=trace_file,
stage="final_decision", stage="final_decision",
trace_metadata=trace_metadata, trace_metadata=trace_metadata,
temperature=model_config.temperature,
) )
function_calls, message_content = parse_openai_response( function_calls, message_content = parse_openai_response(
claude_response, provider="claude" claude_response, provider="claude"
@@ -485,7 +477,6 @@ def get_llm_final_decision(
stage="final_decision", stage="final_decision",
max_tokens=500, # Limit output to prevent excessive verbosity max_tokens=500, # Limit output to prevent excessive verbosity
trace_metadata=trace_metadata, trace_metadata=trace_metadata,
temperature=model_config.temperature,
) )
function_calls, message_content = parse_ollama_response( function_calls, message_content = parse_ollama_response(
ollama_response, provider="ollama" ollama_response, provider="ollama"

View File

@@ -231,7 +231,6 @@ class ChatCompletionRequestPayload(TypedDict, total=False):
messages: list[ChatMessage] messages: list[ChatMessage]
tools: list[ToolDefinition] tools: list[ToolDefinition]
stream: NotRequired[bool] stream: NotRequired[bool]
temperature: NotRequired[float]
@dataclass(frozen=True) @dataclass(frozen=True)

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: if not has_path_inputs and not upload_inputs:
# Just copy the flake to the remote machine, we can substitute other inputs there. # Just copy the flake to the remote machine, we can substitute other inputs there.
path = flake_data["path"] 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( cmd = nix_command(
[ [
"copy", "copy",

View File

@@ -28,11 +28,13 @@ class InventoryInstanceRoleMachine(TypedDict):
InventoryInstanceRoleExtramodulesType = list[Unknown]
InventoryInstanceRoleMachinesType = dict[str, InventoryInstanceRoleMachine] InventoryInstanceRoleMachinesType = dict[str, InventoryInstanceRoleMachine]
InventoryInstanceRoleSettingsType = Unknown InventoryInstanceRoleSettingsType = Unknown
InventoryInstanceRoleTagsType = dict[str, Any] | list[str] InventoryInstanceRoleTagsType = dict[str, Any] | list[str]
class InventoryInstanceRole(TypedDict): class InventoryInstanceRole(TypedDict):
extraModules: NotRequired[InventoryInstanceRoleExtramodulesType]
machines: NotRequired[InventoryInstanceRoleMachinesType] machines: NotRequired[InventoryInstanceRoleMachinesType]
settings: NotRequired[InventoryInstanceRoleSettingsType] settings: NotRequired[InventoryInstanceRoleSettingsType]
tags: NotRequired[InventoryInstanceRoleTagsType] tags: NotRequired[InventoryInstanceRoleTagsType]

View File

@@ -5,7 +5,7 @@ from dataclasses import dataclass
from pathlib import Path from pathlib import Path
from typing import TYPE_CHECKING, Literal, TypedDict from typing import TYPE_CHECKING, Literal, TypedDict
from clan_lib.cmd import Log, RunOpts, run from clan_lib.cmd import RunOpts, run
from clan_lib.errors import ClanError from clan_lib.errors import ClanError
if TYPE_CHECKING: if TYPE_CHECKING:
@@ -70,7 +70,7 @@ class SystemdUserService:
"""Run systemctl command with --user flag.""" """Run systemctl command with --user flag."""
return run( return run(
["systemctl", "--user", action, f"{service_name}.service"], ["systemctl", "--user", action, f"{service_name}.service"],
RunOpts(check=False, log=Log.NONE), RunOpts(check=False),
) )
def _get_property(self, service_name: str, prop: str) -> str: def _get_property(self, service_name: str, prop: str) -> str:
@@ -240,15 +240,11 @@ class SystemdUserService:
service_name = self._service_name(name) service_name = self._service_name(name)
result = self._systemctl("stop", service_name) result = self._systemctl("stop", service_name)
if ( if result.returncode != 0 and "not loaded" not in result.stderr.lower():
result.returncode != 0
and "not loaded" not in result.stderr.lower()
and "does not exist" not in result.stderr.lower()
):
msg = f"Failed to stop service: {result.stderr}" msg = f"Failed to stop service: {result.stderr}"
raise ClanError(msg) raise ClanError(msg)
result = self._systemctl("disable", service_name) self._systemctl("disable", service_name) # Ignore errors for transient units
unit_file = self._unit_file_path(name) unit_file = self._unit_file_path(name)
if unit_file.exists(): if unit_file.exists():

View File

@@ -241,11 +241,6 @@ def generate_dataclass(
# If we are at the top level, and the attribute name is not explicitly included we only do shallow # If we are at the top level, and the attribute name is not explicitly included we only do shallow
field_name = prop.replace("-", "_") field_name = prop.replace("-", "_")
# Skip "extraModules"
# TODO: Introduce seperate model that is tied to the serialization format
if "extraModules" in field_name:
continue
# if len(attr_path) == 0 and prop in shallow_attrs: # if len(attr_path) == 0 and prop in shallow_attrs:
# field_def = field_name, "dict[str, Any]" # field_def = field_name, "dict[str, Any]"
# fields_with_default.append(field_def) # fields_with_default.append(field_def)

View File

@@ -64,9 +64,6 @@
''; '';
in in
{ {
legacyPackages = {
inherit jsonDocs clanModulesViaService;
};
packages = { packages = {
inherit module-docs; inherit module-docs;
}; };

View File

@@ -11,10 +11,151 @@
... ...
}: }:
let let
inherit (lib)
mapAttrsToList
mapAttrs
mkOption
types
splitString
stringLength
substring
;
inherit (self) clanLib;
serviceModules = self.clan.modules;
baseHref = "/option-search/"; baseHref = "/option-search/";
getRoles =
module:
(clanLib.evalService {
modules = [ module ];
prefix = [ ];
}).config.roles;
getManifest =
module:
(clanLib.evalService {
modules = [ module ];
prefix = [ ];
}).config.manifest;
settingsModules = module: mapAttrs (_roleName: roleConfig: roleConfig.interface) (getRoles module);
# Map each letter to its capitalized version # Map each letter to its capitalized version
capitalizeChar =
char:
{
a = "A";
b = "B";
c = "C";
d = "D";
e = "E";
f = "F";
g = "G";
h = "H";
i = "I";
j = "J";
k = "K";
l = "L";
m = "M";
n = "N";
o = "O";
p = "P";
q = "Q";
r = "R";
s = "S";
t = "T";
u = "U";
v = "V";
w = "W";
x = "X";
y = "Y";
z = "Z";
}
.${char};
title =
name:
let
# split by -
parts = splitString "-" name;
# capitalize first letter of each part
capitalize = part: (capitalizeChar (substring 0 1 part)) + substring 1 (stringLength part) part;
capitalizedParts = map capitalize parts;
in
builtins.concatStringsSep " " capitalizedParts;
fakeInstanceOptions =
name: module:
let
manifest = getManifest module;
description = ''
# ${title name} (Clan Service)
**${manifest.description}**
${lib.optionalString (manifest ? readme) manifest.readme}
${
if manifest.categories != [ ] then
"Categories: " + builtins.concatStringsSep ", " manifest.categories
else
"No categories defined"
}
'';
in
{
options = {
instances.${name} = lib.mkOption {
inherit description;
type = types.submodule {
options.roles = mapAttrs (
roleName: roleSettingsModule:
mkOption {
type = types.submodule {
_file = "docs flake-module";
imports = [
{ _module.args = { inherit clanLib; }; }
(import ../../modules/inventoryClass/role.nix {
nestedSettingsOption = mkOption {
type = types.raw;
description = ''
See [instances.${name}.roles.${roleName}.settings](${baseHref}?option_scope=0&option=inventory.instances.${name}.roles.${roleName}.settings)
'';
};
settingsOption = mkOption {
type = types.submoduleWith {
modules = [ roleSettingsModule ];
};
};
})
];
};
}
) (settingsModules module);
};
};
};
};
docModules = [
{
inherit self;
}
self.modules.clan.default
{
options.inventory = lib.mkOption {
type = types.submoduleWith {
modules = [
{ noInstanceOptions = true; }
]
++ mapAttrsToList fakeInstanceOptions serviceModules;
};
};
}
];
baseModule = baseModule =
# Module # Module
@@ -67,6 +208,12 @@
title = "Clan Options"; title = "Clan Options";
# scopes = mapAttrsToList mkScope serviceModules; # scopes = mapAttrsToList mkScope serviceModules;
scopes = [ scopes = [
{
inherit baseHref;
name = "Flake Options (clan.nix file)";
modules = docModules;
urlPrefix = "https://git.clan.lol/clan/clan-core/src/branch/main/";
}
{ {
name = "Machine Options (clan.core NixOS options)"; name = "Machine Options (clan.core NixOS options)";
optionsJSON = "${coreOptions}/share/doc/nixos/options.json"; optionsJSON = "${coreOptions}/share/doc/nixos/options.json";