clan tests: allow testing CLI interactions

This is an improvement of the clanTest nixos vm test module.

The module now has a new option clan.test.fromFlake that allows to specify a flake.nix as the source for the test clan instead of specifying clan.XXX options.

This in turn allows accessing the `flake.nix` inside the test driver allowing to use the clan cli on it
This commit is contained in:
DavHau
2025-06-11 18:47:12 +07:00
parent 90746e0a19
commit b13f64c96d
10 changed files with 301 additions and 26 deletions

View File

@@ -0,0 +1,61 @@
{
pkgs,
nixosLib,
clan-core,
...
}:
nixosLib.runTest (
{ hostPkgs, config, ... }:
{
imports = [
clan-core.modules.nixosVmTest.clanTest
];
hostPkgs = pkgs;
# This tests the compatibility of the inventory
# With the test framework
# - legacy-modules
# - clan.service modules
name = "dummy-inventory-test-from-flake";
clan.test.fromFlake = ./.;
testScript =
{ nodes, ... }:
''
${clan-core.legacyPackages.${hostPkgs.system}.setupNixInNixPython}
def run_clan(cmd: list[str], **kwargs) -> str:
import subprocess
clan = "${clan-core.packages.${hostPkgs.system}.clan-cli}/bin/clan"
clan_args = ["--flake", "${config.clan.test.flakeForSandbox}"]
return subprocess.run(
["${hostPkgs.util-linux}/bin/unshare", "--user", "--map-user", "1000", "--map-group", "1000", clan, *cmd, *clan_args],
**kwargs,
check=True,
).stdout
start_all()
admin1.wait_for_unit("multi-user.target")
peer1.wait_for_unit("multi-user.target")
# Provided by the legacy module
print(admin1.succeed("systemctl status dummy-service"))
print(peer1.succeed("systemctl status dummy-service"))
# peer1 should have the 'hello' file
peer1.succeed("cat ${nodes.peer1.clan.core.vars.generators.new-service.files.not-a-secret.path}")
ls_out = peer1.succeed("ls -la ${nodes.peer1.clan.core.vars.generators.new-service.files.a-secret.path}")
# Check that the file is owned by 'nobody'
assert "nobody" in ls_out, f"File is not owned by 'nobody': {ls_out}"
# Check that the file is in the 'users' group
assert "users" in ls_out, f"File is not in the 'users' group: {ls_out}"
# Check that the file is in the '0644' mode
assert "-rw-r--r--" in ls_out, f"File is not in the '0644' mode: {ls_out}"
run_clan(["machines", "list"])
'';
}
)

View File

@@ -0,0 +1,70 @@
{
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
# Usage see: https://docs.clan.lol
clan = clan-core.clanLib.buildClan {
inherit self;
inventory =
{ ... }:
{
meta.name = "foo";
machines.peer1 = { };
machines.admin1 = { };
services = {
legacy-module.default = {
roles.peer.machines = [ "peer1" ];
roles.admin.machines = [ "admin1" ];
};
};
instances."test" = {
module.name = "new-service";
roles.peer.machines.peer1 = { };
};
modules = {
legacy-module = ./legacy-module;
};
};
modules.new-service = {
_class = "clan.service";
manifest.name = "new-service";
roles.peer = { };
perMachine = {
nixosModule = {
# This should be generated by:
# nix run .#generate-test-vars -- checks/dummy-inventory-test dummy-inventory-test
clan.core.vars.generators.new-service = {
files.not-a-secret = {
secret = false;
deploy = true;
};
files.a-secret = {
secret = true;
deploy = true;
owner = "nobody";
group = "users";
mode = "0644";
};
script = ''
# This is a dummy script that does nothing
echo -n "not-a-secret" > $out/not-a-secret
echo -n "a-secret" > $out/a-secret
'';
};
};
};
};
};
in
{
# all machines managed by Clan
inherit (clan) nixosConfigurations nixosModules clanInternals;
};
}

View File

@@ -0,0 +1,10 @@
---
description = "Set up dummy-module"
categories = ["System"]
features = [ "inventory" ]
[constraints]
roles.admin.min = 1
roles.admin.max = 1
---

View File

@@ -0,0 +1,5 @@
{
imports = [
../shared.nix
];
}

View File

@@ -0,0 +1,5 @@
{
imports = [
../shared.nix
];
}

View File

