Compare commits

..

1 Commits

Author SHA1 Message Date
Jörg Thalheim
58d771cba2 clan_lib/test_create: fix test when running outside of the sandbox... 2025-07-01 15:08:53 +02:00
197 changed files with 3049 additions and 6241 deletions

View File

@@ -22,6 +22,7 @@
dependencies = [
self
pkgs.stdenv.drvPath
self.clan.clanInternals.machines.${pkgs.hostPlatform.system}.test-backup.config.system.clan.deployment.file
] ++ builtins.map (i: i.outPath) (builtins.attrValues self.inputs);
closureInfo = pkgs.closureInfo { rootPaths = dependencies; };
in

View File

@@ -1,6 +1,6 @@
{ fetchgit }:
fetchgit {
url = "https://git.clan.lol/clan/clan-core.git";
rev = "eea93ea22c9818da67e148ba586277bab9e73cea";
sha256 = "sha256-PV0Z+97QuxQbkYSVuNIJwUNXMbHZG/vhsA9M4cDTCOE=";
rev = "28131afbbcd379a8ff04c79c66c670ef655ed889";
sha256 = "1294cwjlnc341fl6zbggn4rgq8z33gqkcyggjfvk9cf7zdgygrf6";
}

View File

@@ -50,6 +50,8 @@
self.nixosConfigurations."test-flash-machine-${pkgs.hostPlatform.system}".config.system.build.toplevel
self.nixosConfigurations."test-flash-machine-${pkgs.hostPlatform.system}".config.system.build.diskoScript
self.nixosConfigurations."test-flash-machine-${pkgs.hostPlatform.system}".config.system.build.diskoScript.drvPath
self.nixosConfigurations."test-flash-machine-${pkgs.hostPlatform.system}".config.system.clan.deployment.file
] ++ builtins.map (i: i.outPath) (builtins.attrValues self.inputs);
closureInfo = pkgs.closureInfo { rootPaths = dependencies; };
in

View File

@@ -1,9 +1,63 @@
{
self,
lib,
...
}:
let
installer =
{ modulesPath, pkgs, ... }:
let
dependencies = [
self.clan.clanInternals.machines.${pkgs.hostPlatform.system}.test-install-machine-with-system.config.system.build.toplevel
self.clan.clanInternals.machines.${pkgs.hostPlatform.system}.test-install-machine-with-system.config.system.build.diskoScript
self.clan.clanInternals.machines.${pkgs.hostPlatform.system}.test-install-machine-with-system.config.system.clan.deployment.file
pkgs.stdenv.drvPath
pkgs.bash.drvPath
pkgs.nixos-anywhere
pkgs.bubblewrap
pkgs.buildPackages.xorg.lndir
] ++ builtins.map (i: i.outPath) (builtins.attrValues self.inputs);
closureInfo = pkgs.closureInfo { rootPaths = dependencies; };
in
{
imports = [
(modulesPath + "/../tests/common/auto-format-root-device.nix")
];
networking.useNetworkd = true;
services.openssh.enable = true;
services.openssh.settings.UseDns = false;
services.openssh.settings.PasswordAuthentication = false;
system.nixos.variant_id = "installer";
environment.systemPackages = [
self.packages.${pkgs.system}.clan-cli-full
pkgs.nixos-facter
];
environment.etc."install-closure".source = "${closureInfo}/store-paths";
virtualisation.emptyDiskImages = [ 512 ];
virtualisation.diskSize = 8 * 1024;
virtualisation.rootDevice = "/dev/vdb";
# both installer and target need to use the same diskImage
virtualisation.diskImage = "./target.qcow2";
virtualisation.memorySize = 3048;
nix.settings = {
substituters = lib.mkForce [ ];
hashed-mirrors = null;
connect-timeout = lib.mkForce 3;
flake-registry = pkgs.writeText "flake-registry" ''{"flakes":[],"version":2}'';
experimental-features = [
"nix-command"
"flakes"
];
};
users.users.nonrootuser = {
isNormalUser = true;
openssh.authorizedKeys.keyFiles = [ ../assets/ssh/pubkey ];
extraGroups = [ "wheel" ];
};
security.sudo.wheelNeedsPassword = false;
system.extraDependencies = dependencies;
};
in
{
# The purpose of this test is to ensure `clan machines install` works
@@ -52,25 +106,6 @@
environment.etc."install-successful".text = "ok";
# Enable SSH and add authorized key for testing
services.openssh.enable = true;
services.openssh.settings.PasswordAuthentication = false;
users.users.nonrootuser = {
isNormalUser = true;
openssh.authorizedKeys.keys = [ (builtins.readFile ../assets/ssh/pubkey) ];
extraGroups = [ "wheel" ];
home = "/home/nonrootuser";
createHome = true;
};
users.users.root.openssh.authorizedKeys.keys = [ (builtins.readFile ../assets/ssh/pubkey) ];
# Allow users to manage their own SSH keys
services.openssh.authorizedKeysFiles = [
"/root/.ssh/authorized_keys"
"/home/%u/.ssh/authorized_keys"
"/etc/ssh/authorized_keys.d/%u"
];
security.sudo.wheelNeedsPassword = false;
boot.consoleLogLevel = lib.mkForce 100;
boot.kernelParams = [ "boot.shell_on_fail" ];
@@ -147,199 +182,55 @@
# vm-test-run-test-installation-> target: waiting for the VM to finish booting
# vm-test-run-test-installation-> target: Guest root shell did not produce any data yet...
# vm-test-run-test-installation-> target: To debug, enter the VM and run 'systemctl status backdoor.service'.
checks =
let
# Custom Python package for port management utilities
closureInfo = pkgs.closureInfo {
rootPaths = [
self.checks.x86_64-linux.clan-core-for-checks
self.clanInternals.machines.${pkgs.hostPlatform.system}.test-install-machine-with-system.config.system.build.toplevel
self.clanInternals.machines.${pkgs.hostPlatform.system}.test-install-machine-with-system.config.system.build.initialRamdisk
self.clanInternals.machines.${pkgs.hostPlatform.system}.test-install-machine-with-system.config.system.build.diskoScript
pkgs.stdenv.drvPath
pkgs.bash.drvPath
pkgs.buildPackages.xorg.lndir
] ++ builtins.map (i: i.outPath) (builtins.attrValues self.inputs);
checks = pkgs.lib.mkIf (pkgs.stdenv.isLinux && !pkgs.stdenv.isAarch64) {
nixos-test-installation = self.clanLib.test.baseTest {
name = "installation";
nodes.target = {
services.openssh.enable = true;
virtualisation.diskImage = "./target.qcow2";
virtualisation.useBootLoader = true;
};
in
pkgs.lib.mkIf (pkgs.stdenv.isLinux && !pkgs.stdenv.isAarch64) {
nixos-test-installation = self.clanLib.test.baseTest {
name = "installation";
nodes.target = (import ./test-helpers.nix { inherit lib pkgs self; }).target;
extraPythonPackages = _p: [
self.legacyPackages.${pkgs.system}.nixosTestLib
];
nodes.installer = installer;
testScript = ''
import tempfile
import os
import subprocess
from nixos_test_lib.ssh import setup_ssh_connection # type: ignore[import-untyped]
from nixos_test_lib.nix_setup import prepare_test_flake # type: ignore[import-untyped]
testScript = ''
installer.start()
def create_test_machine(oldmachine, qemu_test_bin: str, **kwargs):
"""Create a new test machine from an installed disk image"""
start_command = [
f"{qemu_test_bin}/bin/qemu-kvm",
"-cpu",
"max",
"-m",
"3048",
"-virtfs",
"local,path=/nix/store,security_model=none,mount_tag=nix-store",
"-drive",
f"file={oldmachine.state_dir}/target.qcow2,id=drive1,if=none,index=1,werror=report",
"-device",
"virtio-blk-pci,drive=drive1",
"-netdev",
"user,id=net0",
"-device",
"virtio-net-pci,netdev=net0",
]
machine = create_machine(start_command=" ".join(start_command), **kwargs)
driver.machines.append(machine)
return machine
installer.succeed("${pkgs.coreutils}/bin/install -Dm 600 ${../assets/ssh/privkey} /root/.ssh/id_ed25519")
target.start()
installer.wait_until_succeeds("timeout 2 ssh -o StrictHostKeyChecking=accept-new -v nonrootuser@localhost hostname")
installer.succeed("cp -r ${self.checks.x86_64-linux.clan-core-for-checks} test-flake && chmod -R +w test-flake")
# Set up test environment
with tempfile.TemporaryDirectory() as temp_dir:
# Prepare test flake and Nix store
flake_dir = prepare_test_flake(
temp_dir,
"${self.checks.x86_64-linux.clan-core-for-checks}",
"${closureInfo}"
)
installer.succeed("clan machines install --no-reboot --debug --flake test-flake --yes test-install-machine-without-system --target-host nonrootuser@localhost --update-hardware-config nixos-facter >&2")
installer.shutdown()
# Set up SSH connection
ssh_conn = setup_ssh_connection(
target,
temp_dir,
"${../assets/ssh/privkey}"
)
# We are missing the test instrumentation somehow. Test this later.
target.state_dir = installer.state_dir
target.start()
target.wait_for_unit("multi-user.target")
'';
} { inherit pkgs self; };
# Run clan install from host using port forwarding
clan_cmd = [
"${self.packages.${pkgs.system}.clan-cli-full}/bin/clan",
"machines",
"install",
"--phases", "disko,install",
"--debug",
"--flake", flake_dir,
"--yes", "test-install-machine-without-system",
"--target-host", f"nonrootuser@localhost:{ssh_conn.host_port}",
"-i", ssh_conn.ssh_key,
"--option", "store", os.environ['CLAN_TEST_STORE'],
"--update-hardware-config", "nixos-facter",
]
nixos-test-update-hardware-configuration = self.clanLib.test.baseTest {
name = "update-hardware-configuration";
nodes.installer = installer;
subprocess.run(clan_cmd, check=True)
testScript = ''
installer.start()
installer.succeed("${pkgs.coreutils}/bin/install -Dm 600 ${../assets/ssh/privkey} /root/.ssh/id_ed25519")
installer.wait_until_succeeds("timeout 2 ssh -o StrictHostKeyChecking=accept-new -v nonrootuser@localhost hostname")
installer.succeed("cp -r ${self.checks.x86_64-linux.clan-core-for-checks} test-flake && chmod -R +w test-flake")
installer.fail("test -f test-flake/machines/test-install-machine/hardware-configuration.nix")
installer.fail("test -f test-flake/machines/test-install-machine/facter.json")
# Shutdown the installer machine gracefully
try:
target.shutdown()
except BrokenPipeError:
# qemu has already exited
pass
installer.succeed("clan machines update-hardware-config --debug --flake test-flake test-install-machine-without-system nonrootuser@localhost >&2")
installer.succeed("test -f test-flake/machines/test-install-machine-without-system/facter.json")
installer.succeed("rm test-flake/machines/test-install-machine-without-system/facter.json")
# Create a new machine instance that boots from the installed system
installed_machine = create_test_machine(target, "${pkgs.qemu_test}", name="after_install")
installed_machine.start()
installed_machine.wait_for_unit("multi-user.target")
installed_machine.succeed("test -f /etc/install-successful")
'';
} { inherit pkgs self; };
nixos-test-update-hardware-configuration = self.clanLib.test.baseTest {
name = "update-hardware-configuration";
nodes.target = (import ./test-helpers.nix { inherit lib pkgs self; }).target;
extraPythonPackages = _p: [
self.legacyPackages.${pkgs.system}.nixosTestLib
];
testScript = ''
import tempfile
import os
import subprocess
from nixos_test_lib.ssh import setup_ssh_connection # type: ignore[import-untyped]
from nixos_test_lib.nix_setup import prepare_test_flake # type: ignore[import-untyped]
target.start()
# Set up test environment
with tempfile.TemporaryDirectory() as temp_dir:
# Prepare test flake and Nix store
flake_dir = prepare_test_flake(
temp_dir,
"${self.checks.x86_64-linux.clan-core-for-checks}",
"${closureInfo}"
)
# Set up SSH connection
ssh_conn = setup_ssh_connection(
target,
temp_dir,
"${../assets/ssh/privkey}"
)
# Verify files don't exist initially
hw_config_file = os.path.join(flake_dir, "machines/test-install-machine/hardware-configuration.nix")
facter_file = os.path.join(flake_dir, "machines/test-install-machine/facter.json")
assert not os.path.exists(hw_config_file), "hardware-configuration.nix should not exist initially"
assert not os.path.exists(facter_file), "facter.json should not exist initially"
# Set CLAN_FLAKE for the commands
os.environ["CLAN_FLAKE"] = flake_dir
# Test facter backend
clan_cmd = [
"${self.packages.${pkgs.system}.clan-cli-full}/bin/clan",
"machines",
"update-hardware-config",
"--debug",
"--flake", ".",
"--host-key-check", "none",
"test-install-machine-without-system",
"-i", ssh_conn.ssh_key,
"--option", "store", os.environ['CLAN_TEST_STORE'],
f"nonrootuser@localhost:{ssh_conn.host_port}"
]
result = subprocess.run(clan_cmd, capture_output=True, cwd=flake_dir)
if result.returncode != 0:
print(f"Clan update-hardware-config failed: {result.stderr.decode()}")
raise Exception(f"Clan update-hardware-config failed with return code {result.returncode}")
facter_without_system_file = os.path.join(flake_dir, "machines/test-install-machine-without-system/facter.json")
assert os.path.exists(facter_without_system_file), "facter.json should exist after update"
os.remove(facter_without_system_file)
# Test nixos-generate-config backend
clan_cmd = [
"${self.packages.${pkgs.system}.clan-cli-full}/bin/clan",
"machines",
"update-hardware-config",
"--debug",
"--backend", "nixos-generate-config",
"--host-key-check", "none",
"--flake", ".",
"test-install-machine-without-system",
"-i", ssh_conn.ssh_key,
"--option", "store", os.environ['CLAN_TEST_STORE'],
f"nonrootuser@localhost:{ssh_conn.host_port}"
]
result = subprocess.run(clan_cmd, capture_output=True, cwd=flake_dir)
if result.returncode != 0:
print(f"Clan update-hardware-config (nixos-generate-config) failed: {result.stderr.decode()}")
raise Exception(f"Clan update-hardware-config failed with return code {result.returncode}")
hw_config_without_system_file = os.path.join(flake_dir, "machines/test-install-machine-without-system/hardware-configuration.nix")
assert os.path.exists(hw_config_without_system_file), "hardware-configuration.nix should exist after update"
'';
} { inherit pkgs self; };
};
installer.succeed("clan machines update-hardware-config --debug --backend nixos-generate-config --flake test-flake test-install-machine-without-system nonrootuser@localhost >&2")
installer.succeed("test -f test-flake/machines/test-install-machine-without-system/hardware-configuration.nix")
installer.succeed("rm test-flake/machines/test-install-machine-without-system/hardware-configuration.nix")
'';
} { inherit pkgs self; };
};
};
}

View File

@@ -1,44 +0,0 @@
[build-system]
requires = ["setuptools", "wheel"]
build-backend = "setuptools.build_meta"
[project]
name = "nixos-test-lib"
version = "1.0.0"
description = "NixOS test utilities for clan VM testing"
authors = [
{name = "Clan Core Team"}
]
dependencies = []
[project.optional-dependencies]
dev = [
"mypy",
"ruff"
]
[tool.setuptools.packages.find]
where = ["."]
include = ["nixos_test_lib*"]
[tool.setuptools.package-data]
"nixos_test_lib" = ["py.typed"]
[tool.mypy]
python_version = "3.12"
strict = true
warn_return_any = true
warn_unused_configs = true
[tool.ruff]
target-version = "py312"
line-length = 88
[tool.ruff.lint]
select = ["ALL"]
ignore = [
"D", # docstrings
"ANN", # type annotations
"COM812", # trailing comma
"ISC001", # string concatenation
]

View File