@@ -0,0 +1,34 @@
{ config, ... }:
{
systemd.services.dummy-service = {
enable = true;
description = "Dummy service";
wantedBy = [ "multi-user.target" ];
serviceConfig = {
Type = "oneshot";
RemainAfterExit = true;
};
script = ''
generated_password_path="${config.clan.core.vars.generators.dummy-generator.files.generated-password.path}"
if [ ! -f "$generated_password_path" ]; then
echo "Generated password file not found: $generated_password_path"
exit 1
fi
host_id_path="${config.clan.core.vars.generators.dummy-generator.files.host-id.path}"
if [ ! -e "$host_id_path" ]; then
echo "Host ID file not found: $host_id_path"
exit 1
fi
'';
};
# TODO: add and prompt and make it work in the test framework
clan.core.vars.generators.dummy-generator = {
files.host-id.secret = false;
files.generated-password.secret = true;
script = ''
echo $RANDOM > "$out"/host-id
echo $RANDOM > "$out"/generated-password
'';
};
}

View File

@@ -51,6 +51,7 @@ in
postgresql = self.clanLib.test.containerTest ./postgresql nixosTestArgs;
dummy-inventory-test = import ./dummy-inventory-test nixosTestArgs;
dummy-inventory-test-from-flake = import ./dummy-inventory-test-from-flake nixosTestArgs;
data-mesher = import ./data-mesher nixosTestArgs;
}
// lib.optionalAttrs (pkgs.stdenv.hostPlatform.system == "aarch64-linux") {

View File

@@ -7,11 +7,10 @@
let
inherit (lib)
flatten
flip
mapAttrs'
mapAttrsToList
mkForce
mkIf
mkOption
removePrefix
types
unique
;
@@ -24,9 +23,48 @@ in
flake.modules.nixosVmTest.clanTest =
{ config, hostPkgs, ... }:
let
clanFlakeResult = config.clan;
testName = config.name;
clan-core = self;
inherit (lib)
filterAttrs
flip
hasPrefix
intersectAttrs
mapAttrs'
pathExists
removePrefix
throwIf
;
# only relevant if config.clan.test.fromFlake is used
importFlake =
flakeDir:
let
flakeExpr = import (flakeDir + "/flake.nix");
inputs = intersectAttrs flakeExpr.inputs clan-core.inputs;
flake = flakeExpr.outputs (
inputs
// {
self = flake // {
outPath = flakeDir;
};
clan-core = clan-core;
}
);
in
throwIf (pathExists (
flakeDir + "/flake.lock"
)) "Test ${testName} should not have a flake.lock file" flake;
clanFlakeResult =
if config.clan.test.fromFlake != null then importFlake config.clan.test.fromFlake else config.clan;
machineModules = flip filterAttrs clanFlakeResult.nixosModules (
name: _module: hasPrefix "clan-machine-" name
);
update-vars-script = "${
self.packages.${hostPkgs.system}.generate-test-vars
}/bin/generate-test-vars";
@@ -48,7 +86,7 @@ in
);
vars-check =
hostPkgs.runCommand "update-vars-check"
hostPkgs.runCommand "update-vars-check-${testName}"
{
nativeBuildInputs = generatorRuntimeInputs ++ [
hostPkgs.nix
@@ -89,6 +127,16 @@ in
fi
touch $out
'';
# the test's flake.nix with locked clan-core input
flakeForSandbox = hostPkgs.runCommand "offline-flake-for-test-${config.name}" { } ''
cp -r ${config.clan.directory} $out
chmod +w -R $out
substituteInPlace $out/flake.nix \
--replace-fail \
"https://git.clan.lol/clan/clan-core/archive/main.tar.gz" \
"${clan-core.packages.${hostPkgs.system}.clan-core-flake}"
'';
in
{
imports = [
@@ -115,6 +163,9 @@ in
nixpkgs
nix-darwin
;
# By default clan.directory defaults to self, but we don't
# have a sensible default for self here
self = throw "set clan.directory in the test";
};
modules = [
clanLib.buildClanModule.flakePartsModule
@@ -134,6 +185,28 @@ in
type = types.bool;
description = "Whether to use containers for the test.";
};
test.fromFlake = mkOption {
default = null;
type = types.nullOr types.path;
description = ''
path to a directory containing a `flake.nix` defining the clan
Only use this if the clan CLI needs to be used inside the test.
Otherwise, use the other clan.XXX options instead to specify the clan.
Loads the clan from a flake instead of using clan.XXX options.
This has the benefit that a real flake.nix will be available in the test.
This is useful to test CLI interactions which require a flake.nix.
Using this introduces dependencies that should otherwise be avoided.
'';
};
test.flakeForSandbox = mkOption {
default = flakeForSandbox;
type = types.path;
description = "The flake.nix to use for the test.";
readOnly = true;
};
};
}
];
@@ -141,10 +214,12 @@ in
};
};
config = {
clan.directory = mkIf (config.clan.test.fromFlake != null) (mkForce config.clan.test.fromFlake);
# Inherit all nodes from the clan
# i.e. nodes.jon <- clan.machines.jon
# clanInternals.nixosModules contains nixosModules per node
nodes = flip mapAttrs' clanFlakeResult.nixosModules (
nodes = flip mapAttrs' machineModules (
name: machineModule: {
name = removePrefix "clan-machine-" name;
value = machineModule;
@@ -169,11 +244,6 @@ in
clanLib.test.sopsModule
];
# Disable documentation
# This is nice to speed up the evaluation
# And also suppresses any warnings or errors about the documentation
documentation.enable = lib.mkDefault false;
# Disable garbage collection during the test
# https://nix.dev/manual/nix/2.28/command-ref/conf-file.html?highlight=min-free#available-settings
nix.settings.min-free = 0;

View File

@@ -8,4 +8,5 @@
nix.registry = lib.mkForce { };
documentation.doc.enable = false;
documentation.man.enable = false;
documentation.enable = lib.mkDefault false;
}