@@ -1,173 +0,0 @@
{
lib,
pkgs,
self,
...
}:
let
# Common target VM configuration used by both installation and update tests
target =
{ modulesPath, pkgs, ... }:
{
imports = [
(modulesPath + "/../tests/common/auto-format-root-device.nix")
];
networking.useNetworkd = true;
services.openssh.enable = true;
services.openssh.settings.UseDns = false;
services.openssh.settings.PasswordAuthentication = false;
system.nixos.variant_id = "installer";
environment.systemPackages = [
pkgs.nixos-facter
];
# Disable cache.nixos.org to speed up tests
nix.settings.substituters = [ ];
nix.settings.trusted-public-keys = [ ];
virtualisation.emptyDiskImages = [ 512 ];
virtualisation.diskSize = 8 * 1024;
virtualisation.rootDevice = "/dev/vdb";
# both installer and target need to use the same diskImage
virtualisation.diskImage = "./target.qcow2";
virtualisation.memorySize = 3048;
users.users.nonrootuser = {
isNormalUser = true;
openssh.authorizedKeys.keys = [ (builtins.readFile ../assets/ssh/pubkey) ];
extraGroups = [ "wheel" ];
};
users.users.root.openssh.authorizedKeys.keys = [ (builtins.readFile ../assets/ssh/pubkey) ];
# Allow users to manage their own SSH keys
services.openssh.authorizedKeysFiles = [
"/root/.ssh/authorized_keys"
"/home/%u/.ssh/authorized_keys"
"/etc/ssh/authorized_keys.d/%u"
];
security.sudo.wheelNeedsPassword = false;
};
# Common base test machine configuration
baseTestMachine =
{ lib, modulesPath, ... }:
{
imports = [
(modulesPath + "/testing/test-instrumentation.nix")
(modulesPath + "/profiles/qemu-guest.nix")
self.clanLib.test.minifyModule
];
# Enable SSH and add authorized key for testing
services.openssh.enable = true;
services.openssh.settings.PasswordAuthentication = false;
users.users.nonrootuser = {
isNormalUser = true;
openssh.authorizedKeys.keys = [ (builtins.readFile ../assets/ssh/pubkey) ];
extraGroups = [ "wheel" ];
home = "/home/nonrootuser";
createHome = true;
};
users.users.root.openssh.authorizedKeys.keys = [ (builtins.readFile ../assets/ssh/pubkey) ];
# Allow users to manage their own SSH keys
services.openssh.authorizedKeysFiles = [
"/root/.ssh/authorized_keys"
"/home/%u/.ssh/authorized_keys"
"/etc/ssh/authorized_keys.d/%u"
];
security.sudo.wheelNeedsPassword = false;
boot.consoleLogLevel = lib.mkForce 100;
boot.kernelParams = [ "boot.shell_on_fail" ];
# disko config
boot.loader.grub.efiSupport = lib.mkDefault true;
boot.loader.grub.efiInstallAsRemovable = lib.mkDefault true;
clan.core.vars.settings.secretStore = "vm";
clan.core.vars.generators.test = {
files.test.neededFor = "partitioning";
script = ''
echo "notok" > "$out"/test
'';
};
disko.devices = {
disk = {
main = {
type = "disk";
device = "/dev/vda";
preCreateHook = ''
test -e /run/partitioning-secrets/test/test
'';
content = {
type = "gpt";
partitions = {
boot = {
size = "1M";
type = "EF02"; # for grub MBR
priority = 1;
};
ESP = {
size = "512M";
type = "EF00";
content = {
type = "filesystem";
format = "vfat";
mountpoint = "/boot";
mountOptions = [ "umask=0077" ];
};
};
root = {
size = "100%";
content = {
type = "filesystem";
format = "ext4";
mountpoint = "/";
};
};
};
};
};
};
};
};
# NixOS test library combining port utils and clan VM test utilities
nixosTestLib = pkgs.python3Packages.buildPythonPackage {
pname = "nixos-test-lib";
version = "1.0.0";
format = "pyproject";
src = lib.fileset.toSource {
root = ./.;
fileset = lib.fileset.unions [
./pyproject.toml
./nixos_test_lib
];
};
nativeBuildInputs = with pkgs.python3Packages; [
setuptools
wheel
];
doCheck = false;
};
# Common closure info
closureInfo = pkgs.closureInfo {
rootPaths = [
self.checks.x86_64-linux.clan-core-for-checks
self.clanInternals.machines.${pkgs.hostPlatform.system}.test-install-machine-with-system.config.system.build.toplevel
self.clanInternals.machines.${pkgs.hostPlatform.system}.test-install-machine-with-system.config.system.build.initialRamdisk
self.clanInternals.machines.${pkgs.hostPlatform.system}.test-install-machine-with-system.config.system.build.diskoScript
self.clanInternals.machines.${pkgs.hostPlatform.system}.test-install-machine-with-system.config.system.clan.deployment.file
pkgs.stdenv.drvPath
pkgs.bash.drvPath
pkgs.buildPackages.xorg.lndir
] ++ builtins.map (i: i.outPath) (builtins.attrValues self.inputs);
};
in
{
inherit
target
baseTestMachine
nixosTestLib
closureInfo
;
}

View File

@@ -35,6 +35,7 @@
pkgs.stdenv.drvPath
pkgs.stdenvNoCC
self.nixosConfigurations.test-morph-machine.config.system.build.toplevel
self.nixosConfigurations.test-morph-machine.config.system.clan.deployment.file
] ++ builtins.map (i: i.outPath) (builtins.attrValues self.inputs);
closureInfo = pkgs.closureInfo { rootPaths = dependencies; };
in

View File