View File

@@ -1,19 +1,37 @@
{
perSystem = {
legacyPackages.setupNixInNix = ''
export HOME=$TMPDIR
export NIX_STATE_DIR=$TMPDIR/nix
export NIX_CONF_DIR=$TMPDIR/etc
export IN_NIX_SANDBOX=1
export CLAN_TEST_STORE=$TMPDIR/store
# required to prevent concurrent 'nix flake lock' operations
export LOCK_NIX=$TMPDIR/nix_lock
mkdir -p "$CLAN_TEST_STORE/nix/store"
mkdir -p "$CLAN_TEST_STORE/nix/var/nix/gcroots"
if [[ -n "''${closureInfo-}" ]]; then
xargs cp --recursive --target "$CLAN_TEST_STORE/nix/store" < "$closureInfo/store-paths"
nix-store --load-db --store "$CLAN_TEST_STORE" < "$closureInfo/registration"
fi
'';
legacyPackages = {
setupNixInNix = ''
export HOME=$TMPDIR
export NIX_STATE_DIR=$TMPDIR/nix
export NIX_CONF_DIR=$TMPDIR/etc
export IN_NIX_SANDBOX=1
export CLAN_TEST_STORE=$TMPDIR/store
# required to prevent concurrent 'nix flake lock' operations
export LOCK_NIX=$TMPDIR/nix_lock
mkdir -p "$CLAN_TEST_STORE/nix/store"
mkdir -p "$CLAN_TEST_STORE/nix/var/nix/gcroots"
if [[ -n "''${closureInfo-}" ]]; then
xargs cp --recursive --target "$CLAN_TEST_STORE/nix/store" < "$closureInfo/store-paths"
nix-store --load-db --store "$CLAN_TEST_STORE" < "$closureInfo/registration"
fi
'';
setupNixInNixPython = ''
from os import environ
import subprocess
from pathlib import Path
environ['HOME'] = environ['TMPDIR']
environ['NIX_STATE_DIR'] = environ['TMPDIR'] + '/nix'
environ['NIX_CONF_DIR'] = environ['TMPDIR'] + '/etc'
environ['IN_NIX_SANDBOX'] = '1'
environ['CLAN_TEST_STORE'] = environ['TMPDIR'] + '/store'
environ['LOCK_NIX'] = environ['TMPDIR'] + '/nix_lock'
Path(environ['CLAN_TEST_STORE'] + '/nix/store').mkdir(parents=True, exist_ok=True)
Path(environ['CLAN_TEST_STORE'] + '/nix/var/nix/gcroots').mkdir(parents=True, exist_ok=True)
if 'closureInfo' in environ:
subprocess.run(['cp', '--recursive', '--target', environ['CLAN_TEST_STORE'] + '/nix/store'] + environ['closureInfo'].split(), check=True)
subprocess.run(['nix-store', '--load-db', '--store', environ['CLAN_TEST_STORE']] + ['<', environ['closureInfo'] + '/registration'], shell=True, check=True)
'';
};
};
}