@@ -23,14 +23,14 @@ nixosLib.runTest (
clan.test.fromFlake = ./.;
extraPythonPackages = _p: [
clan-core.legacyPackages.${hostPkgs.system}.nixosTestLib
clan-core.legacyPackages.${hostPkgs.system}.setupNixInNixPythonPackage
];
testScript =
{ nodes, ... }:
''
from nixos_test_lib.nix_setup import setup_nix_in_nix # type: ignore[import-untyped]
setup_nix_in_nix(None) # No closure info for this test
from setup_nix_in_nix import setup_nix_in_nix # type: ignore[import-untyped]
setup_nix_in_nix()
def run_clan(cmd: list[str], **kwargs) -> str:
import subprocess

View File

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

View File

@@ -33,7 +33,6 @@ in
root-password = ./root-password;
single-disk = ./single-disk;
sshd = ./sshd;
state-version = ./state-version;
static-hosts = ./static-hosts;
sunshine = ./sunshine;
syncthing = ./syncthing;

View File

@@ -1,18 +0,0 @@
---
description = "Automatically generate the state version of the nixos installation."
features = [ "inventory", "deprecated" ]
---
This module generates the `system.stateVersion` of the nixos installation automatically.
Options: [system.stateVersion](https://search.nixos.org/options?channel=unstable&show=system.stateVersion&from=0&size=50&sort=relevance&type=packages&query=stateVersion)
Migration:
If you are already setting `system.stateVersion`, then import the module and then either let the automatic generation happen, or trigger the generation manually for the machine. The module will take the specified version, if one is already supplied through the config.
To manually generate the version for a specified machine run:
```
clan vars generate [MACHINE]
```
If the setting was already set you can then remove `system.stateVersion` from your machine configuration. For new machines, just import the module.

View File

@@ -1,6 +0,0 @@
# Dont import this file
# It is only here for backwards compatibility.
# Dont author new modules with this file.
{
imports = [ ./roles/default.nix ];
}

View File

@@ -1,28 +0,0 @@
{ config, lib, ... }:
let
var = config.clan.core.vars.generators.state-version.files.version or { };
in
{
warnings = [
''
The clan.state-version service is deprecated and will be
removed on 2025-07-15 in favor of a nix option.
Please migrate your configuration to use `clan.core.settings.state-version.enable = true` instead.
''
];
system.stateVersion = lib.mkDefault (lib.removeSuffix "\n" var.value);
clan.core.vars.generators.state-version = {
files.version = {
secret = false;
value = lib.mkDefault config.system.nixos.release;
};
runtimeInputs = [ ];
script = ''
echo -n ${config.system.stateVersion} > "$out"/version
'';
};
}

View File

@@ -1,37 +0,0 @@
This service generates the `system.stateVersion` of the nixos installation
automatically.
Possible values:
[system.stateVersion](https://search.nixos.org/options?channel=unstable&show=system.stateVersion&from=0&size=50&sort=relevance&type=packages&query=stateVersion)
## Usage
The following configuration will set `stateVersion` for all machines:
```
inventory.instances = {
state-version = {
module = {
name = "state-version";
input = "clan";
};
roles.default.tags.all = { };
};
```
## Migration
If you are already setting `system.stateVersion`, either let the automatic
generation happen, or trigger the generation manually for the machine. The
service will take the specified version, if one is already supplied through the
config.
To manually generate the version for a specified machine run:
```
clan vars generate [MACHINE]
```
If the setting was already set, you can then remove `system.stateVersion` from
your machine configuration. For new machines, just import the service as shown
above.

View File

@@ -1,49 +0,0 @@
{ ... }:
{
_class = "clan.service";
manifest.name = "clan-core/state-version";
manifest.description = "Automatically generate the state version of the nixos installation.";
manifest.categories = [ "System" ];
roles.default = {
perInstance =
{ ... }:
{
nixosModule =
{
config,
lib,
...
}:
let
var = config.clan.core.vars.generators.state-version.files.version or { };
in
{
warnings = [
''
The clan.state-version service is deprecated and will be
removed on 2025-07-15 in favor of a nix option.
Please migrate your configuration to use `clan.core.settings.state-version.enable = true` instead.
''
];
system.stateVersion = lib.mkDefault (lib.removeSuffix "\n" var.value);
clan.core.vars.generators.state-version = {
files.version = {
secret = false;
value = lib.mkDefault config.system.nixos.release;
};
runtimeInputs = [ ];
script = ''
echo -n ${config.system.stateVersion} > "$out"/version
'';
};
};
};
};
}

View File

@@ -1,16 +0,0 @@
{ lib, ... }:
let
module = lib.modules.importApply ./default.nix { };
in
{
clan.modules.state-version = module;
perSystem =
{ ... }:
{
clan.nixosTests.state-version = {
imports = [ ./tests/vm/default.nix ];
clan.modules."@clan/state-version" = module;
};
};
}

View File

@@ -1,22 +0,0 @@
{ lib, ... }:
{
name = "service-state-version";
clan = {
directory = ./.;
inventory = {
machines.server = { };
instances.default = {
module.name = "@clan/state-version";
module.input = "self";
roles.default.machines."server" = { };
};
};
};
nodes.server = { };
testScript = lib.mkDefault ''
start_all()
'';
}

View File

@@ -1,4 +0,0 @@
{
"publickey": "age1qm0p4vf9jvcnn43s6l4prk8zn6cx0ep9gzvevxecv729xz540v8qa742eg",
"type": "age"
}

View File

@@ -73,10 +73,9 @@ in
];
networking.networkmanager.ensureProfiles.profiles = flip mapAttrs settings.networks (
name: networkCfg: {
name: _network: {
connection.id = "$ssid_${name}";
connection.type = "wifi";
connection.autoconnect = networkCfg.autoConnect;
wifi.mode = "infrastructure";
wifi.ssid = "$ssid_${name}";
wifi-security.psk = "$pw_${name}";
@@ -103,7 +102,7 @@ in
# Generate the secrets file
echo "Generating wifi secrets file: $env_file"
${flip (concatMapAttrsStringSep "\n") settings.networks (
name: _networkCfg: ''
name: _network: ''
echo "ssid_${name}=\"$(cat "${ssid_path name}")\"" >> /run/secrets/NetworkManager/wifi-secrets
echo "pw_${name}=\"$(cat "${password_path name}")\"" >> /run/secrets/NetworkManager/wifi-secrets
''

1
docs/.gitignore vendored
View File

@@ -1,5 +1,4 @@
/site/reference
/site/static
/site/options-page
/site/openapi.json
!/site/static/extra.css

View File

@@ -48,13 +48,13 @@ nav:
- Home: index.md
- Guides:
- Getting Started:
- 🚀 Creating Your First Clan: guides/getting-started/index.md
- 📀 Create USB Installer (optional): guides/getting-started/installer.md
- ⚙️ Add Machines: guides/getting-started/add-machines.md
- ⚙️ Add Services: guides/getting-started/add-services.md
- 🔐 Secrets & Facts: guides/getting-started/secrets.md
- 🚢 Deploy Machine: guides/getting-started/deploy.md
- 🧪 Continuous Integration: guides/getting-started/check.md
- Creating Your First Clan: guides/getting-started/index.md
- Create USB Installer (optional): guides/getting-started/installer.md
- Add Machines: guides/getting-started/add-machines.md
- Add Services: guides/getting-started/add-services.md
- Secrets & Facts: guides/getting-started/secrets.md
- Deploy Machine: guides/getting-started/deploy.md
- Continuous Integration: guides/getting-started/check.md
- clanServices: guides/clanServices.md
- Disk Encryption: guides/disk-encryption.md
- Mesh VPN: guides/mesh-vpn.md
@@ -92,7 +92,6 @@ nav:
- reference/clanServices/mycelium.md
- reference/clanServices/packages.md
- reference/clanServices/sshd.md
- reference/clanServices/state-version.md
- reference/clanServices/trusted-nix-caches.md
- reference/clanServices/users.md
- reference/clanServices/wifi.md
@@ -127,7 +126,6 @@ nav:
- reference/clanModules/root-password.md
- reference/clanModules/single-disk.md
- reference/clanModules/sshd.md
- reference/clanModules/state-version.md
- reference/clanModules/static-hosts.md
- reference/clanModules/sunshine.md
- reference/clanModules/syncthing-static-peers.md
@@ -181,9 +179,6 @@ nav:
- 05-deployment-parameters: decisions/05-deployment-parameters.md
- Template: decisions/_template.md
- Options: options.md
- Developer:
- Introduction: intern/index.md
- API: intern/api.md
docs_dir: site
site_dir: out
@@ -241,4 +236,3 @@ extra:
plugins:
- search
- macros
- redoc-tag

View File

@@ -3,7 +3,6 @@
pkgs,
module-docs,
clan-cli-docs,
clan-lib-openapi,
asciinema-player-js,
asciinema-player-css,
roboto,
@@ -30,7 +29,6 @@ pkgs.stdenv.mkDerivation {
mkdocs
mkdocs-material
mkdocs-macros
mkdocs-redoc-tag
]);
configurePhase = ''
pushd docs
@@ -38,10 +36,6 @@ pkgs.stdenv.mkDerivation {
mkdir -p ./site/reference/cli
cp -af ${module-docs}/* ./site/reference/
cp -af ${clan-cli-docs}/* ./site/reference/cli/
mkdir -p ./site/reference/internal
cp -af ${clan-lib-openapi} ./site/openapi.json
chmod -R +w ./site/reference
echo "Generated API documentation in './site/reference/' "

View File

@@ -127,12 +127,7 @@
packages = {
docs = pkgs.python3.pkgs.callPackage ./default.nix {
clan-core = self;
inherit (self'.packages)
clan-cli-docs
docs-options
inventory-api-docs
clan-lib-openapi
;
inherit (self'.packages) clan-cli-docs docs-options inventory-api-docs;
inherit (inputs) nixpkgs;
inherit module-docs;
inherit asciinema-player-js;

View File

@@ -17,10 +17,8 @@ For example:
```nix
inventory.instances = {
borgbackup = {
roles.client.machines."laptop" = {};
roles.client.machines."server1" = {};
roles.server.machines."backup-box" = {};
roles.client.machines = [ "laptop" "server1" ];
roles.server.machines = [ "backup-box" ];
};
}
```
@@ -42,8 +40,7 @@ Example of instantiating a `borgbackup` service using `clan-core`:
```nix
inventory.instances = {
# Instance Name: Different name for this 'borgbackup' instance
borgbackup = {
# Since this is instances."borgbackup" the whole `module = { ... }` below is equivalent and optional.
borgbackup-example = {
module = {
name = "borgbackup"; # <-- Name of the module (optional)
input = "clan-core"; # <-- The flake input where the service is defined (optional)

View File

@@ -63,7 +63,8 @@ Replace `kernelModules` with the ethernet module loaded one on your target machi
}
```
## Copying SSH Public Key
### Step 1: Copying SSH Public Key
Before starting the installation process, ensure that the SSH public key is copied to the NixOS installer.
@@ -73,7 +74,7 @@ Before starting the installation process, ensure that the SSH public key is copi
ssh-copy-id -o PreferredAuthentications=password -o PubkeyAuthentication=no root@nixos-installer.local
```
## Prepare Secret Key and Partition Disks
### Step 1.5: Prepare Secret Key and Partition Disks
1. Access the installer using SSH:
@@ -99,7 +100,7 @@ blkdiscard /dev/disk/by-id/<installdisk>
clan machines install gchq-local --target-host root@nixos-installer --phases kexec,disko
```
## ZFS Pool Import and System Installation
### Step 2: ZFS Pool Import and System Installation
1. SSH into the installer once again:
@@ -150,7 +151,7 @@ zpool export zroot
8. Perform a reboot of the machine and remove the USB installer.
## Accessing the Initial Ramdisk (initrd) Environment
### Step 3: Accessing the Initial Ramdisk (initrd) Environment
1. SSH into the initrd environment using the `initrd_rsa_key` and provided port:

View File

@@ -3,7 +3,7 @@ Clan supports integration with [flake-parts](https://flake.parts/), a framework
To construct your Clan using flake-parts, follow these steps:
## Update Your Flake Inputs
## 1. Update Your Flake Inputs
To begin, you'll need to add `flake-parts` as a new dependency in your flake's inputs. This is alongside the already existing dependencies, such as `clan-core` and `nixpkgs`. Here's how you can update your `flake.nix` file:
@@ -25,7 +25,7 @@ inputs = {
}
```
## Import the Clan flake-parts Module
## 2. Import the Clan flake-parts Module
After updating your flake inputs, the next step is to import the Clan flake-parts module. This will make the [Clan options](../reference/nix-api/clan.md) available within `mkFlake`.
@@ -43,7 +43,7 @@ After updating your flake inputs, the next step is to import the Clan flake-part
}
```
## Configure Clan Settings and Define Machines
### 3. Configure Clan Settings and Define Machines
Next you'll need to configure Clan wide settings and define machines, here's an example of how `flake.nix` should look:
@@ -91,6 +91,6 @@ Next you'll need to configure Clan wide settings and define machines, here's an
```
For detailed information about configuring `flake-parts` and the available options within Clan,
refer to the [Clan module](https://git.clan.lol/clan/clan-core/src/branch/main/flakeModules/clan.nix) documentation.
refer to the Clan module documentation located [here](https://git.clan.lol/clan/clan-core/src/branch/main/flakeModules/clan.nix).
---

View File

@@ -119,34 +119,26 @@ clan = {
1. It is required to define a *targetHost* for each machine before deploying. Best practice has been, to use the zerotier ip/hostname or the ip from the from overlay network you decided to use.
2. Add your *ssh key* here - That will ensure you can always login to your machine via *ssh* in case something goes wrong.
### (Optional) Renaming a Machine
### (Optional): Renaming Machine
Older templates included static machine folders like `jon` and `sara`.
If your setup still uses such static machines, you can rename a machine folder to match your own machine name:
For renaming jon to your own machine name, you can use the following command:
```bash
git mv ./machines/jon ./machines/<your-machine-name>
```
git mv ./machines/jon ./machines/newname
```
Since your Clan configuration lives inside a Git repository, remember:
Note that our clan lives inside a git repository.
Only files that have been added with `git add` are recognized by `nix`.
So for every file that you add or rename you also need to run:
* Only files tracked by Git (`git add`) are recognized.
* Whenever you add, rename, or remove files, run:
```bash
git add ./machines/<your-machine-name>
```
git add ./path/to/my/file
```
to stage the changes.
### (Optional): Removing a Machine
---
If you only want to setup a single machine at this point, you can delete `sara` from `flake.nix` as well as from the machines directory:
### (Optional) Removing a Machine
If you want to work with a single machine for now, you can remove other machine entries both from your `flake.nix` and from the `machines` directory. For example, to remove the machine `sara`:
```bash
```
git rm -rf ./machines/sara
```
Make sure to also remove or update any references to that machine in your `nix files` or `inventory.json` if you have any of that

View File

@@ -27,7 +27,7 @@ Now that you have created a new machine, we will walk through how to install it.
!!! Warning "NixOS can cause strange issues when booting in certain cloud environments."
If on Linode: Make sure that the system uses Direct Disk boot kernel (found in the configuration pannel)
## Setting `targetHost`
### Step 1. Setting `targetHost`
=== "flake.nix (flake-parts)"
@@ -98,7 +98,7 @@ Now that you have created a new machine, we will walk through how to install it.
The use of `root@` in the target address implies SSH access as the `root` user.
Ensure that the root login is secured and only used when necessary.
## Identify the Target Disk
### Step 2. Identify the Target Disk
On the setup computer, SSH into the target:
@@ -129,7 +129,7 @@ In this example we would copy `nvme-eui.e8238fa6bf530001001b448b4aec2929`
!!! tip
For advanced partitioning, see [Disko templates](https://github.com/nix-community/disko-templates) or [Disko examples](https://github.com/nix-community/disko/tree/master/example).
## Fill in hardware specific machine configuration
### Step 3. Fill in hardware specific machine configuration
Edit the following fields inside the `./machines/<machine_name>/configuration.nix`
@@ -164,7 +164,7 @@ Edit the following fields inside the `./machines/<machine_name>/configuration.ni
!!! Info "Replace `__CHANGE_ME__` with the appropriate `ID-LINK` identifier, such as `nvme-eui.e8238fa6bf530001001b448b4aec2929`"
!!! Info "Replace `__YOUR_SSH_KEY__` with your personal key, like `ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAILoMI0NC5eT9pHlQExrvR5ASV3iW9+BXwhfchq0smXUJ jon@jon-desktop`"
## Deploy the machine
### Step 4. Deploy the machine
**Finally deployment time!** Use the following command to build and deploy the image via SSH onto your machine.
@@ -227,7 +227,7 @@ Edit the following fields inside the `./machines/<machine_name>/configuration.ni
```
2. The root password for the installer medium.
This password is autogenerated and meant to be easily typeable.
3. See [how to connect to wlan](./installer.md#optional-connect-to-wifi-manually).
3. See how to connect the installer medium to wlan [here](./installer.md#optional-connect-to-wifi-manually).
!!! tip
Use [KDE Connect](https://apps.kde.org/de/kdeconnect/) for easyily sharing QR codes from phone to desktop
@@ -236,21 +236,21 @@ Edit the following fields inside the `./machines/<machine_name>/configuration.ni
Just run the command **Option B: Cloud VM** below
### Deployment Commands
#### Deployment Commands
#### Using password auth
##### Using password auth
```bash
clan machines install [MACHINE] --target-host <IP> --update-hardware-config nixos-facter
```
#### Using QR JSON
##### Using QR JSON
```bash
clan machines install [MACHINE] --json "[JSON]" --update-hardware-config nixos-facter
```
#### Using QR image file
##### Using QR image file
```bash
clan machines install [MACHINE] --png [PATH] --update-hardware-config nixos-facter

View File

@@ -4,7 +4,8 @@ Ready to create your own Clan and manage a fleet of machines? Follow these simpl
By the end of this guide, you'll have a fresh NixOS configuration ready to push to one or more machines. You'll create a new Git repository and a flake, and all you need is at least one machine to push to. This is the easiest way to begin, and we recommend you to copy your existing configuration into this new setup!
## Prerequisites
### Prerequisites
=== "**Linux**"
@@ -36,23 +37,22 @@ By the end of this guide, you'll have a fresh NixOS configuration ready to push
If you have previously installed Nix, make sure `experimental-features = nix-command flakes` is present in `~/.config/nix/nix.conf` or `/etc/nix/nix.conf`. If this is not the case, please add it to `~/.config/nix/nix.conf`.
## Add Clan CLI to Your Shell
### Step 1: Add Clan CLI to Your Shell
Add the Clan CLI into your environment:
Add the Clan CLI into your development workflow:
```bash
nix shell git+https://git.clan.lol/clan/clan-core#clan-cli --refresh
```
You can find reference documentation for the `clan` CLI program [here](../../reference/cli/index.md).
Alternatively you can check out the help pages directly:
```terminalSession
clan --help
```
Should print the avilable commands.
Also checkout the [cli-reference documentation](../../reference/cli/index.md).
## Initialize Your Project
### Step 2: Initialize Your Project
If you want to migrate an existing project, follow this [guide](../migrations/migration-guide.md).
@@ -62,28 +62,35 @@ Set the foundation of your Clan project by initializing it by running:
clan flakes create my-clan
```
This command creates a `flake.nix` and some other files for your project.
This command creates the `flake.nix` and `.clan-flake` files for your project.
It will also generate files from a default template, to help show general clan usage patterns.
## Explore the Project Structure
### Step 3: Verify the Project Structure
Take a lookg at all project files:
Ensure that all project files exist by running:
```bash
cd my-clan
tree
```
For example, you might see something like:
This should yield the following:
``` { .console .no-copy }
.
├── flake.nix
├── machines/
├── modules/
└── README.md
```
├── machines
│   ├── jon
│   │   ├── configuration.nix
│   │   └── hardware-configuration.nix
│   └── sara
│   ├── configuration.nix
│   └── hardware-configuration.nix
└── modules
└── shared.nix
Dont worry if your output looks different—the template evolves over time.
5 directories, 9 files
```
??? info "Recommended way of sourcing the `clan` CLI tool"
@@ -102,23 +109,17 @@ Dont worry if your output looks different—the template evolves over time.
To automatically add the `clan` CLI tool to your environment without having to
run `nix develop` every time, we recommend setting up [direnv](https://direnv.net/).
```
```bash
clan machines list
```
If you see no output yet, thats expected — [add machines](./add-machines.md) to populate it.
``` { .console .no-copy }
jon
sara
```
---
!!! success
## Next Steps
You just successfully bootstrapped your first Clan.
You can continue with **any** of the following steps at your own pace:
- [x] [Install Nix & Clan CLI](./index.md)
- [x] [Initialize Clan](./index.md#initialize-your-project)
- [ ] [Create USB Installer (optional)](./installer.md)
- [ ] [Add Machines](./add-machines.md)
- [ ] [Add Services](./add-services.md)
- [ ] [Configure Secrets](./secrets.md)
- [ ] [Deploy](./deploy.md) - Requires configured secrets
- [ ] [Setup CI (optional)](./check.md)

View File

@@ -11,12 +11,13 @@ To install Clan on physical machines, you need to use our custom installer image
??? info "Reasons for a Custom Install Image"
Our custom install images are built to include essential tools like [nixos-facter](https://github.com/nix-community/nixos-facter) and support for [ZFS](https://wiki.archlinux.org/title/ZFS). They're also optimized to run on systems with as little as 1 GB of RAM, ensuring efficient performance even on lower-end hardware.
## Prerequisites
### Step 0. Prerequisites
- [x] A free USB Drive with at least 1.5GB (All data on it will be lost)
- [x] Linux/NixOS Machine with Internet
## Identify the USB Flash Drive
### Step 1. Identify the USB Flash Drive
1. Insert your USB flash drive into your computer.
@@ -44,7 +45,7 @@ To install Clan on physical machines, you need to use our custom installer image
sudo umount /dev/sdb1
```
## Installer
### Step 2. Installer
=== "**Linux OS**"
**Create a Custom Installer**
@@ -117,7 +118,7 @@ sudo umount /dev/sdb1
!!! Note
If you don't have `wget` installed, you can use `curl --progress-bar -OL <url>` instead.
## Flash the Installer to the USB Drive
### Step 2.5 Flash the Installer to the USB Drive
!!! Danger "Specifying the wrong device can lead to unrecoverable data loss."
@@ -150,10 +151,11 @@ sudo umount /dev/sdb1
If you need to configure Wi-Fi first, refer to the next section.
If Multicast-DNS (Avahi) is enabled on your own machine, you can also access the installer using the `nixos-installer.local` address.
## Boot From USB Stick
### Step 3: Boot From USB Stick
- To use, boot from the Clan USB drive with **secure boot turned off**. For step by step instructions go to [Disabling Secure Boot](../../guides/secure-boot.md)
## (Optional) Connect to Wifi Manually
If you don't have access via LAN the Installer offers support for connecting via Wifi.
@@ -201,3 +203,4 @@ Press ++ctrl+d++ to exit `IWD`.
Press ++ctrl+d++ **again** to update the displayed QR code and connection information.
You're all set up

View File

@@ -52,6 +52,65 @@ For more information see the [SOPS] guide on [encrypting with age].
!!! note
It's safe to add any secrets created by the clan CLI and placed in your repository to version control systems like `git`.
### Using Age Plugins
If you wish to use a key generated using an [age plugin] as your admin key, extra care is needed.
You must **precede your secret key with a comment that contains its corresponding recipient**.
This is usually output as part of the generation process
and is only required because there is no unified mechanism for recovering a recipient from a plugin secret key.
Here is an example:
```title="~/.config/sops/age/keys.txt"
# public key: age1zdy49ek6z60q9r34vf5mmzkx6u43pr9haqdh5lqdg7fh5tpwlfwqea356l
AGE-PLUGIN-FIDO2-HMAC-1QQPQZRFR7ZZ2WCV...
```
!!! note
The comment that precedes the plugin secret key need only contain the recipient.
Any other text is ignored.
In the example above, you can specify `# recipient: age1zdy...`, `# public: age1zdy....` or even
just `# age1zdy....`
You will need to add an entry into your `flake.nix` to ensure that the necessary `age` plugins
are loaded when using Clan:
```nix title="flake.nix"
{
inputs.clan-core.url = "https://git.clan.lol/clan/clan-core/archive/main.tar.gz";
inputs.nixpkgs.follows = "clan-core/nixpkgs";
outputs =
{ self, clan-core, ... }:
let
clan = clan-core.clanLib.clan {
inherit self;
meta.name = "myclan";
# Add Yubikey and FIDO2 HMAC plugins
# Note: the plugins listed here must be available in nixpkgs.
secrets.age.plugins = [
"age-plugin-yubikey"
"age-plugin-fido2-hmac"
];
machines = {
# elided for brevity
};
};
in
{
inherit (clan) nixosConfigurations nixosModules clanInternals;
# elided for brevity
};
}
```
### Add Your Public Key(s)
```console
@@ -117,62 +176,3 @@ clan secrets users remove-key $USER --age-key <your_public_key>
[age plugin]: https://github.com/FiloSottile/awesome-age?tab=readme-ov-file#plugins
[sops]: https://github.com/getsops/sops
[encrypting with age]: https://github.com/getsops/sops?tab=readme-ov-file#encrypting-using-age
## Further: Using Age Plugins
If you wish to use a key generated using an [age plugin] as your admin key, extra care is needed.
You must **precede your secret key with a comment that contains its corresponding recipient**.
This is usually output as part of the generation process
and is only required because there is no unified mechanism for recovering a recipient from a plugin secret key.
Here is an example:
```title="~/.config/sops/age/keys.txt"
# public key: age1zdy49ek6z60q9r34vf5mmzkx6u43pr9haqdh5lqdg7fh5tpwlfwqea356l
AGE-PLUGIN-FIDO2-HMAC-1QQPQZRFR7ZZ2WCV...
```
!!! note
The comment that precedes the plugin secret key need only contain the recipient.
Any other text is ignored.
In the example above, you can specify `# recipient: age1zdy...`, `# public: age1zdy....` or even
just `# age1zdy....`
You will need to add an entry into your `flake.nix` to ensure that the necessary `age` plugins
are loaded when using Clan:
```nix title="flake.nix"
{
inputs.clan-core.url = "https://git.clan.lol/clan/clan-core/archive/main.tar.gz";
inputs.nixpkgs.follows = "clan-core/nixpkgs";
outputs =
{ self, clan-core, ... }:
let
clan = clan-core.lib.clan {
inherit self;
meta.name = "myclan";
# Add Yubikey and FIDO2 HMAC plugins
# Note: the plugins listed here must be available in nixpkgs.
secrets.age.plugins = [
"age-plugin-yubikey"
"age-plugin-fido2-hmac"
];
machines = {
# elided for brevity
};
};
in
{
inherit (clan) nixosConfigurations nixosModules clanInternals;
# elided for brevity
};
}
```

View File

@@ -121,3 +121,16 @@ It is possible to add services to multiple machines via tags as shown
};
}
```
### API specification
**The complete schema specification is available [here](../reference/nix-api/inventory.md)**
Or it can build anytime via:
```sh
nix build git+https://git.clan.lol/clan/clan-core#schemas.inventory
> result
> ├── schema.cue
> └── schema.json
```

View File

@@ -9,7 +9,7 @@ Currently, Clan supports the following features for macOS:
- `clan machines update` for existing [nix-darwin](https://github.com/nix-darwin/nix-darwin) installations
- Support for [vars](../guides/vars-backend.md)
## Add Your Machine to Your Clan Flake
## Step 1: Add Your Machine to Your Clan Flake
In this example, we'll name the machine `yourmachine`. Replace this with your preferred machine name.
@@ -35,7 +35,7 @@ clan-core.lib.clan {
}
```
## Add a `configuration.nix` for Your Machine
## Step 2: Add a `configuration.nix` for Your Machine
Create the file `./machines/yourmachine/configuration.nix` with the following content (replace `yourmachine` with your chosen machine name):
@@ -48,7 +48,7 @@ Create the file `./machines/yourmachine/configuration.nix` with the following co
After creating the file, run `git add` to ensure Nix recognizes it.
## Generate Vars (If Needed)
## Step 3: Generate Vars (If Needed)
If your machine uses vars, generate them with:
@@ -58,12 +58,12 @@ clan vars generate yourmachine
Replace `yourmachine` with your chosen machine name.
## Install Nix
## Step 4: Install Nix
Install Nix on your macOS machine using one of the methods described in the [nix-darwin prerequisites](https://github.com/nix-darwin/nix-darwin?tab=readme-ov-file#prerequisites).
## Install nix-darwin
## Step 5: Install nix-darwin
Upload your Clan flake to the macOS machine. Then, from within your flake directory, run:
@@ -73,7 +73,7 @@ sudo nix run nix-darwin/master#darwin-rebuild -- switch --flake .#yourmachine
Replace `yourmachine` with your chosen machine name.
## Manage Your Machine with Clan
## Step 6: Manage Your Machine with Clan
Once all the steps above are complete, you can start managing your machine with:

View File

@@ -15,86 +15,140 @@ Clan
Node B
```
This guide shows you how to configure `zerotier` through clan's `Inventory` System.
If you select multiple network technologies at the same time. e.g. (zerotier + yggdrassil)
You must choose one of them as primary network and the machines are always connected via the primary network.
## The Controller
This guide shows you how to configure `zerotier` either through `NixOS Options` directly, or Clan's `Inventory` System.
The controller is the initial entrypoint for new machines into the vpn.
It will sign the id's of new machines.
Once id's are signed, the controller's continuous operation is not essential.
A good controller choice is nevertheless a machine that can always be reached for updates - so that new peers can be added to the network.
For the purpose of this guide we have two machines:
=== "**Inventory**"
## 1. Choose the Controller
- The `controller` machine, which will be the zerotier controller.
- The `new_machine` machine, which is the machine we want to add to the vpn network.
The controller is the initial entrypoint for new machines into the vpn.
It will sign the id's of new machines.
Once id's are signed, the controller's continuous operation is not essential.
A good controller choice is nevertheless a machine that can always be reached for updates - so that new peers can be added to the network.
## Configure the Service
For the purpose of this guide we have two machines:
```nix {.nix title="flake.nix" hl_lines="19-25"}
{
inputs.clan-core.url = "https://git.clan.lol/clan/clan-core/archive/main.tar.gz";
inputs.nixpkgs.follows = "clan-core/nixpkgs";
- The `controller` machine, which will be the zerotier controller.
- The `new_machine` machine, which is the machine we want to add to the vpn network.
outputs =
{ self, clan-core, ... }:
let
clan = clan-core.lib.clan {
inherit self;
## 2. Configure the Inventory
meta.name = "myclan";
Note: consider picking a more descriptive name for the VPN than "default".
It will be added as an altname for the Zerotier virtual ethernet interface, and
will also be visible in the Zerotier app.
inventory.machines = {
controller = {};
new_machine = {};
};
inventory.instances = {
zerotier = {
# Assign the controller machine to the role "controller"
roles.controller.machines."controller" = {};
# All clan machines are zerotier peers
roles.peer.tags."all" = {};
};
};
```nix
clan.inventory = {
services.zerotier.default = {
roles.controller.machines = [
"controller"
];
roles.peer.machines = [
"new_machine"
];
};
in
{
inherit (clan) nixosConfigurations nixosModules clanInternals;
# elided for brevity
};
}
```
```
## Apply the Configuration
## 3. Apply the Configuration
Update the `controller` machine:
Update the `controller` machine first:
```bash
clan machines update controller
```
```bash
clan machines update controller
```
Then update all other peers:
=== "**NixOS Options**"
## 1. Set-Up the VPN Controller
```bash
clan machines update
```
The VPN controller is initially essential for providing configuration to new
peers. Once addresses are allocated, the controller's continuous operation is not essential.
### Verify Connection
1. **Designate a Machine**: Label a machine as the VPN controller in the clan,
referred to as `<CONTROLLER>` henceforth in this guide.
2. **Add Configuration**: Input the following configuration to the NixOS
configuration of the controller machine:
```nix
clan.core.networking.zerotier.controller = {
enable = true;
public = true;
};
```
3. **Update the Controller Machine**: Execute the following:
```bash
clan machines update <CONTROLLER>
```
Your machine is now operational as the VPN controller.
On the `new_machine` run:
## 2. Add Machines to the VPN
```bash
$ sudo zerotier-cli info
```
To introduce a new machine to the VPN, adhere to the following steps:
The status should be "ONLINE":
1. **Update Configuration**: On the new machine, incorporate the following to its
configuration, substituting `<CONTROLLER>` with the controller machine name:
```nix
{ config, ... }: {
clan.core.networking.zerotier.networkId = builtins.readFile ../../vars/per-machine/<CONTROLLER>/zerotier/zerotier-network-id/value;
}
```
1. **Update the New Machine**: Execute:
```bash
$ clan machines update <NEW_MACHINE>
```
Replace `<NEW_MACHINE>` with the designated new machine name.
```{.console, .no-copy}
200 info d2c71971db 1.12.1 ONLINE
```
!!! Note "For Private Networks"
1. **Retrieve Zerotier Metadata**
=== "From the repo"
**Retrieve the ZeroTier IP**: In the clan repo, execute:
```console
$ clan facts list <NEW_MACHINE> | jq -r '.["zerotier-ip"]'
```
The returned address is the Zerotier IP address of the machine.
=== "On the new machine"
**Retrieve the ZeroTier ID**: On the `new_machine`, execute:
```bash
$ sudo zerotier-cli info
```
Example Output:
```{.console, .no-copy}
200 info d2c71971db 1.12.1 OFFLINE
```
, where `d2c71971db` is the ZeroTier ID.
2. **Authorize the New Machine on the Controller**: On the controller machine,
execute:
=== "with ZerotierIP"
```bash
$ sudo zerotier-members allow --member-ip <IP>
```
Substitute `<IP>` with the ZeroTier IP obtained previously.
=== "with ZerotierID"
```bash
$ sudo zerotier-members allow <ID>
```
Substitute `<ID>` with the ZeroTier ID obtained previously.
2. **Verify Connection**: On the `new_machine`, re-execute:
```bash
$ sudo zerotier-cli info
```
The status should now be "ONLINE":
```{.console, .no-copy}
200 info d2c71971db 1.12.1 ONLINE
```
!!! success "Congratulations!"
The new machine is now part of the VPN, and the ZeroTier
configuration on NixOS within the Clan project is complete.
## Further
@@ -104,45 +158,3 @@ In the future we plan to add additional network technologies like tinc, head/tai
We chose zerotier because in our tests it was a straight forwards solution to bootstrap.
It allows you to selfhost a controller and the controller doesn't need to be globally reachable.
Which made it a good fit for starting the project.
## Debugging
### Retrieve the ZeroTier ID
In the repo:
```console
$ clan vars list <machineName>
```
```{.console, .no-copy}
$ clan vars list controller
# ... elided
zerotier/zerotier-identity-secret: ********
zerotier/zerotier-ip: fd0a:b849:2928:1234:c99:930a:a959:2928
zerotier/zerotier-network-id: 0aa959282834000c
```
On the machine:
```bash
$ sudo zerotier-cli info
```
#### Manually Authorize a Machine on the Controller
=== "with ZerotierIP"
```bash
$ sudo zerotier-members allow --member-ip <IP>
```
Substitute `<IP>` with the ZeroTier IP obtained previously.
=== "with ZerotierID"
```bash
$ sudo zerotier-members allow <ID>
```
Substitute `<ID>` with the ZeroTier ID obtained previously.

View File

@@ -74,7 +74,9 @@ instances = {
## Steps to Migrate
### Move `services` entries to `instances`
### 1. Move `services` entries to `instances`
Check if a service that you use has been migrated [In our reference](../../reference/clanServices/index.md)
@@ -94,7 +96,7 @@ Each nested service-instance-pair becomes a flat key, like `borgbackup.simple
---
### Add `module.name` and `module.input`
### 2. Add `module.name` and `module.input`
Each instance must declare the module name and flake input it comes from:
@@ -115,7 +117,7 @@ Then refer to it as `input = "clan-core"`.
---
### Move role and machine config under `roles`
### 3. Move role and machine config under `roles`
In the new system:

View File

@@ -1,11 +1,9 @@
At the moment, NixOS/Clan does not support [Secure Boot](https://wiki.gentoo.org/wiki/Secure_Boot). Therefore, you need to disable it in the BIOS. You can watch this [video guide](https://www.youtube.com/watch?v=BKVShiMUePc) or follow the instructions below:
## Insert the USB Stick
### Step 1: Insert the USB Stick
- Begin by inserting the USB stick into a USB port on your computer.
## Access the UEFI/BIOS Menu
### Step 2: Access the UEFI/BIOS Menu
- Restart your computer.
- As your computer restarts, press the appropriate key to enter the UEFI/BIOS settings.
??? tip "The key depends on your laptop or motherboard manufacturer. Click to see a reference list:"
@@ -34,22 +32,18 @@ At the moment, NixOS/Clan does not support [Secure Boot](https://wiki.gentoo.org
!!! Note
Pressing the key quickly and repeatedly is sometimes necessary to access the UEFI/BIOS menu, as the window to enter this mode is brief.
## Access Advanced Mode (Optional)
### Step 3: Access Advanced Mode (Optional)
- If your UEFI/BIOS has a `Simple` or `Easy` mode interface, look for an option labeled `Advanced Mode` (often found in the lower right corner).
- Click on `Advanced Mode` to access more settings. This step is optional, as your boot settings might be available in the basic view.
## Disable Secure Boot
### Step 4: Disable Secure Boot
- Locate the `Secure Boot` option in your UEFI/BIOS settings. This is typically found under a `Security` tab, `Boot` tab, or a similarly named section.
- Set the `Secure Boot` option to `Disabled`.
## Change Boot Order
### Step 5: Change Boot Order
- Find the option to adjust the boot order—often labeled `Boot Order`, `Boot Sequence`, or `Boot Priority`.
- Ensure that your USB device is set as the first boot option. This allows your computer to boot from the USB stick.
## Save and Exit
### Step 6: Save and Exit
- Save your changes before exiting the UEFI/BIOS menu. Look for a `Save & Exit` option or press the corresponding function key (often `F10`).
- Your computer should now restart and boot from the USB stick.

View File

@@ -1,7 +0,0 @@
---
template: options.html
hide:
- navigation
- toc
---
<redoc src="/openapi.json" />

View File

@@ -1,25 +0,0 @@
# Developer Documentation
!!! Danger
This documentation is **not** intended for external users. It may contain low-level details and internal-only interfaces.*
Welcome to the internal developer documentation.
This section is intended for contributors, engineers, and internal stakeholders working directly with our system, tooling, and APIs. It provides a technical overview of core components, internal APIs, conventions, and patterns that support the platform.
Our goal is to make the internal workings of the system **transparent, discoverable, and consistent** — helping you contribute confidently, troubleshoot effectively, and build faster.
## What's Here?
!!! note "docs migration ongoing"
- [ ] **API Reference**: 🚧🚧🚧 Detailed documentation of internal API functions, inputs, and expected outputs. 🚧🚧🚧
- [ ] **System Concepts**: Architectural overviews and domain-specific guides.
- [ ] **Development Guides**: How to test, extend, or integrate with key components.
- [ ] **Design Notes**: Rationales behind major design decisions or patterns.
## Who is This For?
* Developers contributing to the platform
* Engineers debugging or extending internal systems
* Anyone needing to understand **how** and **why** things work under the hood

32
flake.lock generated
View File

@@ -16,11 +16,11 @@
]
},
"locked": {
"lastModified": 1751413887,
"narHash": "sha256-+ut7DrSwamExIvaCFdiTYD88NTSYJFG2CEOvCha59vI=",
"rev": "246f0d66547d073af6249e4f7852466197e871ed",
"lastModified": 1751241706,
"narHash": "sha256-T3hOK/yQexsrgTfkSceRVpWOtkMqbbKYWUCPwQnrUl0=",
"rev": "97d8e88ec1d43b52f9886a722c013af2db15bb47",
"type": "tarball",
"url": "https://git.clan.lol/api/v1/repos/clan/data-mesher/archive/246f0d66547d073af6249e4f7852466197e871ed.tar.gz"
"url": "https://git.clan.lol/api/v1/repos/clan/data-mesher/archive/97d8e88ec1d43b52f9886a722c013af2db15bb47.tar.gz"
},
"original": {
"type": "tarball",
@@ -34,11 +34,11 @@
]
},
"locked": {
"lastModified": 1751607816,
"narHash": "sha256-5PtrwjqCIJ4DKQhzYdm8RFePBuwb+yTzjV52wWoGSt4=",
"lastModified": 1750903843,
"narHash": "sha256-Ng9+f0H5/dW+mq/XOKvB9uwvGbsuiiO6HrPdAcVglCs=",
"owner": "nix-community",
"repo": "disko",
"rev": "da6109c917b48abc1f76dd5c9bf3901c8c80f662",
"rev": "83c4da299c1d7d300f8c6fd3a72ac46cb0d59aae",
"type": "github"
},
"original": {
@@ -54,11 +54,11 @@
]
},
"locked": {
"lastModified": 1751413152,
"narHash": "sha256-Tyw1RjYEsp5scoigs1384gIg6e0GoBVjms4aXFfRssQ=",
"lastModified": 1749398372,
"narHash": "sha256-tYBdgS56eXYaWVW3fsnPQ/nFlgWi/Z2Ymhyu21zVM98=",
"owner": "hercules-ci",
"repo": "flake-parts",
"rev": "77826244401ea9de6e3bac47c2db46005e1f30b5",
"rev": "9305fe4e5c2a6fcf5ba6a3ff155720fbe4076569",
"type": "github"
},
"original": {
@@ -164,10 +164,10 @@
"nixpkgs": {
"locked": {
"lastModified": 315532800,
"narHash": "sha256-0HRxGUoOMtOYnwlMWY0AkuU88WHaI3Q5GEILmsWpI8U=",
"rev": "a48741b083d4f36dd79abd9f760c84da6b4dc0e5",
"narHash": "sha256-VgDAFPxHNhCfC7rI5I5wFqdiVJBH43zUefVo8hwo7cI=",
"rev": "41da1e3ea8e23e094e5e3eeb1e6b830468a7399e",
"type": "tarball",
"url": "https://releases.nixos.org/nixpkgs/nixpkgs-25.11pre823094.a48741b083d4/nixexprs.tar.xz"
"url": "https://releases.nixos.org/nixpkgs/nixpkgs-25.11pre814815.41da1e3ea8e2/nixexprs.tar.xz"
},
"original": {
"type": "tarball",
@@ -221,11 +221,11 @@
]
},
"locked": {
"lastModified": 1751606940,
"narHash": "sha256-KrDPXobG7DFKTOteqdSVeL1bMVitDcy7otpVZWDE6MA=",
"lastModified": 1750119275,
"narHash": "sha256-Rr7Pooz9zQbhdVxux16h7URa6mA80Pb/G07T4lHvh0M=",
"owner": "Mic92",
"repo": "sops-nix",
"rev": "3633fc4acf03f43b260244d94c71e9e14a2f6e0d",
"rev": "77c423a03b9b2b79709ea2cb63336312e78b72e2",
"type": "github"
},
"original": {

View File

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

View File

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

View File

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

View File

@@ -26,7 +26,6 @@ in
inventory,
clanCoreModules,
prefix ? [ ],
exportsModule,
}:
let
# machineHasTag = machineName: tagName: lib.elem tagName inventory.machines.${machineName}.tags;
@@ -90,6 +89,23 @@ in
}
) inventory.instances or { };
# TODO: Eagerly check the _class of the resolved module
importedModulesEvaluated = lib.mapAttrs (
module_ident: instances:
clanLib.evalService {
prefix = prefix ++ [ module_ident ];
modules =
[
# Import the resolved module.
# i.e. clan.modules.admin
(builtins.head instances).instance.resolvedModule
] # Include all the instances that correlate to the resolved module
++ (builtins.map (v: {
instances.${v.instanceName}.roles = v.instance.instanceRoles;
}) instances);
}
) grouped;
# Group the instances by the module they resolve to
# This is necessary to evaluate the module in a single pass
# :: { <module.input>_<module.name> :: [ { name, value } ] }
@@ -110,52 +126,16 @@ in
}
) { } 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;
acc: _module_ident: eval:
acc ++ [ eval.config.result.final.${machineName}.nixosModule or { } ]
) [ ] importedModulesEvaluated;
}) inventory.machines or { };
evalServices =
{ modules, prefix }:
lib.evalModules {
specialArgs = {
inherit clanLib;
_ctx = prefix;
};
modules = [
./all-services-wrapper.nix
] ++ modules;
};
servicesEval = evalServices {
inherit prefix;
modules = [
{
inherit exportsModule;
mappedServices = lib.mapAttrs (_module_ident: instances: {
imports =
[
# Import the resolved module.
# i.e. clan.modules.admin
(builtins.head instances).instance.resolvedModule
] # Include all the instances that correlate to the resolved module
++ (builtins.map (v: {
instances.${v.instanceName}.roles = v.instance.instanceRoles;
}) instances);
}) grouped;
}
];
};
importedModulesEvaluated = servicesEval.config.mappedServices;
in
{
inherit
servicesEval
importedModuleWithInstances
grouped
allMachines

View File

@@ -104,13 +104,6 @@ let
in
{
options = {
# Option to disable some behavior during docs rendering
_docs_rendering = mkOption {
default = false;
visible = false;
type = types.bool;
};
instances = mkOption {
visible = false;
defaultText = "Throws: 'The service must define its instances' when not defined";
@@ -391,33 +384,6 @@ in
type = types.deferredModuleWith {
staticModules = [
({
options.exports = mkOption {
type = types.deferredModule;
default = { };
description = ''
export modules defined in 'perInstance'
mapped to their instance name
Example
with instances:
```nix
instances.A = { ... };
instances.B= { ... };
roles.peer.perInstance = { instanceName, machine, ... }:
{
exports.foo = 1;
}
This yields all other services can access these exports
=>
exports.instances.A.foo = 1;
exports.instances.B.foo = 1;
```
'';
};
options.nixosModule = mkOption {
type = types.deferredModule;
default = { };
@@ -446,6 +412,27 @@ in
```
'';
};
options.services = mkOption {
visible = false;
type = attrsWith {
placeholder = "serviceName";
elemType = submoduleWith {
modules = [
{
_module.args._ctx = _ctx ++ [
config.manifest.name
"roles"
roleName
"perInstance"
"services"
];
}
./service-module.nix
];
};
};
default = { };
};
})
];
};
@@ -527,32 +514,6 @@ in
type = types.deferredModuleWith {
staticModules = [
({
options.exports = mkOption {
type = types.deferredModule;
default = { };
description = ''
export modules defined in 'perMachine'
mapped to their machine name
Example
with machines:
```nix
instances.A = { roles.peer.machines.jon = ... };
instances.B = { roles.peer.machines.jon = ... };
perMachine = { machine, ... }:
{
exports.foo = 1;
}
This yields all other services can access these exports
=>
exports.machines.jon.foo = 1;
exports.machines.sara.foo = 1;
```
'';
};
options.nixosModule = mkOption {
type = types.deferredModule;
default = { };
@@ -576,6 +537,25 @@ in
```
'';
};
options.services = mkOption {
visible = false;
type = attrsWith {
placeholder = "serviceName";
elemType = submoduleWith {
modules = [
{
_module.args._ctx = _ctx ++ [
config.manifest.name
"perMachine"
"services"
];
}
./service-module.nix
];
};
};
default = { };
};
})
];
};
@@ -628,96 +608,6 @@ in
modules = [ v ];
}).config;
};
exports = mkOption {
description = ''
This services exports.
Gets merged with all other services exports
Final value (merged and evaluated with other services) available as `exports'` in the arguments of this module.
```nix
{ exports', ... }: {
_class = "clan.service";
# ...
}
```
'';
default = { };
type = types.submoduleWith {
# Static modules
modules = [
{
options.instances = mkOption {
type = types.attrsOf types.deferredModule;
description = ''
export modules defined in 'perInstance'
mapped to their instance name
Example
with instances:
```nix
instances.A = { ... };
instances.B= { ... };
roles.peer.perInstance = { instanceName, machine, ... }:
{
exports.foo = 1;
}
This yields all other services can access these exports
=>
exports.instances.A.foo = 1;
exports.instances.B.foo = 1;
```
'';
};
options.machines = mkOption {
type = types.attrsOf types.deferredModule;
description = ''
export modules defined in 'perMachine'
mapped to their machine name
Example
with machines:
```nix
instances.A = { roles.peer.machines.jon = ... };
instances.B = { roles.peer.machines.jon = ... };
perMachine = { machine, ... }:
{
exports.foo = 1;
}
This yields all other services can access these exports
=>
exports.machines.jon.foo = 1;
exports.machines.sara.foo = 1;
```
'';
};
# Lazy default via imports
# should probably be moved to deferredModuleWith { staticModules = [ ]; }
imports =
if config._docs_rendering then
[ ]
else
lib.mapAttrsToList (_roleName: role: {
instances = lib.mapAttrs (_instanceName: instance: {
imports = lib.mapAttrsToList (_machineName: v: v.exports) instance.allMachines;
}) role.allInstances;
}) config.result.allRoles
++ lib.mapAttrsToList (machineName: machine: {
machines.${machineName} = machine.exports;
}) config.result.allMachines;
}
];
};
};
# ---
# Place the result in _module.result to mark them as "internal" and discourage usage/overrides
#
@@ -837,18 +727,40 @@ in
instanceAcc: instanceName: instance:
instanceAcc
// {
nixosModules = (
if instance.allMachines.${machineName}.nixosModule or { } != { } then
instanceAcc.nixosModules
++ [
(lib.setDefaultModuleLocation
"Via instances.${instanceName}.roles.${roleName}.machines.${machineName}"
instance.allMachines.${machineName}.nixosModule
)
]
else
instanceAcc.nixosModules
);
nixosModules =
(
(lib.mapAttrsToList (
nestedServiceName: serviceModule:
let
unmatchedMachines = lib.attrNames (
lib.removeAttrs serviceModule.result.final (lib.attrNames config.result.allMachines)
);
in
if unmatchedMachines != [ ] then
throw ''
The following machines are not part of the parent service: ${builtins.toJSON unmatchedMachines}
Either remove the machines, or include them into the parent via a role.
(Added via roles.${roleName}.perInstance.services.${nestedServiceName})
${errorContext}
''
else
serviceModule.result.final.${machineName}.nixosModule
) instance.allMachines.${machineName}.services or { })
)
++ (
if instance.allMachines.${machineName}.nixosModule or { } != { } then
instanceAcc.nixosModules
++ [
(lib.setDefaultModuleLocation
"Via instances.${instanceName}.roles.${roleName}.machines.${machineName}"
instance.allMachines.${machineName}.nixosModule
)
]
else
instanceAcc.nixosModules
);
}
) roleAcc role.allInstances
)
@@ -861,18 +773,38 @@ in
{
inherit instanceResults machineResult;
nixosModule = {
imports = [
# include service assertions:
(
imports =
[
# include service assertions:
(
let
failedAssertions = (lib.filterAttrs (_: v: !v.assertion) config.result.assertions);
in
{
assertions = lib.attrValues failedAssertions;
}
)
(lib.setDefaultModuleLocation "Via ${config.manifest.name}.perMachine - machine='${machineName}';" machineResult.nixosModule)
]
++ (lib.mapAttrsToList (
nestedServiceName: serviceModule:
let
failedAssertions = (lib.filterAttrs (_: v: !v.assertion) config.result.assertions);
unmatchedMachines = lib.attrNames (
lib.removeAttrs serviceModule.result.final (lib.attrNames config.result.allMachines)
);
in
{
assertions = lib.attrValues failedAssertions;
}
)
(lib.setDefaultModuleLocation "Via ${config.manifest.name}.perMachine - machine='${machineName}';" machineResult.nixosModule)
] ++ instanceResults.nixosModules;
if unmatchedMachines != [ ] then
throw ''
The following machines are not part of the parent service: ${builtins.toJSON unmatchedMachines}
Either remove the machines, or include them into the parent via a role.
(Added via perMachine.services.${nestedServiceName})
${errorContext}
''
else
serviceModule.result.final.${machineName}.nixosModule
) machineResult.services)
++ instanceResults.nixosModules;
};
}
) config.result.allMachines;

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,8 @@
{ clanLib, lib, ... }:
{
test_simple = import ./simple.nix { inherit clanLib lib; };
test_multi_machine = import ./multi_machine.nix { inherit clanLib lib; };
test_multi_import_duplication = import ./multi_import_duplication.nix { inherit clanLib lib; };
}

View File

@@ -0,0 +1,125 @@
{ clanLib, lib, ... }:
let
# Potentially imported many times
# To add the ssh key
example-admin = (
{ lib, ... }:
{
manifest.name = "example-admin";
roles.client.interface = {
options.keys = lib.mkOption { };
};
roles.client.perInstance =
{ settings, ... }:
{
nixosModule = {
inherit (settings) keys;
};
};
}
);
consumer-A =
{ ... }:
{
manifest.name = "consumer-A";
instances.foo = {
roles.server.machines."jon" = { };
};
instances.bar = {
roles.server.machines."jon" = { };
};
roles.server = {
perInstance =
{ machine, instanceName, ... }:
{
services."example-admin" = {
imports = [
example-admin
];
instances."${instanceName}" = {
roles.client.machines.${machine.name} = {
settings.keys = [ "pubkey-1" ];
};
};
};
};
};
};
consumer-B =
{ ... }:
{
manifest.name = "consumer-A";
instances.foo = {
roles.server.machines."jon" = { };
};
instances.bar = {
roles.server.machines."jon" = { };
};
roles.server = {
perInstance =
{ machine, instanceName, ... }:
{
services."example-admin" = {
imports = [
example-admin
];
instances."${instanceName}" = {
roles.client.machines.${machine.name} = {
settings.keys = [
"pubkey-1"
];
};
};
};
};
};
};
eval = clanLib.evalService {
modules = [
(consumer-A)
];
prefix = [ ];
};
eval2 = clanLib.evalService {
modules = [
(consumer-B)
];
prefix = [ ];
};
evalNixos = lib.evalModules {
modules = [
{
options.assertions = lib.mkOption { };
# This is suboptimal
options.keys = lib.mkOption { };
}
eval.config.result.final.jon.nixosModule
eval2.config.result.final.jon.nixosModule
];
};
in
{
# Check that the nixos system has the settings from the nested module, as well as those from the "perMachine" and "perInstance"
inherit eval;
expr = evalNixos.config;
expected = {
assertions = [ ];
# TODO: Some deduplication mechanism is nice
# Could add types.set or do 'apply = unique', or something else ?
keys = [
"pubkey-1"
"pubkey-1"
"pubkey-1"
"pubkey-1"
];
};
}

View File

@@ -0,0 +1,108 @@
{ clanLib, lib, ... }:
let
service-B = (
{ lib, ... }:
{
manifest.name = "service-B";
roles.client.interface = {
options.user = lib.mkOption { };
options.host = lib.mkOption { };
};
roles.client.perInstance =
{ settings, instanceName, ... }:
{
nixosModule = {
units.${instanceName} = {
script = settings.user + "@" + settings.host;
};
};
};
perMachine =
{ ... }:
{
nixosModule = {
ssh.enable = true;
};
};
}
);
service-A =
{ ... }:
{
manifest.name = "service-A";
instances.foo = {
roles.server.machines."jon" = { };
roles.server.machines."sara" = { };
};
roles.server = {
perInstance =
{ machine, instanceName, ... }:
{
services."B" = {
imports = [
service-B
];
instances."A-${instanceName}-B" = {
roles.client.machines.${machine.name} = {
settings.user = "johnny";
settings.host = machine.name;
};
};
};
};
};
};
eval = clanLib.evalService {
modules = [
(service-A)
];
prefix = [ ];
};
evalNixos = lib.mapAttrs (
_n: v:
(lib.evalModules {
modules = [
{
options.assertions = lib.mkOption { };
options.units = lib.mkOption { };
options.ssh = lib.mkOption { };
}
v.nixosModule
];
}).config
) eval.config.result.final;
in
{
# Check that the nixos system has the settings from the nested module, as well as those from the "perMachine" and "perInstance"
inherit eval;
expr = evalNixos;
expected = {
jon = {
assertions = [ ];
ssh = {
enable = true;
};
units = {
A-foo-B = {
script = "johnny@jon";
};
};
};
sara = {
assertions = [ ];
ssh = {
enable = true;
};
units = {
A-foo-B = {
script = "johnny@sara";
};
};
};
};
}

View File

@@ -0,0 +1,117 @@
/*
service-B :: Service
exports a nixosModule which set "address" and "hostname"
Note: How we use null together with mkIf to create optional values.
This is a method, to create mergable modules
service-A :: Service
service-A.roles.server.perInstance.services."B"
imports service-B
configures a client with hostname = "johnny"
service-A.perMachine.services."B"
imports service-B
configures a client with address = "root"
*/
{ clanLib, lib, ... }:
let
service-B = (
{ lib, ... }:
{
manifest.name = "service-B";
roles.client.interface = {
options.hostname = lib.mkOption { default = null; };
options.address = lib.mkOption { default = null; };
};
roles.client.perInstance =
{ settings, ... }:
{
nixosModule = {
imports = [
# Only export the value that is actually set.
(lib.mkIf (settings.hostname != null) {
hostname = settings.hostname;
})
(lib.mkIf (settings.address != null) {
address = settings.address;
})
];
};
};
}
);
service-A =
{ ... }:
{
manifest.name = "service-A";
instances.foo = {
roles.server.machines."jon" = { };
};
instances.bar = {
roles.server.machines."jon" = { };
};
roles.server = {
perInstance =
{ machine, instanceName, ... }:
{
services."B" = {
imports = [
service-B
];
instances."B-for-A" = {
roles.client.machines.${machine.name} = {
settings.hostname = instanceName + "+johnny";
};
};
};
};
};
perMachine =
{ machine, ... }:
{
services."B" = {
imports = [
service-B
];
instances."B-for-A" = {
roles.client.machines.${machine.name} = {
settings.address = "root";
};
};
};
};
};
eval = clanLib.evalService {
modules = [
(service-A)
];
prefix = [ ];
};
evalNixos = lib.evalModules {
modules = [
{
options.assertions = lib.mkOption { };
options.hostname = lib.mkOption { type = lib.types.separatedString " "; };
options.address = lib.mkOption { type = lib.types.str; };
}
eval.config.result.final."jon".nixosModule
];
};
in
{
# Check that the nixos system has the settings from the nested module, as well as those from the "perMachine" and "perInstance"
inherit eval;
expr = evalNixos.config;
expected = {
address = "root";
assertions = [ ];
# Concatenates hostnames from both instances
hostname = "bar+johnny foo+johnny";
};
}

View File

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

View File

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

View File

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

View File

@@ -394,7 +394,6 @@ in
options = {
# ModuleSpec
module = lib.mkOption {
default = { };
type = types.submodule {
options.input = lib.mkOption {
type = types.nullOr types.str;

View File

@@ -15,7 +15,7 @@ find = {}
[tool.setuptools.package-data]
test_driver = ["py.typed"]
[tool.mypy]
python_version = "3.13"
python_version = "3.12"
warn_redundant_casts = true
disallow_untyped_calls = true
disallow_untyped_defs = true

View File

@@ -1,6 +1,7 @@
{
config,
lib,
pkgs,
...
}:
{
@@ -23,14 +24,6 @@
description = ''
the location of the deployment.json file
'';
default = throw ''
deployment.json file generation has been removed in favor of direct selectors.
Please upgrade your clan-cli to the latest version.
The deployment data is now accessed directly from the configuration
instead of being written to a separate JSON file.
'';
};
deployment.buildHost = lib.mkOption {
type = lib.types.nullOr lib.types.str;
@@ -90,5 +83,8 @@
inherit (config.system.clan.deployment) nixosMobileWorkaround;
inherit (config.clan.deployment) requireExplicitUpdate;
};
system.clan.deployment.file = pkgs.writeText "deployment.json" (
builtins.toJSON config.system.clan.deployment.data
);
};
}

View File

@@ -11,7 +11,8 @@ in
enable = lib.mkEnableOption "automatic state-version generation.
The option will take the specified version, if one is already supplied through
the config or generate one if not";
the config or generate one if not.
";
};
config = lib.mkIf (config.clan.core.settings.state-version.enable) {

View File

@@ -73,5 +73,10 @@ in
) [ ] (lib.attrValues generator.files)
) [ ] (lib.attrValues config.clan.core.vars.generators);
system.clan.deployment.data = {
vars = config.clan.core.vars._serialized;
inherit (config.clan.core.networking) targetHost buildHost;
inherit (config.clan.core.deployment) requireExplicitUpdate;
};
};
}

View File

@@ -34,6 +34,50 @@ let
in
{
options = {
_serialized = lib.mkOption {
readOnly = true;
internal = true;
description = ''
JSON serialization of the generators.
This is read from the python client to generate the specified resources.
'';
default = {
# TODO: We don't support per-machine choice of backends
# Configuring different backend doesn't work, this information should be made read only and configured
# Via clan.settings instead.
inherit (config.settings) secretModule publicModule;
# Serialize generators, so that we can use them in the python client
# This need to be done because we have some non-serializable values in the generators
# Like the finalScript (derivation) or pkgs.
generators = lib.flip lib.mapAttrs config.generators (
_name: generator: {
inherit (generator)
name
dependencies
validationHash
migrateFact
share
prompts
;
files = lib.flip lib.mapAttrs generator.files (
_name: file: {
inherit (file)
name
owner
group
mode
deploy
secret
neededFor
;
}
);
}
);
};
};
settings = import ./settings-opts.nix { inherit lib; };
generators = lib.mkOption {
description = ''

View File

@@ -64,6 +64,8 @@ in
};
};
config = {
system.clan.deployment.data.password-store.secretLocation =
config.clan.vars.password-store.secretLocation;
clan.core.vars.settings =
lib.mkIf (config.clan.core.vars.settings.secretStore == "password-store")
{

View File

@@ -1,7 +1,7 @@
# shellcheck shell=bash
source_up
watch_file .local.env flake-module.nix shell.nix webview-ui/flake-module.nix
watch_file flake-module.nix shell.nix webview-ui/flake-module.nix
# Because we depend on nixpkgs sources, uploading to builders takes a long time
use flake .#clan-app --builders ''

View File

@@ -103,18 +103,6 @@ GTK_DEBUG=interactive ./bin/clan-app --debug
Appending `--debug` flag enables debug logging printed into the console.
Debugging crashes in the `webview` library can be done by executing:
```bash
$ ./pygdb.sh ./bin/clan-app --content-uri http://localhost:3000/ --debug
```
I recommend creating the file `.local.env` with the content:
```bash
export WEBVIEW_LIB_DIR=$HOME/Projects/webview/build/core
```
where `WEBVIEW_LIB_DIR` points to a local checkout of the webview lib source, that has been build by hand. The `.local.env` file will be automatically sourced if it exists and will be ignored by git.
### Profiling
To activate profiling you can run
@@ -123,3 +111,51 @@ To activate profiling you can run
CLAN_CLI_PERF=1 ./bin/clan-app
```
### Library Components
> Note:
>
> we recognized bugs when starting some cli-commands through the integrated vs-code terminal.
> If encountering issues make sure to run commands in a regular os-shell.
lib-Adw has a demo application showing all widgets. You can run it by executing
```bash
adwaita-1-demo
```
GTK4 has a demo application showing all widgets. You can run it by executing
```bash
gtk4-widget-factory
```
To find available icons execute
```bash
gtk4-icon-browser
```
### Links
Here are some important documentation links related to the Clan App:
- [GTK4 PyGobject Reference](http://lazka.github.io/pgi-docs/index.html#Gtk-4.0): This link provides the PyGObject reference documentation for GTK4, the toolkit used for building the user interface of the clan app. It includes information about GTK4 widgets, signals, and other features.
- [Adw Widget Gallery](https://gnome.pages.gitlab.gnome.org/libadwaita/doc/main/widget-gallery.html): This link showcases a widget gallery for Adw, allowing you to see the available widgets and their visual appearance. It can be helpful for designing the user interface of the clan app.
- [GNOME Human Interface Guidelines](https://developer.gnome.org/hig/): This link provides the GNOME Human Interface Guidelines, which offer design and usability recommendations for creating GNOME applications. It covers topics such as layout, navigation, and interaction patterns.
## Error handling
> Error dialogs should be avoided where possible, since they are disruptive.
>
> For simple non-critical errors, toasts can be a good alternative.
[direnv]: https://direnv.net/
[process-compose]: https://f1bonacc1.github.io/process-compose/
[vite]: https://vite.dev/
[webview]: https://github.com/webview/webview
[Storybook]: https://storybook.js.org/
[webkit]: https://webkit.org/

View File

@@ -1,3 +1,4 @@
# ruff: noqa: N801
import gi
gi.require_version("Gtk", "4.0")

View File

@@ -8,10 +8,14 @@ from dataclasses import dataclass
from pathlib import Path
import clan_lib.machines.actions # noqa: F401
from clan_lib.api import API, load_in_all_api_functions, tasks
from clan_lib.api import API, ErrorDataClass, SuccessDataClass
# TODO: We have to manually import python files to make the API.register be triggered.
# We NEED to fix this, as this is super unintuitive and error-prone.
from clan_lib.api.tasks import list_tasks as dummy_list # noqa: F401
from clan_lib.custom_logger import setup_logging
from clan_lib.dirs import user_data_dir
from clan_lib.log_manager import LogGroupConfig, LogManager
from clan_lib.log_manager import LogManager
from clan_lib.log_manager import api as log_manager_api
from clan_app.api.file_gtk import open_file
@@ -41,23 +45,46 @@ def app_run(app_opts: ClanAppOptions) -> int:
webview = Webview(debug=app_opts.debug)
webview.title = "Clan App"
# This seems to call the gtk api correctly but and gtk also seems to our icon, but somehow the icon is not loaded.
webview.icon = "clan-white"
# Add a log group ["clans", <dynamic_name>, "machines", <dynamic_name>]
log_manager = LogManager(base_dir=user_data_dir() / "clan-app" / "logs")
clan_log_group = LogGroupConfig("clans", "Clans").add_child(
LogGroupConfig("machines", "Machines")
log_manager_api.LOG_MANAGER_INSTANCE = LogManager(
base_dir=user_data_dir() / "clan-app" / "logs"
)
log_manager = log_manager.add_root_group_config(clan_log_group)
# Init LogManager global in log_manager_api module
log_manager_api.LOG_MANAGER_INSTANCE = log_manager
# Init BAKEND_THREADS global in tasks module
tasks.BAKEND_THREADS = webview.threads
def cancel_task(
task_id: str, *, op_key: str
) -> SuccessDataClass[None] | ErrorDataClass:
"""Cancel a task by its op_key."""
log.debug(f"Cancelling task with op_key: {task_id}")
future = webview.threads.get(task_id)
if future:
future.stop_event.set()
log.debug(f"Task {task_id} cancelled.")
else:
log.warning(f"Task {task_id} not found.")
return SuccessDataClass(
op_key=op_key,
data=None,
status="success",
)
# Populate the API global with all functions
load_in_all_api_functions()
def list_tasks(
*,
op_key: str,
) -> SuccessDataClass[list[str]] | ErrorDataClass:
"""List all tasks."""
log.debug("Listing all tasks.")
tasks = list(webview.threads.keys())
return SuccessDataClass(
op_key=op_key,
data=tasks,
status="success",
)
API.overwrite_fn(list_tasks)
API.overwrite_fn(open_file)
API.overwrite_fn(cancel_task)
webview.bind_jsonschema_api(API, log_manager=log_manager_api.LOG_MANAGER_INSTANCE)
webview.size = Size(1280, 1024, SizeHint.NONE)
webview.navigate(content_uri)

View File

@@ -88,6 +88,9 @@ class _WebviewLibrary:
self.webview_set_title = self.lib.webview_set_title
self.webview_set_title.argtypes = [c_void_p, c_char_p]
self.webview_set_icon = self.lib.webview_set_icon
self.webview_set_icon.argtypes = [c_void_p, c_char_p]
self.webview_set_size = self.lib.webview_set_size
self.webview_set_size.argtypes = [c_void_p, c_int, c_int, c_int]
@@ -109,8 +112,6 @@ class _WebviewLibrary:
self.webview_return = self.lib.webview_return
self.webview_return.argtypes = [c_void_p, c_char_p, c_int, c_char_p]
self.binding_callback_t = CFUNCTYPE(None, c_char_p, c_char_p, c_void_p)
self.CFUNCTYPE = CFUNCTYPE

View File

@@ -1,10 +1,11 @@
# ruff: noqa: TRY301
import ctypes
import functools
import io
import json
import logging
import threading
from collections.abc import Callable
from dataclasses import dataclass
from enum import IntEnum
from typing import Any
@@ -15,7 +16,6 @@ from clan_lib.api import (
dataclass_to_dict,
from_dict,
)
from clan_lib.api.tasks import WebThread
from clan_lib.async_run import AsyncContext, get_async_ctx, set_async_ctx
from clan_lib.custom_logger import setup_logging
from clan_lib.log_manager import LogManager
@@ -44,6 +44,12 @@ class Size:
self.hint = hint
@dataclass
class WebThread:
thread: threading.Thread
stop_event: threading.Event
class Webview:
def __init__(
self, debug: bool = False, size: Size | None = None, window: int | None = None
@@ -67,38 +73,21 @@ class Webview:
) -> None:
op_key = op_key_bytes.decode()
args = json.loads(request_data.decode())
log.debug(f"Calling {method_name}({json.dumps(args, indent=4)})")
header: dict[str, Any]
log.debug(f"Calling {method_name}({args[0]})")
try:
# Initialize dataclasses from the payload
reconciled_arguments = {}
if len(args) == 1:
request = args[0]
header = request.get("header", {})
msg = f"Expected header to be a dict, got {type(header)}"
if not isinstance(header, dict):
raise TypeError(msg)
body = request.get("body", {})
msg = f"Expected body to be a dict, got {type(body)}"
if not isinstance(body, dict):
raise TypeError(msg)
for k, v in args[0].items():
# Some functions expect to be called with dataclass instances
# But the js api returns dictionaries.
# Introspect the function and create the expected dataclass from dict dynamically
# Depending on the introspected argument_type
arg_class = api.get_method_argtype(method_name, k)
for k, v in body.items():
# Some functions expect to be called with dataclass instances
# But the js api returns dictionaries.
# Introspect the function and create the expected dataclass from dict dynamically
# Depending on the introspected argument_type
arg_class = api.get_method_argtype(method_name, k)
# TODO: rename from_dict into something like construct_checked_value
# from_dict really takes Anything and returns an instance of the type/class
reconciled_arguments[k] = from_dict(arg_class, v)
elif len(args) > 1:
msg = (
"Expected a single argument, got multiple arguments to api_wrapper"
)
raise ValueError(msg)
# TODO: rename from_dict into something like construct_checked_value
# from_dict really takes Anything and returns an instance of the type/class
reconciled_arguments[k] = from_dict(arg_class, v)
reconciled_arguments["op_key"] = op_key
except Exception as e:
@@ -123,39 +112,9 @@ class Webview:
def thread_task(stop_event: threading.Event) -> None:
ctx: AsyncContext = get_async_ctx()
ctx.should_cancel = lambda: stop_event.is_set()
try:
# If the API call has set log_group in metadata,
# create the log file under that group.
log_group: list[str] = header.get("logging", {}).get("group_path", None)
if log_group is not None:
if not isinstance(log_group, list):
msg = f"Expected log_group to be a list, got {type(log_group)}"
raise TypeError(msg)
log.warning(
f"Using log group {log_group} for {method_name} with op_key {op_key}"
)
log_file = log_manager.create_log_file(
wrap_method, op_key=op_key, group_path=log_group
).get_file_path()
except Exception as e:
log.exception(f"Error while handling request header of {method_name}")
result = ErrorDataClass(
op_key=op_key,
status="error",
errors=[
ApiError(
message="An internal error occured",
description=str(e),
location=["header_middleware", method_name],
)
],
)
serialized = json.dumps(
dataclass_to_dict(result), indent=4, ensure_ascii=False
)
self.return_(op_key, FuncStatus.SUCCESS, serialized)
log_file = log_manager.create_log_file(
wrap_method, op_key=op_key
).get_file_path()
with log_file.open("ab") as log_f:
# Redirect all cmd.run logs to this file.
@@ -170,15 +129,15 @@ class Webview:
handler = setup_logging(
log.getEffectiveLevel(), log_file=handler_stream
)
log.info("Starting thread for webview API call")
try:
# Original logic: call the wrapped API method.
result = wrap_method(**reconciled_arguments)
wrapped_result = {"body": dataclass_to_dict(result), "header": {}}
# Serialize the result to JSON.
serialized = json.dumps(
dataclass_to_dict(wrapped_result), indent=4, ensure_ascii=False
dataclass_to_dict(result), indent=4, ensure_ascii=False
)
# This log message will now also be written to log_f
@@ -245,6 +204,15 @@ class Webview:
_webview_lib.webview_set_title(self._handle, _encode_c_string(value))
self._title = value
@property
def icon(self) -> str:
return self._icon
@icon.setter
def icon(self, value: str) -> None:
_webview_lib.webview_set_icon(self._handle, _encode_c_string(value))
self._icon = value
def destroy(self) -> None:
for name in list(self._callbacks.keys()):
self.unbind(name)
@@ -269,7 +237,9 @@ class Webview:
name,
method,
)
c_callback = _webview_lib.binding_callback_t(wrapper)
c_callback = _webview_lib.CFUNCTYPE(
None, ctypes.c_char_p, ctypes.c_char_p, ctypes.c_void_p
)(wrapper)
if name in self._callbacks:
msg = f"Callback {name} already exists. Skipping binding."
@@ -291,7 +261,9 @@ class Webview:
success = False
self.return_(seq.decode(), 0 if success else 1, json.dumps(result))
c_callback = _webview_lib.binding_callback_t(wrapper)
c_callback = _webview_lib.CFUNCTYPE(
None, ctypes.c_char_p, ctypes.c_char_p, ctypes.c_void_p
)(wrapper)
self._callbacks[name] = c_callback
_webview_lib.webview_bind(
self._handle, _encode_c_string(name), c_callback, None

View File

@@ -1,34 +1,34 @@
{
perSystem =
{
lib,
self',
pkgs,
config,
...
}:
{
packages = {
webview-lib = pkgs.callPackage ./webview-lib { };
clan-app = pkgs.callPackage ./default.nix {
inherit (config.packages) clan-cli clan-app-ui webview-lib;
pythonRuntime = pkgs.python3;
};
packages =
{
webview-lib = pkgs.callPackage ./webview-lib { };
clan-app = pkgs.callPackage ./default.nix {
inherit (config.packages) clan-cli clan-app-ui webview-lib;
pythonRuntime = pkgs.python3;
};
fonts = pkgs.callPackage ./fonts.nix { };
fonts = pkgs.callPackage ./fonts.nix { };
clan-app-ui = pkgs.callPackage ./ui.nix {
clan-ts-api = config.packages.clan-ts-api;
fonts = config.packages.fonts;
};
clan-app-ui = pkgs.callPackage ./ui.nix {
clan-ts-api = config.packages.clan-ts-api;
fonts = config.packages.fonts;
};
};
# //
# todo add darwin support
# todo re-enable
# see ui.nix for an explanation of why this is disabled for now
# (lib.optionalAttrs pkgs.stdenv.hostPlatform.isLinux {
# clan-app-ui-storybook = self'.packages.clan-app-ui.storybook;
# });
}
//
# todo add darwin support
(lib.optionalAttrs pkgs.stdenv.hostPlatform.isLinux {
clan-app-ui-storybook = self'.packages.clan-app-ui.storybook;
});
devShells.clan-app = pkgs.callPackage ./shell.nix {
inherit self';

View File

@@ -1,5 +0,0 @@
#!/usr/bin/env bash
PYTHON_DIR=$(dirname "$(which python3)")/..
gdb --quiet -ex "source $PYTHON_DIR/share/gdb/libpython.py" --ex "sharedlib $WEBVIEW_LIB_DIR/libwebview.so" --ex "run" --args python "$@"

View File

@@ -30,7 +30,7 @@ norecursedirs = "tests/helpers"
markers = ["impure"]
[tool.mypy]
python_version = "3.13"
python_version = "3.12"
warn_redundant_casts = true
disallow_untyped_calls = true
disallow_untyped_defs = true

View File

@@ -89,10 +89,9 @@ mkShell {
popd
# configure process-compose
if test -f "$CLAN_CORE_PATH/pkgs/clan-app/.local.env"; then
source "$CLAN_CORE_PATH/pkgs/clan-app/.local.env"
if test -f "$GIT_ROOT/pkgs/clan-app/.local.env"; then
source "$GIT_ROOT/pkgs/clan-app/.local.env"
fi
export PC_CONFIG_FILES="$CLAN_CORE_PATH/pkgs/clan-app/process-compose.yaml"
echo -e "${GREEN}To launch a qemu VM for testing, run:\n start-vm <number of VMs>${NC}"

View File

@@ -7,7 +7,7 @@ import pytest
@pytest.fixture(scope="session")
def wayland_compositor() -> Generator[Popen]:
def wayland_compositor() -> Generator[Popen, None, None]:
# Start the Wayland compositor (e.g., Weston)
# compositor = Popen(["weston", "--backend=headless-backend.so"])
compositor = Popen(["weston"])
@@ -20,7 +20,7 @@ GtkProc = NewType("GtkProc", Popen)
@pytest.fixture
def app() -> Generator[GtkProc]:
def app() -> Generator[GtkProc, None, None]:
cmd = [sys.executable, "-m", "clan_app"]
print(f"Running: {cmd}")
rapp = Popen(

View File

@@ -1 +0,0 @@
../ui/package.json

View File

@@ -0,0 +1,84 @@
{
"name": "@clan/ui",
"version": "0.0.1",
"description": "",
"type": "module",
"scripts": {
"start": "vite",
"dev": "vite",
"build": "npm run check && npm run test && vite build && npm run convert-html",
"convert-html": "node gtk.webview.js",
"serve": "vite preview",
"check": "tsc --noEmit --skipLibCheck && eslint ./src --fix",
"knip": "knip --fix",
"test": "vitest run --project unit --typecheck",
"storybook": "storybook",
"storybook-build": "storybook build",
"storybook-dev": "storybook dev -p 6006",
"test-storybook": "vitest run --project storybook",
"test-storybook-update-snapshots": "vitest run --project storybook --update",
"test-storybook-static": "npm run storybook-build && concurrently -k -s first -n 'SB,TEST' -c 'magenta,blue' 'npx http-server storybook-static --port 6006 --silent' 'npx wait-on tcp:127.0.0.1:6006 && npm run test-storybook'"
},
"license": "MIT",
"devDependencies": {
"@eslint/js": "^9.3.0",
"@kachurun/storybook-solid": "^9.0.11",
"@kachurun/storybook-solid-vite": "^9.0.11",
"@storybook/addon-a11y": "^9.0.8",
"@storybook/addon-docs": "^9.0.8",
"@storybook/addon-links": "^9.0.8",
"@storybook/addon-onboarding": "^9.0.8",
"@storybook/addon-vitest": "^9.0.8",
"@tailwindcss/typography": "^0.5.13",
"@types/json-schema": "^7.0.15",
"@types/node": "^22.15.19",
"@vitest/browser": "^3.2.3",
"autoprefixer": "^10.4.19",
"classnames": "^2.5.1",
"concurrently": "^9.1.2",
"eslint": "^9.27.0",
"eslint-plugin-tailwindcss": "^3.17.0",
"jsdom": "^26.1.0",
"knip": "^5.61.2",
"postcss": "^8.4.38",
"prettier": "^3.2.5",
"solid-devtools": "^0.34.0",
"storybook": "^9.0.8",
"tailwindcss": "^4.0.0",
"typescript": "^5.4.5",
"typescript-eslint": "^8.32.1",
"vite": "^7.0.0",
"vite-plugin-solid": "^2.8.2",
"vite-plugin-solid-svg": "^0.8.1",
"vitest": "^3.2.3"
},
"dependencies": {
"@floating-ui/dom": "^1.6.8",
"@kobalte/core": "^0.13.10",
"@kobalte/tailwindcss": "^0.9.0",
"@modular-forms/solid": "^0.25.1",
"@solid-primitives/storage": "^4.3.2",
"@solidjs/router": "^0.15.3",
"@tanstack/eslint-plugin-query": "^5.51.12",
"@tanstack/solid-query": "^5.76.0",
"corvu": "^0.7.1",
"nanoid": "^5.0.7",
"solid-js": "^1.9.7",
"solid-toast": "^0.5.0"
},
"optionalDependencies": {
"@esbuild/darwin-arm64": "^0.25.4",
"@esbuild/darwin-x64": "^0.25.4",
"@esbuild/linux-arm64": "^0.25.4",
"@esbuild/linux-x64": "^0.25.4"
},
"overrides": {
"vite": {
"rollup": "npm:@rollup/wasm-node@^4.34.9"
},
"@rollup/rollup-darwin-x64": "npm:@rollup/wasm-node@^4.34.9",
"@rollup/rollup-linux-x64": "npm:@rollup/wasm-node@^4.34.9",
"@rollup/rollup-darwin-arm64": "npm:@rollup/wasm-node@^4.34.9",
"@rollup/rollup-linux-arm64": "npm:@rollup/wasm-node@^4.34.9"
}
}

View File

@@ -19,6 +19,7 @@ import {
} from "@/src/components/inputBase";
import { FieldLayout } from "./layout";
import Icon from "@/src/components/icon";
import { useContext } from "corvu/dialog";
interface Option {
value: string;
@@ -50,6 +51,9 @@ interface SelectInputpProps {
}
export function SelectInput(props: SelectInputpProps) {
const dialogContext = (dialogContextId?: string) =>
useContext(dialogContextId);
const _id = createUniqueId();
const [reference, setReference] = createSignal<HTMLElement>();

View File

@@ -23,61 +23,37 @@ export type SuccessQuery<T extends OperationNames> = Extract<
>;
export type SuccessData<T extends OperationNames> = SuccessQuery<T>["data"];
interface SendHeaderType {
logging?: { group_path: string[] };
}
interface BackendSendType<K extends OperationNames> {
body: OperationArgs<K>;
header?: SendHeaderType;
}
// eslint-disable-next-line @typescript-eslint/no-empty-object-type
interface ReceiveHeaderType {}
interface BackendReturnType<K extends OperationNames> {
body: OperationResponse<K>;
header: ReceiveHeaderType;
}
const _callApi = <K extends OperationNames>(
method: K,
args: OperationArgs<K>,
backendOpts?: SendHeaderType,
): { promise: Promise<BackendReturnType<K>>; op_key: string } => {
): { promise: Promise<OperationResponse<K>>; op_key: string } => {
// if window[method] does not exist, throw an error
if (!(method in window)) {
console.error(`Method ${method} not found on window object`);
// return a rejected promise
return {
promise: Promise.resolve({
body: {
status: "error",
errors: [
{
message: `Method ${method} not found on window object`,
code: "method_not_found",
},
],
op_key: "noop",
},
header: {},
status: "error",
errors: [
{
message: `Method ${method} not found on window object`,
code: "method_not_found",
},
],
op_key: "noop",
}),
op_key: "noop",
};
}
const message: BackendSendType<OperationNames> = {
body: args,
header: backendOpts,
};
const promise = (
window as unknown as Record<
OperationNames,
(
args: BackendSendType<OperationNames>,
) => Promise<BackendReturnType<OperationNames>>
args: OperationArgs<OperationNames>,
) => Promise<OperationResponse<OperationNames>>
>
)[method](message) as Promise<BackendReturnType<K>>;
)[method](args) as Promise<OperationResponse<K>>;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const op_key = (promise as any)._webviewMessageId as string;
@@ -87,7 +63,7 @@ const _callApi = <K extends OperationNames>(
const handleCancel = async <K extends OperationNames>(
ops_key: string,
orig_task: Promise<BackendReturnType<K>>,
orig_task: Promise<OperationResponse<K>>,
) => {
console.log("Canceling operation: ", ops_key);
const { promise, op_key } = _callApi("cancel_task", { task_id: ops_key });
@@ -107,7 +83,7 @@ const handleCancel = async <K extends OperationNames>(
});
const resp = await promise;
if (resp.body.status === "error") {
if (resp.status === "error") {
toast.custom(
(t) => (
<ErrorToastComponent
@@ -129,11 +105,10 @@ const handleCancel = async <K extends OperationNames>(
export const callApi = <K extends OperationNames>(
method: K,
args: OperationArgs<K>,
backendOpts?: SendHeaderType,
): { promise: Promise<OperationResponse<K>>; op_key: string } => {
console.log("Calling API", method, args, backendOpts);
console.log("Calling API", method, args);
const { promise, op_key } = _callApi(method, args, backendOpts);
const { promise, op_key } = _callApi(method, args);
promise.catch((error) => {
toast.custom(
(t) => (
@@ -171,14 +146,13 @@ export const callApi = <K extends OperationNames>(
console.log("Not printing toast because operation was cancelled");
}
const body = response.body;
if (body.status === "error" && !cancelled) {
if (response.status === "error" && !cancelled) {
toast.remove(toastId);
toast.custom(
(t) => (
<ErrorToastComponent
t={t}
message={"Error: " + body.errors[0].message}
message={"Error: " + response.errors[0].message}
/>
),
{
@@ -188,8 +162,7 @@ export const callApi = <K extends OperationNames>(
} else {
toast.remove(toastId);
}
return body;
return response;
});
return { promise: new_promise, op_key: op_key };
};

View File

@@ -61,7 +61,7 @@ export const ApiTester = () => {
return await callApi(
values.endpoint as keyof API,
JSON.parse(values.payload || "{}"),
).promise;
);
},
staleTime: Infinity,
enabled: false,

View File

@@ -27,5 +27,5 @@
}
.button--dark-active:active {
@apply active:border-secondary-900;
@apply active:border-secondary-900 active:shadow-button-primary-active;
}

View File

@@ -7,5 +7,5 @@
}
.button--ghost-active:active {
@apply active:bg-secondary-200 active:text-secondary-900;
@apply active:bg-secondary-200 active:text-secondary-900 active:shadow-button-primary-active;
}

View File

@@ -27,7 +27,7 @@
}
.button--light-active:active {
@apply active:bg-secondary-200 border-secondary-600 active:text-secondary-900;
@apply active:bg-secondary-200 border-secondary-600 active:text-secondary-900 active:shadow-button-primary-active;
box-shadow: inset 2px 2px theme(backgroundColor.secondary.300);

View File

@@ -17,7 +17,7 @@ const defaultRemoteData: RemoteData = {
private_key: undefined,
password: "",
forward_agent: true,
host_key_check: "strict",
host_key_check: 0,
verbose_ssh: false,
ssh_options: {},
tor_socks: false,
@@ -32,7 +32,7 @@ const sampleRemoteData: RemoteData = {
private_key: undefined,
password: "",
forward_agent: true,
host_key_check: "ask",
host_key_check: 1,
verbose_ssh: false,
ssh_options: {
StrictHostKeyChecking: "no",
@@ -238,7 +238,7 @@ const advancedRemoteData: RemoteData = {
private_key: undefined,
password: "",
forward_agent: false,
host_key_check: "none",
host_key_check: 2,
verbose_ssh: true,
ssh_options: {
ConnectTimeout: "10",

View File

@@ -11,6 +11,13 @@ import { Loader } from "@/src/components/v2/Loader/Loader";
import { Button } from "@/src/components/v2/Button/Button";
import Accordion from "@/src/components/accordion";
// Define the HostKeyCheck enum values with proper API mapping
export enum HostKeyCheck {
ASK = 0,
TOFU = 1,
IGNORE = 2,
}
// Export the API types for use in other components
export type { RemoteData, Machine, RemoteDataSource };
@@ -178,6 +185,40 @@ export function RemoteForm(props: RemoteFormProps) {
const [formData, setFormData] = createSignal<RemoteData | null>(null);
const [isSaving, setIsSaving] = createSignal(false);
const hostKeyCheckOptions = [
{ value: "ASK", label: "Ask" },
{ value: "TOFU", label: "TOFU (Trust On First Use)" },
{ value: "IGNORE", label: "Ignore" },
];
// Helper function to convert enum name to numeric value
const getHostKeyCheckValue = (name: string): number => {
switch (name) {
case "ASK":
return HostKeyCheck.ASK;
case "TOFU":
return HostKeyCheck.TOFU;
case "IGNORE":
return HostKeyCheck.IGNORE;
default:
return HostKeyCheck.ASK;
}
};
// Helper function to convert numeric value to enum name
const getHostKeyCheckName = (value: number | undefined): string => {
switch (value) {
case HostKeyCheck.ASK:
return "ASK";
case HostKeyCheck.TOFU:
return "TOFU";
case HostKeyCheck.IGNORE:
return "IGNORE";
default:
return "ASK";
}
};
// Query host data when machine is provided
const hostQuery = useQuery(() => ({
queryKey: [
@@ -186,7 +227,6 @@ export function RemoteForm(props: RemoteFormProps) {
props.queryFn,
props.machine?.name,
props.machine?.flake,
props.machine?.flake.identifier,
props.field || "targetHost",
],
queryFn: async () => {
@@ -201,24 +241,11 @@ export function RemoteForm(props: RemoteFormProps) {
});
}
const result = await callApi(
"get_host",
{
name: props.machine.name,
flake: props.machine.flake,
field: props.field || "targetHost",
},
{
logging: {
group_path: [
"clans",
props.machine.flake.identifier,
"machines",
props.machine.name,
],
},
},
).promise;
const result = await callApi("get_host", {
name: props.machine.name,
flake: props.machine.flake,
field: props.field || "targetHost",
}).promise;
if (result.status === "error")
throw new Error("Failed to fetch host data");
@@ -345,13 +372,16 @@ export function RemoteForm(props: RemoteFormProps) {
<SelectInput
label="Host Key Check"
value={formData()?.host_key_check || "ask"}
options={[
{ value: "ask", label: "Ask" },
{ value: "none", label: "None" },
{ value: "strict", label: "Strict" },
{ value: "tofu", label: "Trust on First Use" },
]}
value={getHostKeyCheckName(formData()?.host_key_check)}
options={hostKeyCheckOptions}
selectProps={{
onInput: (e) =>
updateFormData({
host_key_check: getHostKeyCheckValue(
e.currentTarget.value,
) as 0 | 1 | 2 | 3,
}),
}}
disabled={computedDisabled}
helperText="How to handle host key verification"
/>

View File

@@ -1,39 +0,0 @@
import { JSX, Show } from "solid-js";
interface SimpleModalProps {
open: boolean;
onClose: () => void;
title?: string;
children: JSX.Element;
}
export const SimpleModal = (props: SimpleModalProps) => {
return (
<Show when={props.open}>
<div class="fixed inset-0 z-50 flex items-center justify-center">
{/* Backdrop */}
<div class="fixed inset-0 bg-black/50" onClick={props.onClose} />
{/* Modal Content */}
<div class="relative mx-4 w-full max-w-md rounded-lg bg-white shadow-lg">
{/* Header */}
<Show when={props.title}>
<div class="flex items-center justify-between border-b p-4">
<h3 class="text-lg font-semibold">{props.title}</h3>
<button
type="button"
class="text-gray-400 hover:text-gray-600"
onClick={props.onClose}
>
×
</button>
</div>
</Show>
{/* Body */}
<div>{props.children}</div>
</div>
</div>
</Show>
);
};

View File

@@ -125,7 +125,7 @@ export const InputLabel = (props: InputLabelProps) => {
weight="bold"
class="inline-flex gap-1 align-middle !fg-def-1"
classList={{
[cx("!text-red-600")]: !!props.error,
[cx("!fg-semantic-1")]: !!props.error,
}}
aria-invalid={props.error}
>
@@ -185,7 +185,7 @@ export const InputError = (props: InputErrorProps) => {
// @ts-expect-error: Dependent type is to complex to check how it is coupled to the override for now
size="xxs"
weight="medium"
class={cx("col-span-full px-1 !text-red-500", typoClasses)}
class={cx("col-span-full px-1 !fg-semantic-4", typoClasses)}
{...rest}
>
{props.error}

View File

@@ -47,17 +47,11 @@ export const MachineListItem = (props: MachineListItemProps) => {
);
return;
}
const target_host = await callApi(
"get_host",
{
field: "targetHost",
flake: { identifier: active_clan },
name: name,
},
{
logging: { group_path: ["clans", active_clan, "machines", name] },
},
).promise;
const target_host = await callApi("get_host", {
field: "targetHost",
flake: { identifier: active_clan },
name: name,
}).promise;
if (target_host.status == "error") {
console.error("No target host found for the machine");
@@ -85,6 +79,7 @@ export const MachineListItem = (props: MachineListItemProps) => {
},
no_reboot: true,
debug: true,
nix_options: [],
password: null,
},
target_host: target_host.data!.data,
@@ -109,19 +104,11 @@ export const MachineListItem = (props: MachineListItemProps) => {
}
setUpdating(true);
const target_host = await callApi(
"get_host",
{
field: "targetHost",
flake: { identifier: active_clan },
name: name,
},
{
logging: {
group_path: ["clans", active_clan, "machines", name],
},
},
).promise;
const target_host = await callApi("get_host", {
field: "targetHost",
flake: { identifier: active_clan },
name: name,
}).promise;
if (target_host.status == "error") {
console.error("No target host found for the machine");
@@ -138,19 +125,11 @@ export const MachineListItem = (props: MachineListItemProps) => {
return;
}
const build_host = await callApi(
"get_host",
{
field: "buildHost",
flake: { identifier: active_clan },
name: name,
},
{
logging: {
group_path: ["clans", active_clan, "machines", name],
},
},
).promise;
const build_host = await callApi("get_host", {
field: "buildHost",
flake: { identifier: active_clan },
name: name,
}).promise;
if (build_host.status == "error") {
console.error("No target host found for the machine");
@@ -162,24 +141,16 @@ export const MachineListItem = (props: MachineListItemProps) => {
return;
}
await callApi(
"deploy_machine",
{
machine: {
name: name,
flake: {
identifier: active_clan,
},
},
target_host: target_host.data!.data,
build_host: build_host.data?.data || null,
},
{
logging: {
group_path: ["clans", active_clan, "machines", name],
await callApi("deploy_machine", {
machine: {
name: name,
flake: {
identifier: active_clan,
},
},
).promise;
target_host: target_host.data!.data,
build_host: build_host.data?.data || null,
}).promise;
setUpdating(false);
};

View File

@@ -0,0 +1,134 @@
import Dialog from "corvu/dialog";
import { createSignal, JSX } from "solid-js";
import { Button } from "../Button/Button";
import Icon from "../icon";
import cx from "classnames";
interface ModalProps {
open: boolean | undefined;
handleClose: () => void;
title: string;
children: JSX.Element;
class?: string;
}
export const Modal = (props: ModalProps) => {
const [dragging, setDragging] = createSignal(false);
const [startOffset, setStartOffset] = createSignal({ x: 0, y: 0 });
let dialogRef: HTMLDivElement;
const handleMouseDown = (e: MouseEvent) => {
setDragging(true);
const rect = dialogRef.getBoundingClientRect();
setStartOffset({
x: e.clientX - rect.left,
y: e.clientY - rect.top,
});
};
const handleMouseMove = (e: MouseEvent) => {
if (dragging()) {
let newTop = e.clientY - startOffset().y;
let newLeft = e.clientX - startOffset().x;
if (newTop < 0) {
newTop = 0;
}
if (newLeft < 0) {
newLeft = 0;
}
dialogRef.style.top = `${newTop}px`;
dialogRef.style.left = `${newLeft}px`;
// dialogRef.style.maxHeight = `calc(100vh - ${newTop}px - 2rem)`;
// dialogRef.style.maxHeight = `calc(100vh - ${newTop}px - 2rem)`;
}
};
const handleMouseUp = () => setDragging(false);
return (
<Dialog open={props.open} trapFocus={true}>
<Dialog.Portal>
<Dialog.Overlay
class="fixed inset-0 z-50 bg-black/50"
onMouseMove={handleMouseMove}
/>
<Dialog.Content
class={cx(
"overflow-hidden absolute left-1/3 top-1/3 z-50 min-w-[560px] rounded-md border border-def-4 focus-visible:outline-none",
props.class,
)}
classList={{
"!cursor-grabbing": dragging(),
[cx("scale-[101%] transition-transform")]: dragging(),
}}
ref={(el) => {
dialogRef = el;
}}
onMouseMove={handleMouseMove}
onMouseUp={handleMouseUp}
onMouseDown={(e: MouseEvent) => {
e.stopPropagation(); // Prevent backdrop drag conflict
}}
onClick={(e: MouseEvent) => e.stopPropagation()} // Prevent backdrop click closing
>
<Dialog.Label
as="div"
class="flex w-full justify-center border-b-2 px-4 py-2 align-middle bg-def-3 border-def-4"
onMouseDown={handleMouseDown}
>
<div
class="flex w-full cursor-move flex-col gap-px py-1 "
classList={{
"!cursor-grabbing": dragging(),
}}
>
<hr class="h-px w-full border-none bg-secondary-300" />
<hr class="h-px w-full border-none bg-secondary-300" />
<hr class="h-px w-full border-none bg-secondary-300" />
<hr class="h-px w-full border-none bg-secondary-300" />
<hr class="h-px w-full border-none bg-secondary-300" />
<hr class="h-px w-full border-none bg-secondary-300" />
</div>
<span class="mx-2 select-none whitespace-nowrap">
{props.title}
</span>
<div
class="flex w-full cursor-move flex-col gap-px py-1 "
classList={{
"!cursor-grabbing": dragging(),
}}
>
<hr class="h-px w-full border-none bg-secondary-300" />
<hr class="h-px w-full border-none bg-secondary-300" />
<hr class="h-px w-full border-none bg-secondary-300" />
<hr class="h-px w-full border-none bg-secondary-300" />
<hr class="h-px w-full border-none bg-secondary-300" />
<hr class="h-px w-full border-none bg-secondary-300" />
</div>
<div class="absolute right-1 top-2 pl-1 bg-def-3">
<Button
onMouseDown={(e) => e.stopPropagation()}
tabIndex={-1}
class="size-4"
variant="ghost"
onClick={() => props.handleClose()}
size="s"
startIcon={<Icon icon={"Close"} />}
/>
</div>
</Dialog.Label>
<Dialog.Description
class="flex max-h-[90vh] flex-col overflow-y-hidden bg-def-1"
as="div"
>
{props.children}
</Dialog.Description>
</Dialog.Content>
</Dialog.Portal>
</Dialog>
);
};

View File

@@ -1,4 +1,4 @@
/* @import "material-icons/iconfont/filled.css"; */
@import "material-icons/iconfont/filled.css";
/* List of icons: https://marella.me/material-icons/demo/ */
/* @import url(./components/Typography/css/typography.css); */

View File

@@ -19,12 +19,10 @@ import { createEffect, createSignal } from "solid-js"; // For, Show might not be
import toast from "solid-toast";
import { FieldLayout } from "@/src/Form/fields/layout";
import { InputLabel } from "@/src/components/inputBase";
import { Modal } from "@/src/components/modal";
import Fieldset from "@/src/Form/fieldset"; // Still used for other fieldsets
import Accordion from "@/src/components/accordion";
import { SimpleModal } from "@/src/components/SimpleModal";
// Import the new generic component
import {
FileSelectorField,
@@ -194,11 +192,12 @@ export const Flash = () => {
return (
<>
<Header title="Flash installer" />
<SimpleModal
<Modal
open={confirmOpen() || isFlashing()}
onClose={() => !isFlashing() && setConfirmOpen(false)}
handleClose={() => !isFlashing() && setConfirmOpen(false)}
title="Confirm"
>
{/* ... Modal content as before ... */}
<div class="flex flex-col gap-4 p-4">
<div class="flex flex-col justify-between rounded-sm border p-4 align-middle text-red-900 border-def-2">
<Typography
@@ -231,7 +230,7 @@ export const Flash = () => {
</Button>
</div>
</div>
</SimpleModal>
</Modal>
<div class="w-full self-stretch p-8">
<Form
onSubmit={handleSubmit}

View File

@@ -125,6 +125,7 @@ export function InstallMachine(props: InstallMachineProps) {
machine: {
name: props.name,
flake: { identifier: curr_uri },
private_key: values.sshKey?.name,
},
},
target_host: targetHostResponse.data.data,

View File

@@ -77,18 +77,10 @@ export function MachineForm(props: MachineFormProps) {
if (!machine_name || !base_dir) {
return [];
}
const result = await callApi(
"get_generators_closure",
{
base_dir: base_dir,
machine_name: machine_name,
},
{
logging: {
group_path: ["clans", base_dir, "machines", machine_name],
},
},
).promise;
const result = await callApi("get_generators_closure", {
base_dir: base_dir,
machine_name: machine_name,
}).promise;
if (result.status === "error") throw new Error("Failed to fetch data");
return result.data;
},
@@ -120,21 +112,13 @@ export function MachineForm(props: MachineFormProps) {
return;
}
const target = await callApi(
"get_host",
{
field: "targetHost",
name: machine,
flake: {
identifier: curr_uri,
},
const target = await callApi("get_host", {
field: "targetHost",
name: machine,
flake: {
identifier: curr_uri,
},
{
logging: {
group_path: ["clans", curr_uri, "machines", machine],
},
},
).promise;
}).promise;
if (target.status === "error") {
toast.error("Failed to get target host");
@@ -148,26 +132,18 @@ export function MachineForm(props: MachineFormProps) {
const target_host = target.data.data;
setIsUpdating(true);
const r = await callApi(
"deploy_machine",
{
machine: {
name: machine,
flake: {
identifier: curr_uri,
},
},
target_host: {
...target_host,
},
build_host: null,
},
{
logging: {
group_path: ["clans", curr_uri, "machines", machine],
const r = await callApi("deploy_machine", {
machine: {
name: machine,
flake: {
identifier: curr_uri,
},
},
).promise.finally(() => {
target_host: {
...target_host,
},
build_host: null,
}).promise.finally(() => {
setIsUpdating(false);
});
};

View File

@@ -44,7 +44,7 @@ export const HWStep = (props: StepProps<HardwareValues>) => {
command_prefix: "sudo",
port: 22,
forward_agent: false,
host_key_check: "ask", // 0 = ASK
host_key_check: 1, // 0 = ASK
verbose_ssh: false,
ssh_options: {},
tor_socks: false,

View File

@@ -149,19 +149,11 @@ export const VarsStep = (props: VarsStepProps) => {
const generatorsQuery = createQuery(() => ({
queryKey: [props.dir, props.machine_id, "generators", props.fullClosure],
queryFn: async () => {
const result = await callApi(
"get_generators_closure",
{
base_dir: props.dir,
machine_name: props.machine_id,
full_closure: props.fullClosure,
},
{
logging: {
group_path: ["clans", props.dir, "machines", props.machine_id],
},
},
).promise;
const result = await callApi("get_generators_closure", {
base_dir: props.dir,
machine_name: props.machine_id,
full_closure: props.fullClosure,
}).promise;
if (result.status === "error") throw new Error("Failed to fetch data");
return result.data;
},

View File

@@ -16,22 +16,14 @@ export const MachineInstall = () => {
queryFn: async () => {
const curr = activeClanURI();
if (curr) {
const result = await callApi(
"get_machine_details",
{
machine: {
flake: {
identifier: curr,
},
name: params.id,
const result = await callApi("get_machine_details", {
machine: {
flake: {
identifier: curr,
},
name: params.id,
},
{
logging: {
group_path: ["clans", curr, "machines", params.id],
},
},
).promise;
}).promise;
if (result.status === "error") throw new Error("Failed to fetch data");
return result.data;
}

View File

@@ -38,7 +38,6 @@ export const MachineListView: Component = () => {
},
}).promise;
console.log("response", response);
if (response.status === "error") {
console.error("Failed to fetch data");
} else {

View File

@@ -3,9 +3,11 @@
nodejs_22,
importNpmLock,
clan-ts-api,
playwright-driver,
ps,
fonts,
}:
buildNpmPackage (_finalAttrs: {
buildNpmPackage (finalAttrs: {
pname = "clan-app-ui";
version = "0.0.1";
nodejs = nodejs_22;
@@ -23,39 +25,35 @@ buildNpmPackage (_finalAttrs: {
cp -r ${fonts} ".fonts"
'';
# todo figure out why this fails only inside of Nix
# Something about passing orientation in any of the Form stories is causing the browser to crash
# `npm run test-storybook-static` works fine in the devshell
#
# passthru = rec {
# storybook = buildNpmPackage {
# pname = "${finalAttrs.pname}-storybook";
# inherit (finalAttrs)
# version
# nodejs
# src
# npmDeps
# npmConfigHook
# preBuild
# ;
#
# nativeBuildInputs = finalAttrs.nativeBuildInputs ++ [
# ps
# ];
#
# npmBuildScript = "test-storybook-static";
#
# env = finalAttrs.env // {
# PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD = 1;
# PLAYWRIGHT_BROWSERS_PATH = "${playwright-driver.browsers.override {
# withChromiumHeadlessShell = true;
# }}";
# PLAYWRIGHT_HOST_PLATFORM_OVERRIDE = "ubuntu-24.04";
# };
#
# postBuild = ''
# mv storybook-static $out
# '';
# };
# };
passthru = rec {
storybook = buildNpmPackage {
pname = "${finalAttrs.pname}-storybook";
inherit (finalAttrs)
version
nodejs
src
npmDeps
npmConfigHook
preBuild
;
nativeBuildInputs = finalAttrs.nativeBuildInputs ++ [
ps
];
npmBuildScript = "test-storybook-static";
env = finalAttrs.env // {
PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD = 1;
PLAYWRIGHT_BROWSERS_PATH = "${playwright-driver.browsers.override {
withChromiumHeadlessShell = true;
}}";
PLAYWRIGHT_HOST_PLATFORM_OVERRIDE = "ubuntu-24.04";
};
postBuild = ''
mv storybook-static $out
'';
};
};
})

View File

@@ -43,11 +43,9 @@
"eslint": "^9.27.0",
"eslint-plugin-tailwindcss": "^3.17.0",
"eslint-plugin-unused-imports": "^4.1.4",
"extend": "^3.0.2",
"http-server": "^14.1.1",
"jsdom": "^26.1.0",
"knip": "^5.61.2",
"markdown-to-jsx": "^7.7.10",
"playwright": "~1.52.0",
"postcss": "^8.4.38",
"postcss-url": "^10.1.3",
@@ -4531,13 +4529,6 @@
"node": ">=12.0.0"
}
},
"node_modules/extend": {
"version": "3.0.2",
"resolved": "https://registry.npmjs.org/extend/-/extend-3.0.2.tgz",
"integrity": "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==",
"dev": true,
"license": "MIT"
},
"node_modules/fast-deep-equal": {
"version": "3.1.3",
"resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz",
@@ -5693,19 +5684,6 @@
"node": ">=10"
}
},
"node_modules/markdown-to-jsx": {
"version": "7.7.10",
"resolved": "https://registry.npmjs.org/markdown-to-jsx/-/markdown-to-jsx-7.7.10.tgz",
"integrity": "sha512-au62yyLyJukhC2P1TYi3uBi/RScGYai69uT72D8a048QH8rRj+yhND3C21GdZHE+6emtsf6Yqemcf//K+EIWDg==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">= 10"
},
"peerDependencies": {
"react": ">= 0.14.0"
}
},
"node_modules/math-intrinsics": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz",

View File

@@ -43,11 +43,9 @@
"eslint": "^9.27.0",
"eslint-plugin-tailwindcss": "^3.17.0",
"eslint-plugin-unused-imports": "^4.1.4",
"extend": "^3.0.2",
"http-server": "^14.1.1",
"jsdom": "^26.1.0",
"knip": "^5.61.2",
"markdown-to-jsx": "^7.7.10",
"playwright": "~1.52.0",
"postcss": "^8.4.38",
"postcss-url": "^10.1.3",

View File

@@ -1,19 +1,26 @@
import { API } from "@/api/API";
import { API, Error as ApiError } from "@/api/API";
import { Schema as Inventory } from "@/api/Inventory";
import { toast } from "solid-toast";
import {
ErrorToastComponent,
CancelToastComponent,
} from "@/src/components/toast";
type OperationNames = keyof API;
type Services = NonNullable<Inventory["services"]>;
type ServiceNames = keyof Services;
export type OperationArgs<T extends OperationNames> = API[T]["arguments"];
type OperationArgs<T extends OperationNames> = API[T]["arguments"];
export type OperationResponse<T extends OperationNames> = API[T]["return"];
export type ClanServiceInstance<T extends ServiceNames> = NonNullable<
type ApiEnvelope<T> =
| {
status: "success";
data: T;
op_key: string;
}
| ApiError;
type Services = NonNullable<Inventory["services"]>;
type ServiceNames = keyof Services;
type ClanService<T extends ServiceNames> = Services[T];
type ClanServiceInstance<T extends ServiceNames> = NonNullable<
Services[T]
>[string];
@@ -21,63 +28,51 @@ export type SuccessQuery<T extends OperationNames> = Extract<
OperationResponse<T>,
{ status: "success" }
>;
export type SuccessData<T extends OperationNames> = SuccessQuery<T>["data"];
type SuccessData<T extends OperationNames> = SuccessQuery<T>["data"];
interface SendHeaderType {
logging?: { group_path: string[] };
}
interface BackendSendType<K extends OperationNames> {
body: OperationArgs<K>;
header?: SendHeaderType;
}
type ErrorQuery<T extends OperationNames> = Extract<
OperationResponse<T>,
{ status: "error" }
>;
type ErrorData<T extends OperationNames> = ErrorQuery<T>["errors"];
// eslint-disable-next-line @typescript-eslint/no-empty-object-type
interface ReceiveHeaderType {}
interface BackendReturnType<K extends OperationNames> {
body: OperationResponse<K>;
header: ReceiveHeaderType;
}
type ClanOperations = Record<OperationNames, (str: string) => void>;
interface GtkResponse<T> {
result: T;
op_key: string;
}
const _callApi = <K extends OperationNames>(
method: K,
args: OperationArgs<K>,
backendOpts?: SendHeaderType,
): { promise: Promise<BackendReturnType<K>>; op_key: string } => {
): { promise: Promise<OperationResponse<K>>; op_key: string } => {
// if window[method] does not exist, throw an error
if (!(method in window)) {
console.error(`Method ${method} not found on window object`);
// return a rejected promise
return {
promise: Promise.resolve({
body: {
status: "error",
errors: [
{
message: `Method ${method} not found on window object`,
code: "method_not_found",
},
],
op_key: "noop",
},
header: {},
status: "error",
errors: [
{
message: `Method ${method} not found on window object`,
code: "method_not_found",
},
],
op_key: "noop",
}),
op_key: "noop",
};
}
const message: BackendSendType<OperationNames> = {
body: args,
header: backendOpts,
};
const promise = (
window as unknown as Record<
OperationNames,
(
args: BackendSendType<OperationNames>,
) => Promise<BackendReturnType<OperationNames>>
args: OperationArgs<OperationNames>,
) => Promise<OperationResponse<OperationNames>>
>
)[method](message) as Promise<BackendReturnType<K>>;
)[method](args) as Promise<OperationResponse<K>>;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const op_key = (promise as any)._webviewMessageId as string;
@@ -87,7 +82,7 @@ const _callApi = <K extends OperationNames>(
const handleCancel = async <K extends OperationNames>(
ops_key: string,
orig_task: Promise<BackendReturnType<K>>,
orig_task: Promise<OperationResponse<K>>,
) => {
console.log("Canceling operation: ", ops_key);
const { promise, op_key } = _callApi("cancel_task", { task_id: ops_key });
@@ -107,7 +102,7 @@ const handleCancel = async <K extends OperationNames>(
});
const resp = await promise;
if (resp.body.status === "error") {
if (resp.status === "error") {
toast.custom(
(t) => (
<ErrorToastComponent
@@ -129,11 +124,10 @@ const handleCancel = async <K extends OperationNames>(
export const callApi = <K extends OperationNames>(
method: K,
args: OperationArgs<K>,
backendOpts?: SendHeaderType,
): { promise: Promise<OperationResponse<K>>; op_key: string } => {
console.log("Calling API", method, args, backendOpts);
console.log("Calling API", method, args);
const { promise, op_key } = _callApi(method, args, backendOpts);
const { promise, op_key } = _callApi(method, args);
promise.catch((error) => {
toast.custom(
(t) => (
@@ -171,14 +165,13 @@ export const callApi = <K extends OperationNames>(
console.log("Not printing toast because operation was cancelled");
}
const body = response.body;
if (body.status === "error" && !cancelled) {
if (response.status === "error" && !cancelled) {
toast.remove(toastId);
toast.custom(
(t) => (
<ErrorToastComponent
t={t}
message={"Error: " + body.errors[0].message}
message={"Error: " + response.errors[0].message}
/>
),
{
@@ -188,8 +181,7 @@ export const callApi = <K extends OperationNames>(
} else {
toast.remove(toastId);
}
return body;
return response;
});
return { promise: new_promise, op_key: op_key };
};

View File

@@ -3,6 +3,9 @@ import { Button, ButtonProps } from "./Button";
import { Component } from "solid-js";
import { expect, fn, waitFor } from "storybook/test";
import { StoryContext } from "@kachurun/storybook-solid-vite";
import { StorybookClock } from "@/tests/clock";
const clock = StorybookClock();
const getCursorStyle = (el: Element) => window.getComputedStyle(el).cursor;
@@ -144,22 +147,32 @@ export default meta;
type Story = StoryObj<ButtonProps>;
const timeout = process.env.NODE_ENV === "test" ? 500 : 2000;
export const Primary: Story = {
args: {
hierarchy: "primary",
onAction: fn(async () => {
// wait 500 ms to simulate an action
await new Promise((resolve) => setTimeout(resolve, timeout));
await new Promise((resolve) => clock.setTimeout(resolve, 2000));
// randomly fail to check that the loading state still returns to normal
if (Math.random() > 0.5) {
throw new Error("Action failure");
}
}),
},
parameters: {
test: {
// increase test timeout to allow for the loading action
mockTimers: true,
},
},
play: async ({ canvas, step, userEvent, args }: StoryContext) => {
play: async ({
canvas,
canvasElement,
step,
userEvent,
args,
}: StoryContext) => {
const buttons = await canvas.findAllByRole("button");
for (const button of buttons) {
@@ -188,20 +201,23 @@ export const Primary: Story = {
// click the button
await userEvent.click(button);
// advance the clock
clock.tick(1);
// check the button has changed
await waitFor(
async () => {
// the action handler should have been called
await expect(args.onAction).toHaveBeenCalled();
// the button should have a loading class
await expect(button).toHaveClass("loading");
// the loader should be visible
await expect(loader.clientWidth).toBeGreaterThan(0);
// the pointer should have changed to wait
await expect(getCursorStyle(button)).toEqual("wait");
},
{ timeout: timeout + 500 },
);
await waitFor(async () => {
// the action handler should have been called
await expect(args.onAction).toHaveBeenCalled();
// the button should have a loading class
await expect(button).toHaveClass("loading");
// the loader should be visible
await expect(loader.clientWidth).toBeGreaterThan(0);
// the pointer should have changed to wait
await expect(getCursorStyle(button)).toEqual("wait");
});
// advance the clock
clock.tick(2000);
// wait for the action handler to finish
await waitFor(
@@ -213,7 +229,7 @@ export const Primary: Story = {
// the pointer should be normal
await expect(getCursorStyle(button)).toEqual("pointer");
},
{ timeout: timeout + 500 },
{ timeout: 2500 },
);
});
}

Some files were not shown because too many files have changed in this diff Show More