Compare commits

..

1 Commits

Author SHA1 Message Date
Johannes Kirschbauer
1f13ce2732 WIP: dont merge 2025-10-09 15:53:21 +02:00
67 changed files with 754 additions and 611 deletions

View File

@@ -2,6 +2,7 @@
self, self,
lib, lib,
inputs, inputs,
privateInputs ? { },
... ...
}: }:
let let
@@ -18,7 +19,20 @@ let
nixosLib = import (self.inputs.nixpkgs + "/nixos/lib") { }; nixosLib = import (self.inputs.nixpkgs + "/nixos/lib") { };
in in
{ {
imports = filter pathExists [ imports =
let
clanCoreModulesDir = ../nixosModules/clanCore;
getClanCoreTestModules =
let
moduleNames = attrNames (builtins.readDir clanCoreModulesDir);
testPaths = map (
moduleName: clanCoreModulesDir + "/${moduleName}/tests/flake-module.nix"
) moduleNames;
in
filter pathExists testPaths;
in
getClanCoreTestModules
++ filter pathExists [
./devshell/flake-module.nix ./devshell/flake-module.nix
./flash/flake-module.nix ./flash/flake-module.nix
./installation/flake-module.nix ./installation/flake-module.nix
@@ -26,10 +40,6 @@ in
./morph/flake-module.nix ./morph/flake-module.nix
./nixos-documentation/flake-module.nix ./nixos-documentation/flake-module.nix
./dont-depend-on-repo-root.nix ./dont-depend-on-repo-root.nix
# clan core submodule tests
../nixosModules/clanCore/machine-id/tests/flake-module.nix
../nixosModules/clanCore/postgresql/tests/flake-module.nix
../nixosModules/clanCore/state-version/tests/flake-module.nix
]; ];
flake.check = genAttrs [ "x86_64-linux" "aarch64-darwin" ] ( flake.check = genAttrs [ "x86_64-linux" "aarch64-darwin" ] (
system: system:
@@ -128,7 +138,7 @@ in
// flakeOutputs // flakeOutputs
// { // {
clan-core-for-checks = pkgs.runCommand "clan-core-for-checks" { } '' clan-core-for-checks = pkgs.runCommand "clan-core-for-checks" { } ''
cp -r ${self} $out cp -r ${privateInputs.clan-core-for-checks} $out
chmod -R +w $out chmod -R +w $out
cp ${../flake.lock} $out/flake.lock cp ${../flake.lock} $out/flake.lock

View File

@@ -15,6 +15,7 @@ let
networking.useNetworkd = true; networking.useNetworkd = true;
services.openssh.enable = true; services.openssh.enable = true;
services.openssh.settings.UseDns = false; services.openssh.settings.UseDns = false;
services.openssh.settings.PasswordAuthentication = false;
system.nixos.variant_id = "installer"; system.nixos.variant_id = "installer";
environment.systemPackages = [ environment.systemPackages = [
pkgs.nixos-facter pkgs.nixos-facter

View File

@@ -50,13 +50,13 @@
dns = dns =
{ pkgs, ... }: { pkgs, ... }:
{ {
environment.systemPackages = [ pkgs.nettools ]; environment.systemPackages = [ pkgs.net-tools ];
}; };
client = client =
{ pkgs, ... }: { pkgs, ... }:
{ {
environment.systemPackages = [ pkgs.nettools ]; environment.systemPackages = [ pkgs.net-tools ];
}; };
server01 = { server01 = {

View File

@@ -1,39 +1,91 @@
The `sshd` Clan service manages SSH to make it easy to securely access your # Clan service: sshd
machines over the internet. The service uses `vars` to store the SSH host keys What it does
for each machine to ensure they remain stable across deployments. - Generates and persists SSH host keys via `vars`.
- Optionally issues CAsigned host certificates for servers.
- Installs the `server` CA public key into `clients` `known_hosts` for TOFUless verification.
`sshd` also generates SSH certificates for both servers and clients allowing for
certificate-based authentication for SSH.
The service also disables password-based authentication over SSH, to access your When to use it
machines you'll need to use public key authentication or certificate-based - ZeroTOFU SSH for dynamic fleets: admins/CI can connect to frequently rebuilt hosts (e.g., server-1.example.com) without prompts or perhost `known_hosts` churn.
authentication.
## Usage Roles
- Server: runs sshd, presents a CAsigned host certificate for `<machine>.<domain>`.
- Client: trusts the CA for the given domains to verify servers certificates.
Tip: assign both roles to a machine if it should both present a cert and verify others.
Quick start (with host certificates)
Useful if you never want to get a prompt about trusting the ssh fingerprint.
```nix
{
inventory.instances = {
sshd-with-certs = {
module = { name = "sshd"; input = "clan-core"; };
# Servers present certificates for <machine>.example.com
roles.server.tags.all = { };
roles.server.settings = {
certificate.searchDomains = [ "example.com" ];
# Optional: also add RSA host keys
# hostKeys.rsa.enable = true;
};
# Clients trust the CA for *.example.com
roles.client.tags.all = { };
roles.client.settings = {
certificate.searchDomains = [ "example.com" ];
};
};
};
}
```
Basic: only add persistent host keys (ed25519), no certificates
Useful if you want to get an ssh "trust this server" prompt once and then never again.
```nix ```nix
{ {
inventory.instances = { inventory.instances = {
# By default this service only generates ed25519 host keys
sshd-basic = { sshd-basic = {
module = { module = {
name = "sshd"; name = "sshd";
input = "clan-core"; input = "clan-core";
}; };
roles.server.tags.all = { }; roles.server.tags.all = { };
roles.client.tags.all = { };
};
# Also generate RSA host keys for all servers
sshd-with-rsa = {
module = {
name = "sshd";
input = "clan-core";
};
roles.server.tags.all = { };
roles.server.settings = {
hostKeys.rsa.enable = true;
};
roles.client.tags.all = { };
}; };
}; };
} }
``` ```
Example: selective trust per environment
Admins should trust only production; CI should trust prod and staging. Servers are reachable under both domains.
```nix
{
inventory.instances = {
sshd-env-scoped = {
module = { name = "sshd"; input = "clan-core"; };
# Servers present certs for both prod and staging FQDNs
roles.server.tags.all = { };
roles.server.settings = {
certificate.searchDomains = [ "prod.example.com" "staging.example.com" ];
};
# Admin laptop: trust prod only
roles.client.machines."admin-laptop".settings = {
certificate.searchDomains = [ "prod.example.com" ];
};
# CI runner: trust prod and staging
roles.client.machines."ci-runner-1".settings = {
certificate.searchDomains = [ "prod.example.com" "staging.example.com" ];
};
};
};
}
```
- Admin -> server1.prod.example.com: zeroTOFU (verified via cert).
- Admin -> server1.staging.example.com: falls back to TOFU (or is blocked by policy).
- CI -> either prod or staging: zeroTOFU for both.
Note: server and client searchDomains dont have to be identical; they only need to overlap for the hostnames you actually use.
Notes
- Connect using a name that matches a cert principal (e.g., `server1.example.com`); wildcards are not allowed inside the certificate.
- CA private key stays in `vars` (not deployed); only the CA public key is distributed.
- Logins still require your user SSH keys on the server (passwords are disabled).

View File

@@ -11,7 +11,9 @@
pkgs.syncthing pkgs.syncthing
]; ];
script = '' script = ''
syncthing generate --config "$out" export TMPDIR=/tmp
TEMPORARY=$(mktemp -d)
syncthing generate --config "$out" --data "$TEMPORARY"
mv "$out"/key.pem "$out"/key mv "$out"/key.pem "$out"/key
mv "$out"/cert.pem "$out"/cert mv "$out"/cert.pem "$out"/cert
cat "$out"/config.xml | grep -oP '(?<=<device id=")[^"]+' | uniq > "$out"/id cat "$out"/config.xml | grep -oP '(?<=<device id=")[^"]+' | uniq > "$out"/id

24
devFlake/flake.lock generated
View File

@@ -3,16 +3,16 @@
"clan-core-for-checks": { "clan-core-for-checks": {
"flake": false, "flake": false,
"locked": { "locked": {
"lastModified": 1760368011, "lastModified": 1760000589,
"narHash": "sha256-mLK2nwbfklfOGIVAKVNDwGyYz8mPh4fzsAqSK3BlCiI=", "narHash": "sha256-9xBwxeb8x5XOo3alaJvv2ZwL7UhW3/oYUUBK+odWGrk=",
"ref": "clan-25.05", "ref": "main",
"rev": "1b3c129aa9741d99b27810652ca888b3fbfc3a11", "rev": "e2f20b5ffcd4ff59e2528d29649056e3eb8d22bb",
"shallow": true, "shallow": true,
"type": "git", "type": "git",
"url": "https://git.clan.lol/clan/clan-core" "url": "https://git.clan.lol/clan/clan-core"
}, },
"original": { "original": {
"ref": "clan-25.05", "ref": "main",
"shallow": true, "shallow": true,
"type": "git", "type": "git",
"url": "https://git.clan.lol/clan/clan-core" "url": "https://git.clan.lol/clan/clan-core"
@@ -105,16 +105,16 @@
}, },
"nixpkgs-dev": { "nixpkgs-dev": {
"locked": { "locked": {
"lastModified": 1760309387, "lastModified": 1759989671,
"narHash": "sha256-e0lvQ7+B1Y8zjykYHAj9tBv10ggLqK0nmxwvMU3J0Eo=", "narHash": "sha256-3Wk0I5TYsd7cyIO8vYGxjOuQ8zraZEUFZqEhSSIhQLs=",
"owner": "NixOS", "owner": "NixOS",
"repo": "nixpkgs", "repo": "nixpkgs",
"rev": "6cd95994a9c8f7c6f8c1f1161be94119afdcb305", "rev": "837076de579c67aa0c2ce2ab49948b24d907d449",
"type": "github" "type": "github"
}, },
"original": { "original": {
"owner": "NixOS", "owner": "NixOS",
"ref": "nixos-25.05-small", "ref": "nixos-unstable-small",
"repo": "nixpkgs", "repo": "nixpkgs",
"type": "github" "type": "github"
} }
@@ -208,11 +208,11 @@
"nixpkgs": [] "nixpkgs": []
}, },
"locked": { "locked": {
"lastModified": 1760120816, "lastModified": 1758728421,
"narHash": "sha256-gq9rdocpmRZCwLS5vsHozwB6b5nrOBDNc2kkEaTXHfg=", "narHash": "sha256-ySNJ008muQAds2JemiyrWYbwbG+V7S5wg3ZVKGHSFu8=",
"owner": "numtide", "owner": "numtide",
"repo": "treefmt-nix", "repo": "treefmt-nix",
"rev": "761ae7aff00907b607125b2f57338b74177697ed", "rev": "5eda4ee8121f97b218f7cc73f5172098d458f1d1",
"type": "github" "type": "github"
}, },
"original": { "original": {

View File

@@ -2,7 +2,7 @@
description = "private dev inputs"; description = "private dev inputs";
# Dev dependencies # Dev dependencies
inputs.nixpkgs-dev.url = "github:NixOS/nixpkgs/nixos-25.05-small"; inputs.nixpkgs-dev.url = "github:NixOS/nixpkgs/nixos-unstable-small";
inputs.flake-utils.url = "github:numtide/flake-utils"; inputs.flake-utils.url = "github:numtide/flake-utils";
inputs.flake-utils.inputs.systems.follows = "systems"; inputs.flake-utils.inputs.systems.follows = "systems";
@@ -15,7 +15,7 @@
inputs.systems.url = "github:nix-systems/default"; inputs.systems.url = "github:nix-systems/default";
inputs.clan-core-for-checks.url = "git+https://git.clan.lol/clan/clan-core?ref=clan-25.05&shallow=1"; inputs.clan-core-for-checks.url = "git+https://git.clan.lol/clan/clan-core?ref=main&shallow=1";
inputs.clan-core-for-checks.flake = false; inputs.clan-core-for-checks.flake = false;
inputs.test-fixtures.url = "git+https://git.clan.lol/clan/test-fixtures"; inputs.test-fixtures.url = "git+https://git.clan.lol/clan/test-fixtures";

View File

@@ -70,6 +70,8 @@ hide:
.clamp-toggle:checked ~ .clamp-more::after { content: "Read less"; } .clamp-toggle:checked ~ .clamp-more::after { content: "Read less"; }
</style> </style>
trivial change
<div class="clamp-wrap" style="--lines: 3;"> <div class="clamp-wrap" style="--lines: 3;">
<input type="checkbox" id="clan-readmore" class="clamp-toggle" /> <input type="checkbox" id="clan-readmore" class="clamp-toggle" />
<div class="clamp-content"> <div class="clamp-content">

23
flake.lock generated
View File

@@ -71,16 +71,15 @@
] ]
}, },
"locked": { "locked": {
"lastModified": 1759509947, "lastModified": 1758805352,
"narHash": "sha256-4XifSIHfpJKcCf5bZZRhj8C4aCpjNBaE3kXr02s4rHU=", "narHash": "sha256-BHdc43Lkayd+72W/NXRKHzX5AZ+28F3xaUs3a88/Uew=",
"owner": "nix-darwin", "owner": "nix-darwin",
"repo": "nix-darwin", "repo": "nix-darwin",
"rev": "000eadb231812ad6ea6aebd7526974aaf4e79355", "rev": "c48e963a5558eb1c3827d59d21c5193622a1477c",
"type": "github" "type": "github"
}, },
"original": { "original": {
"owner": "nix-darwin", "owner": "nix-darwin",
"ref": "nix-darwin-25.05",
"repo": "nix-darwin", "repo": "nix-darwin",
"type": "github" "type": "github"
} }
@@ -115,15 +114,15 @@
}, },
"nixpkgs": { "nixpkgs": {
"locked": { "locked": {
"lastModified": 1760324802, "lastModified": 315532800,
"narHash": "sha256-VWlJtLQ5EQQj45Wj0yTExtSjwRyZ59/qMqEwus/Exlg=", "narHash": "sha256-1tUpklZsKzMGI3gjo/dWD+hS8cf+5Jji8TF5Cfz7i3I=",
"rev": "7e297ddff44a3cc93673bb38d0374df8d0ad73e4", "rev": "08b8f92ac6354983f5382124fef6006cade4a1c1",
"type": "tarball", "type": "tarball",
"url": "https://releases.nixos.org/nixos/25.05/nixos-25.05.811135.7e297ddff44a/nixexprs.tar.xz" "url": "https://releases.nixos.org/nixpkgs/nixpkgs-25.11pre862603.08b8f92ac635/nixexprs.tar.xz"
}, },
"original": { "original": {
"type": "tarball", "type": "tarball",
"url": "https://nixos.org/channels/nixos-25.05/nixexprs.tar.xz" "url": "https://nixos.org/channels/nixpkgs-unstable/nixexprs.tar.xz"
} }
}, },
"root": { "root": {
@@ -182,11 +181,11 @@
] ]
}, },
"locked": { "locked": {
"lastModified": 1760120816, "lastModified": 1758728421,
"narHash": "sha256-gq9rdocpmRZCwLS5vsHozwB6b5nrOBDNc2kkEaTXHfg=", "narHash": "sha256-ySNJ008muQAds2JemiyrWYbwbG+V7S5wg3ZVKGHSFu8=",
"owner": "numtide", "owner": "numtide",
"repo": "treefmt-nix", "repo": "treefmt-nix",
"rev": "761ae7aff00907b607125b2f57338b74177697ed", "rev": "5eda4ee8121f97b218f7cc73f5172098d458f1d1",
"type": "github" "type": "github"
}, },
"original": { "original": {

View File

@@ -2,9 +2,9 @@
description = "clan.lol base operating system"; description = "clan.lol base operating system";
inputs = { inputs = {
nixpkgs.url = "https://nixos.org/channels/nixos-25.05/nixexprs.tar.xz"; nixpkgs.url = "https://nixos.org/channels/nixpkgs-unstable/nixexprs.tar.xz";
nix-darwin.url = "github:nix-darwin/nix-darwin/nix-darwin-25.05"; nix-darwin.url = "github:nix-darwin/nix-darwin";
nix-darwin.inputs.nixpkgs.follows = "nixpkgs"; nix-darwin.inputs.nixpkgs.follows = "nixpkgs";
flake-parts.url = "github:hercules-ci/flake-parts"; flake-parts.url = "github:hercules-ci/flake-parts";

View File

@@ -11,6 +11,8 @@
treefmt.programs.nixfmt.enable = true; treefmt.programs.nixfmt.enable = true;
treefmt.programs.nixfmt.package = pkgs.nixfmt-rfc-style; treefmt.programs.nixfmt.package = pkgs.nixfmt-rfc-style;
treefmt.programs.deadnix.enable = true; treefmt.programs.deadnix.enable = true;
treefmt.programs.sizelint.enable = true;
treefmt.programs.sizelint.failOnWarn = true;
treefmt.programs.clang-format.enable = true; treefmt.programs.clang-format.enable = true;
treefmt.settings.global.excludes = [ treefmt.settings.global.excludes = [
"*.png" "*.png"

View File

@@ -1,51 +0,0 @@
{ lib }:
let
sanitizePath =
rootPath: path:
let
storePrefix = builtins.unsafeDiscardStringContext ("${rootPath}");
pathStr = lib.removePrefix "/" (
lib.removePrefix storePrefix (builtins.unsafeDiscardStringContext (toString path))
);
in
pathStr;
mkFunctions = rootPath: passthru: virtual_fs: {
# Some functions to override lib functions
pathExists =
path:
let
pathStr = sanitizePath rootPath path;
isPassthru = builtins.any (exclude: (builtins.match exclude pathStr) != null) passthru;
in
if isPassthru then
builtins.pathExists path
else
let
res = virtual_fs ? ${pathStr};
in
lib.trace "pathExists: '${pathStr}' -> '${lib.generators.toPretty { } res}'" res;
readDir =
path:
let
pathStr = sanitizePath rootPath path;
base = (pathStr + "/");
res = lib.mapAttrs' (name: fileInfo: {
name = lib.removePrefix base name;
value = fileInfo.type;
}) (lib.filterAttrs (n: _: lib.hasPrefix base n) virtual_fs);
isPassthru = builtins.any (exclude: (builtins.match exclude pathStr) != null) passthru;
in
if isPassthru then
builtins.readDir path
else
lib.trace "readDir: '${pathStr}' -> '${lib.generators.toPretty { } res}'" res;
};
in
{
virtual_fs,
rootPath,
# Patterns
passthru ? [ ],
}:
mkFunctions rootPath passthru virtual_fs

View File

@@ -28,6 +28,7 @@ lib.fix (
# Plain imports. # Plain imports.
introspection = import ./introspection { inherit lib; }; introspection = import ./introspection { inherit lib; };
jsonschema = import ./jsonschema { inherit lib; }; jsonschema = import ./jsonschema { inherit lib; };
facts = import ./facts.nix { inherit lib; };
docs = import ./docs.nix { inherit lib; }; docs = import ./docs.nix { inherit lib; };
# flakes # flakes
@@ -35,10 +36,6 @@ lib.fix (
# TODO: Flatten our lib functions like this: # TODO: Flatten our lib functions like this:
resolveModule = clanLib.callLib ./resolve-module { }; resolveModule = clanLib.callLib ./resolve-module { };
fs = {
inherit (builtins) pathExists readDir;
};
}; };
in in
f f

71
lib/facts.nix Normal file
View File

@@ -0,0 +1,71 @@
{ lib, ... }:
clanDir:
let
allMachineNames = lib.mapAttrsToList (name: _: name) (builtins.readDir clanDir);
getFactPath = machine: fact: "${clanDir}/machines/${machine}/facts/${fact}";
readFact =
machine: fact:
let
path = getFactPath machine fact;
in
if builtins.pathExists path then builtins.readFile path else null;
# Example:
#
# readFactFromAllMachines zerotier-ip
# => {
# machineA = "1.2.3.4";
# machineB = "5.6.7.8";
# };
readFactFromAllMachines =
fact:
let
machines = allMachineNames;
facts = lib.genAttrs machines (machine: readFact machine fact);
filteredFacts = lib.filterAttrs (_machine: fact: fact != null) facts;
in
filteredFacts;
# all given facts are are set and factvalues are never null.
#
# Example:
#
# readFactsFromAllMachines [ "zerotier-ip" "syncthing.pub" ]
# => {
# machineA =
# {
# "zerotier-ip" = "1.2.3.4";
# "synching.pub" = "1234";
# };
# machineB =
# {
# "zerotier-ip" = "5.6.7.8";
# "synching.pub" = "23456719";
# };
# };
readFactsFromAllMachines =
facts:
let
# machine -> fact -> factvalue
machinesFactsAttrs = lib.genAttrs allMachineNames (
machine: lib.genAttrs facts (fact: readFact machine fact)
);
# remove all machines which don't have all facts set
filteredMachineFactAttrs = lib.filterAttrs (
_machine: values: builtins.all (fact: values.${fact} != null) facts
) machinesFactsAttrs;
in
filteredMachineFactAttrs;
in
{
inherit
allMachineNames
getFactPath
readFact
readFactFromAllMachines
readFactsFromAllMachines
;
}

View File

@@ -1,7 +1,8 @@
# tests for the nixos options to jsonschema converter # tests for the nixos options to jsonschema converter
# run these tests via `nix-unit ./test.nix` # run these tests via `nix-unit ./test.nix`
{ {
lib ? (import <nixpkgs> { }).lib, lib ? import /home/johannes/git/nixpkgs/lib,
# lib ? (import <nixpkgs> { }).lib,
slib ? (import ./. { inherit lib; }), slib ? (import ./. { inherit lib; }),
}: }:
let let
@@ -67,17 +68,24 @@ in
}; };
}; };
}; };
test_no_default = { test_no_default =
expr = stableView ( let
slib.getPrios {
options = configuration = (
(eval [ eval [
{ {
options.foo.bar = lib.mkOption { options.foo.bar = lib.mkOption {
type = lib.types.bool; type = lib.types.bool;
}; };
} }
]).options; ]
);
in
{
inherit configuration;
expr = stableView (
slib.getPrios {
options = configuration.options;
} }
); );
expected = { expected = {

View File

@@ -133,13 +133,12 @@ in
} }
) )
{ {
# Note: we use clanLib.fs here, so that we can override it in tests # TODO: Figure out why this causes infinite recursion
inventory = lib.optionalAttrs (clanLib.fs.pathExists "${directory}/machines") ({ inventory.machines = lib.optionalAttrs (builtins.pathExists "${directory}/machines") (
imports = lib.mapAttrsToList (name: _t: { builtins.mapAttrs (_n: _v: { }) (
_file = "${directory}/machines/${name}"; lib.filterAttrs (_: t: t == "directory") (builtins.readDir "${directory}/machines")
machines.${name} = { }; )
}) ((lib.filterAttrs (_: t: t == "directory") (clanLib.fs.readDir "${directory}/machines"))); );
});
} }
{ {
inventory.machines = lib.mapAttrs (_n: _: { }) config.machines; inventory.machines = lib.mapAttrs (_n: _: { }) config.machines;

View File

@@ -1,108 +0,0 @@
{
lib ? import <nixpkgs/lib>,
}:
let
clanLibOrig = (import ./.. { inherit lib; }).__unfix__;
clanLibWithFs =
{ virtual_fs }:
lib.fix (
lib.extends (
final: _:
let
clan-core = {
clanLib = final;
modules.clan.default = lib.modules.importApply ./clan { inherit clan-core; };
# Note: Can add other things to "clan-core"
# ... Not needed for this test
};
in
{
clan = import ../clan {
inherit lib clan-core;
};
# Override clanLib.fs for unit-testing against a virtual filesystem
fs = import ../clanTest/virtual-fs.nix { inherit lib; } {
inherit rootPath virtual_fs;
# Example of a passthru
# passthru = [
# ".*inventory\.json$"
# ];
};
}
) clanLibOrig
);
rootPath = ./.;
in
{
test_autoload_directories =
let
vclan =
(clanLibWithFs {
virtual_fs = {
"machines" = {
type = "directory";
};
"machines/foo-machine" = {
type = "directory";
};
"machines/bar-machine" = {
type = "directory";
};
};
}).clan
{ config.directory = rootPath; };
in
{
inherit vclan;
expr = {
machines = lib.attrNames vclan.config.inventory.machines;
definedInMachinesDir = map (
p: lib.hasInfix "/machines/" p
) vclan.options.inventory.valueMeta.configuration.options.machines.files;
};
expected = {
machines = [
"bar-machine"
"foo-machine"
];
definedInMachinesDir = [
true # /machines/foo-machine
true # /machines/bar-machine
false # <clan-core>/module.nix defines "machines" without members
];
};
};
# Could probably be unified with the previous test
# This is here for the sake to show that 'virtual_fs' is a test parameter
test_files_are_not_machines =
let
vclan =
(clanLibWithFs {
virtual_fs = {
"machines" = {
type = "directory";
};
"machines/foo.nix" = {
type = "file";
};
"machines/bar.nix" = {
type = "file";
};
};
}).clan
{ config.directory = rootPath; };
in
{
inherit vclan;
expr = {
machines = lib.attrNames vclan.config.inventory.machines;
};
expected = {
machines = [ ];
};
};
}

View File

@@ -12,7 +12,6 @@ let
in in
####### #######
{ {
autoloading = import ./dir_test.nix { inherit lib; };
test_missing_self = test_missing_self =
let let
eval = clan { eval = clan {

View File

@@ -164,19 +164,7 @@
config = lib.mkIf (config.clan.core.secrets != { }) { config = lib.mkIf (config.clan.core.secrets != { }) {
clan.core.facts.services = lib.mapAttrs' ( clan.core.facts.services = lib.mapAttrs' (
name: service: name: service:
lib.warn lib.warn "clan.core.secrets.${name} is deprecated, use clan.core.facts.services.${name} instead" (
''
###############################################################################
# #
# clan.core.secrets.${name} clan.core.facts.services.${name} is deprecated #
# in favor of "vars" #
# #
# Refer to https://docs.clan.lol/guides/migrations/migration-facts-vars/ #
# for migration instructions. #
# #
###############################################################################
''
(
lib.nameValuePair name ({ lib.nameValuePair name ({
secret = service.secrets; secret = service.secrets;
public = service.facts; public = service.facts;

View File

@@ -6,17 +6,7 @@
}: }:
{ {
config.warnings = lib.optionals (config.clan.core.facts.services != { }) [ config.warnings = lib.optionals (config.clan.core.facts.services != { }) [
'' "Facts are deprecated, please migrate them to vars instead, see: https://docs.clan.lol/guides/migrations/migration-facts-vars/"
###############################################################################
# #
# Facts are deprecated please migrate any usages to vars instead #
# #
# #
# Refer to https://docs.clan.lol/guides/migrations/migration-facts-vars/ #
# for migration instructions. #
# #
###############################################################################
''
]; ];
options.clan.core.facts = { options.clan.core.facts = {

View File

@@ -5,16 +5,18 @@
let let
inherit (lib) inherit (lib)
filterAttrs filterAttrs
flatten
mapAttrsToList mapAttrsToList
; ;
in
relevantFiles = filterAttrs (
_name: f: f.secret && f.deploy && (f.neededFor == "users" || f.neededFor == "services")
);
collectFiles =
generators: generators:
builtins.concatLists ( let
relevantFiles =
generator:
filterAttrs (
_name: f: f.secret && f.deploy && (f.neededFor == "users" || f.neededFor == "services")
) generator.files;
allFiles = flatten (
mapAttrsToList ( mapAttrsToList (
gen_name: generator: gen_name: generator:
mapAttrsToList (fname: file: { mapAttrsToList (fname: file: {
@@ -28,8 +30,8 @@ let
mode mode
restartUnits restartUnits
; ;
}) (relevantFiles generator.files) }) (relevantFiles generator)
) generators ) generators
); );
in in
collectFiles allFiles

View File

@@ -41,7 +41,7 @@ class ApiBridge(Protocol):
def process_request(self, request: BackendRequest) -> None: def process_request(self, request: BackendRequest) -> None:
"""Process an API request through the middleware chain.""" """Process an API request through the middleware chain."""
from clan_app.middleware.base import MiddlewareContext from clan_app.middleware.base import MiddlewareContext # noqa: PLC0415
with ExitStack() as stack: with ExitStack() as stack:
# Capture the current call stack up to this point # Capture the current call stack up to this point
@@ -62,7 +62,7 @@ class ApiBridge(Protocol):
) )
middleware.process(context) middleware.process(context)
except Exception as e: except Exception as e:
from clan_app.middleware.base import ( from clan_app.middleware.base import ( # noqa: PLC0415
MiddlewareError, MiddlewareError,
) )

View File

@@ -191,13 +191,13 @@ class HttpBridge(ApiBridge, BaseHTTPRequestHandler):
return file_data return file_data
def do_OPTIONS(self) -> None: # noqa: N802 def do_OPTIONS(self) -> None:
"""Handle CORS preflight requests.""" """Handle CORS preflight requests."""
self.send_response_only(200) self.send_response_only(200)
self._send_cors_headers() self._send_cors_headers()
self.end_headers() self.end_headers()
def do_GET(self) -> None: # noqa: N802 def do_GET(self) -> None:
"""Handle GET requests.""" """Handle GET requests."""
parsed_url = urlparse(self.path) parsed_url = urlparse(self.path)
path = parsed_url.path path = parsed_url.path
@@ -211,7 +211,7 @@ class HttpBridge(ApiBridge, BaseHTTPRequestHandler):
else: else:
self.send_api_error_response("info", "Not Found", ["http_bridge", "GET"]) self.send_api_error_response("info", "Not Found", ["http_bridge", "GET"])
def do_POST(self) -> None: # noqa: N802 def do_POST(self) -> None:
"""Handle POST requests.""" """Handle POST requests."""
parsed_url = urlparse(self.path) parsed_url = urlparse(self.path)
path = parsed_url.path path = parsed_url.path

View File

@@ -34,7 +34,7 @@ class WebviewBridge(ApiBridge):
log.debug(f"Sending response: {serialized}") log.debug(f"Sending response: {serialized}")
# Import FuncStatus locally to avoid circular import # Import FuncStatus locally to avoid circular import
from .webview import FuncStatus from .webview import FuncStatus # noqa: PLC0415
self.webview.return_(response._op_key, FuncStatus.SUCCESS, serialized) # noqa: SLF001 self.webview.return_(response._op_key, FuncStatus.SUCCESS, serialized) # noqa: SLF001

View File

@@ -113,27 +113,15 @@ mkShell {
# todo darwin support needs some work # todo darwin support needs some work
(lib.optionalString stdenv.hostPlatform.isLinux '' (lib.optionalString stdenv.hostPlatform.isLinux ''
# configure playwright for storybook snapshot testing # configure playwright for storybook snapshot testing
# we only want webkit as that matches what the app is rendered with export PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD=1
export PLAYWRIGHT_BROWSERS_PATH=${ export PLAYWRIGHT_BROWSERS_PATH=${
playwright-driver.browsers.override { playwright-driver.browsers.override {
withFfmpeg = false; withFfmpeg = false;
withFirefox = false; withFirefox = false;
withWebkit = true;
withChromium = false; withChromium = false;
withChromiumHeadlessShell = false; withChromiumHeadlessShell = true;
} }
} }
export PLAYWRIGHT_HOST_PLATFORM_OVERRIDE="ubuntu-24.04"
# stop playwright from trying to validate it has downloaded the necessary browsers
# we are providing them manually via nix
export PLAYWRIGHT_SKIP_VALIDATE_HOST_REQUIREMENTS=true
# playwright browser drivers are versioned e.g. webkit-2191
# this helps us avoid having to update the playwright js dependency everytime we update nixpkgs and vice versa
# see vitest.config.js for corresponding launch configuration
export PLAYWRIGHT_WEBKIT_EXECUTABLE=$(find -L "$PLAYWRIGHT_BROWSERS_PATH" -type f -name "pw_run.sh")
''); '');
} }

View File

@@ -53,7 +53,7 @@
"jsdom": "^26.1.0", "jsdom": "^26.1.0",
"knip": "^5.61.2", "knip": "^5.61.2",
"markdown-to-jsx": "^7.7.10", "markdown-to-jsx": "^7.7.10",
"playwright": "~1.55.1", "playwright": "~1.53.2",
"postcss": "^8.4.38", "postcss": "^8.4.38",
"postcss-url": "^10.1.3", "postcss-url": "^10.1.3",
"prettier": "^3.2.5", "prettier": "^3.2.5",
@@ -6956,13 +6956,13 @@
} }
}, },
"node_modules/playwright": { "node_modules/playwright": {
"version": "1.55.1", "version": "1.53.2",
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.55.1.tgz", "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.53.2.tgz",
"integrity": "sha512-cJW4Xd/G3v5ovXtJJ52MAOclqeac9S/aGGgRzLabuF8TnIb6xHvMzKIa6JmrRzUkeXJgfL1MhukP0NK6l39h3A==", "integrity": "sha512-6K/qQxVFuVQhRQhFsVZ9fGeatxirtrpPgxzBYWyZLEXJzqYwuL4fuNmfOfD5et1tJE4GScKyPNeLhZeRwuTU3A==",
"dev": true, "dev": true,
"license": "Apache-2.0", "license": "Apache-2.0",
"dependencies": { "dependencies": {
"playwright-core": "1.55.1" "playwright-core": "1.53.2"
}, },
"bin": { "bin": {
"playwright": "cli.js" "playwright": "cli.js"
@@ -6975,9 +6975,9 @@
} }
}, },
"node_modules/playwright-core": { "node_modules/playwright-core": {
"version": "1.55.1", "version": "1.53.2",
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.55.1.tgz", "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.53.2.tgz",
"integrity": "sha512-Z6Mh9mkwX+zxSlHqdr5AOcJnfp+xUWLCt9uKV18fhzA8eyxUd8NUWzAjxUh55RZKSYwDGX0cfaySdhZJGMoJ+w==", "integrity": "sha512-ox/OytMy+2w1jcYEYlOo1Hhp8hZkLCximMTUTMBXjGUA1KoFfiSZ+DU+3a739jsPY0yoKH2TFy9S2fsJas8yAw==",
"dev": true, "dev": true,
"license": "Apache-2.0", "license": "Apache-2.0",
"bin": { "bin": {

View File

@@ -48,7 +48,7 @@
"jsdom": "^26.1.0", "jsdom": "^26.1.0",
"knip": "^5.61.2", "knip": "^5.61.2",
"markdown-to-jsx": "^7.7.10", "markdown-to-jsx": "^7.7.10",
"playwright": "~1.55.1", "playwright": "~1.53.2",
"postcss": "^8.4.38", "postcss": "^8.4.38",
"postcss-url": "^10.1.3", "postcss-url": "^10.1.3",
"prettier": "^3.2.5", "prettier": "^3.2.5",

View File

@@ -1,7 +1,7 @@
import type { Meta, StoryObj } from "@kachurun/storybook-solid"; import type { Meta, StoryObj } from "@kachurun/storybook-solid";
import { Button, ButtonProps } from "./Button"; import { Button, ButtonProps } from "./Button";
import { Component } from "solid-js"; import { Component } from "solid-js";
import { expect, fn, waitFor, within } from "storybook/test"; import { expect, fn, waitFor } from "storybook/test";
import { StoryContext } from "@kachurun/storybook-solid-vite"; import { StoryContext } from "@kachurun/storybook-solid-vite";
const getCursorStyle = (el: Element) => window.getComputedStyle(el).cursor; const getCursorStyle = (el: Element) => window.getComputedStyle(el).cursor;
@@ -216,11 +216,17 @@ const timeout = process.env.NODE_ENV === "test" ? 500 : 2000;
export const Primary: Story = { export const Primary: Story = {
args: { args: {
hierarchy: "primary", hierarchy: "primary",
onClick: fn(), onAction: fn(async () => {
// wait 500 ms to simulate an action
await new Promise((resolve) => setTimeout(resolve, timeout));
// randomly fail to check that the loading state still returns to normal
if (Math.random() > 0.5) {
throw new Error("Action failure");
}
}),
}, },
play: async ({ canvasElement, step, userEvent, args }: StoryContext) => { play: async ({ canvas, step, userEvent, args }: StoryContext) => {
const canvas = within(canvasElement);
const buttons = await canvas.findAllByRole("button"); const buttons = await canvas.findAllByRole("button");
for (const button of buttons) { for (const button of buttons) {
@@ -232,6 +238,14 @@ export const Primary: Story = {
} }
await step(`Click on ${testID}`, async () => { await step(`Click on ${testID}`, async () => {
// check for the loader
const loaders = button.getElementsByClassName("loader");
await expect(loaders.length).toEqual(1);
// assert its width is 0 before we click
const [loader] = loaders;
await expect(loader.clientWidth).toEqual(0);
// move the mouse over the button // move the mouse over the button
await userEvent.hover(button); await userEvent.hover(button);
@@ -241,8 +255,33 @@ export const Primary: Story = {
// click the button // click the button
await userEvent.click(button); await userEvent.click(button);
// the click handler should have been called // check the button has changed
await expect(args.onClick).toHaveBeenCalled(); 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 },
);
// wait for the action handler to finish
await waitFor(
async () => {
// the loading class should be removed
await expect(button).not.toHaveClass("loading");
// the loader should be hidden
await expect(loader.clientWidth).toEqual(0);
// the pointer should be normal
await expect(getCursorStyle(button)).toEqual("pointer");
},
{ timeout: timeout + 500 },
);
}); });
} }
}, },

View File

@@ -57,7 +57,6 @@ export const Button = (props: ButtonProps) => {
return ( return (
<KobalteButton <KobalteButton
role="button"
class={cx( class={cx(
styles.button, // default button class styles.button, // default button class
local.size != "default" && styles[local.size], local.size != "default" && styles[local.size],

View File

@@ -160,47 +160,47 @@ const mockFetcher = <K extends OperationNames>(
}, },
}) satisfies ApiCall<K>; }) satisfies ApiCall<K>;
// export const Default: Story = { export const Default: Story = {
// args: {}, args: {},
// decorators: [ decorators: [
// (Story: StoryObj) => { (Story: StoryObj) => {
// const queryClient = new QueryClient({ const queryClient = new QueryClient({
// defaultOptions: { defaultOptions: {
// queries: { queries: {
// retry: false, retry: false,
// staleTime: Infinity, staleTime: Infinity,
// }, },
// }, },
// }); });
//
// Object.entries(queryData).forEach(([clanURI, clan]) => { Object.entries(queryData).forEach(([clanURI, clan]) => {
// queryClient.setQueryData( queryClient.setQueryData(
// ["clans", encodeBase64(clanURI), "details"], ["clans", encodeBase64(clanURI), "details"],
// clan.details, clan.details,
// ); );
//
// const machines = clan.machines || {}; const machines = clan.machines || {};
//
// queryClient.setQueryData( queryClient.setQueryData(
// ["clans", encodeBase64(clanURI), "machines"], ["clans", encodeBase64(clanURI), "machines"],
// machines, machines,
// ); );
//
// Object.entries(machines).forEach(([name, machine]) => { Object.entries(machines).forEach(([name, machine]) => {
// queryClient.setQueryData( queryClient.setQueryData(
// ["clans", encodeBase64(clanURI), "machine", name, "state"], ["clans", encodeBase64(clanURI), "machine", name, "state"],
// machine.state, machine.state,
// ); );
// }); });
// }); });
//
// return ( return (
// <ApiClientProvider client={{ fetch: mockFetcher }}> <ApiClientProvider client={{ fetch: mockFetcher }}>
// <QueryClientProvider client={queryClient}> <QueryClientProvider client={queryClient}>
// <Story /> <Story />
// </QueryClientProvider> </QueryClientProvider>
// </ApiClientProvider> </ApiClientProvider>
// ); );
// }, },
// ], ],
// }; };

View File

@@ -22,9 +22,9 @@ import { Alert } from "@/src/components/Alert/Alert";
import { removeClanURI } from "@/src/stores/clan"; import { removeClanURI } from "@/src/stores/clan";
const schema = v.object({ const schema = v.object({
name: v.string(), name: v.pipe(v.optional(v.string())),
description: v.optional(v.string()), description: v.nullish(v.string()),
icon: v.optional(v.string()), icon: v.pipe(v.nullish(v.string())),
}); });
export interface ClanSettingsModalProps { export interface ClanSettingsModalProps {

View File

@@ -0,0 +1,15 @@
import { Meta, StoryObj } from "@kachurun/storybook-solid";
import { CubeScene } from "./cubes";
const meta: Meta = {
title: "scene/cubes",
component: CubeScene,
};
export default meta;
type Story = StoryObj;
export const Default: Story = {
args: {},
};

View File

@@ -304,10 +304,11 @@ const FlashProgress = () => {
const [store, set] = getStepStore<InstallStoreType>(stepSignal); const [store, set] = getStepStore<InstallStoreType>(stepSignal);
onMount(async () => { onMount(async () => {
const result = await store.flash?.progress?.result; const result = await store.flash.progress.result;
if (result?.status == "success") { if (result.status == "success") {
stepSignal.next(); console.log("Flashing Success");
} }
stepSignal.next();
}); });
const handleCancel = async () => { const handleCancel = async () => {

View File

@@ -165,23 +165,23 @@ export default meta;
type Story = StoryObj<typeof ServiceWorkflow>; type Story = StoryObj<typeof ServiceWorkflow>;
// export const Default: Story = { export const Default: Story = {
// args: {}, args: {},
// }; };
//
// export const SelectRoleMembers: Story = { export const SelectRoleMembers: Story = {
// render: () => ( render: () => (
// <ServiceWorkflow <ServiceWorkflow
// handleSubmit={(instance) => { handleSubmit={(instance) => {
// console.log("Submitted instance:", instance); console.log("Submitted instance:", instance);
// }} }}
// onClose={() => { onClose={() => {
// console.log("Closed"); console.log("Closed");
// }} }}
// initialStep="select:members" initialStep="select:members"
// initialStore={{ initialStore={{
// currentRole: "peer", currentRole: "peer",
// }} }}
// /> />
// ), ),
// }; };

View File

@@ -9,11 +9,7 @@
"esModuleInterop": true, "esModuleInterop": true,
"jsx": "preserve", "jsx": "preserve",
"jsxImportSource": "solid-js", "jsxImportSource": "solid-js",
"types": [ "types": ["vite/client", "vite-plugin-solid-svg/types-component-solid"],
"vite/client",
"vite-plugin-solid-svg/types-component-solid",
"@vitest/browser/providers/playwright"
],
"noEmit": true, "noEmit": true,
"resolveJsonModule": true, "resolveJsonModule": true,
"allowJs": true, "allowJs": true,

View File

@@ -40,14 +40,7 @@ export default mergeConfig(
enabled: true, enabled: true,
headless: true, headless: true,
provider: "playwright", provider: "playwright",
instances: [ instances: [{ browser: "chromium" }],
{
browser: "webkit",
launch: {
executablePath: process.env.PLAYWRIGHT_WEBKIT_EXECUTABLE,
},
},
],
}, },
// This setup file applies Storybook project annotations for Vitest // This setup file applies Storybook project annotations for Vitest
// More info at: https://storybook.js.org/docs/api/portable-stories/portable-stories-vitest#setprojectannotations // More info at: https://storybook.js.org/docs/api/portable-stories/portable-stories-vitest#setprojectannotations

View File

@@ -9,7 +9,7 @@ def main() -> None:
load_in_all_api_functions() load_in_all_api_functions()
# import lazily since we otherwise we do not have all api functions loaded according to Qubasa # import lazily since we otherwise we do not have all api functions loaded according to Qubasa
from clan_lib.api import API from clan_lib.api import API # noqa: PLC0415
schema = API.to_json_schema() schema = API.to_json_schema()
print(f"""{json.dumps(schema, indent=2)}""") print(f"""{json.dumps(schema, indent=2)}""")

View File

@@ -102,7 +102,7 @@ class TestFlake(Flake):
opts: "ListOptions | None" = None, # noqa: ARG002 opts: "ListOptions | None" = None, # noqa: ARG002
) -> "dict[str, MachineResponse]": ) -> "dict[str, MachineResponse]":
"""List machines of a clan""" """List machines of a clan"""
from clan_lib.machines.actions import ( from clan_lib.machines.actions import ( # noqa: PLC0415
InventoryMachine, InventoryMachine,
MachineResponse, MachineResponse,
) )

View File

@@ -231,7 +231,7 @@ def remove_machine_command(args: argparse.Namespace) -> None:
def add_group_argument(parser: argparse.ArgumentParser) -> None: def add_group_argument(parser: argparse.ArgumentParser) -> None:
from clan_cli.completions import ( from clan_cli.completions import ( # noqa: PLC0415
add_dynamic_completer, add_dynamic_completer,
complete_groups, complete_groups,
) )
@@ -334,7 +334,7 @@ def register_groups_parser(parser: argparse.ArgumentParser) -> None:
help="the name of the machines to add", help="the name of the machines to add",
type=machine_name_type, type=machine_name_type,
) )
from clan_cli.completions import ( from clan_cli.completions import ( # noqa: PLC0415
add_dynamic_completer, add_dynamic_completer,
complete_machines, complete_machines,
) )
@@ -353,7 +353,7 @@ def register_groups_parser(parser: argparse.ArgumentParser) -> None:
help="the name of the machines to remove", help="the name of the machines to remove",
type=machine_name_type, type=machine_name_type,
) )
from clan_cli.completions import ( from clan_cli.completions import ( # noqa: PLC0415
add_dynamic_completer, add_dynamic_completer,
complete_machines, complete_machines,
) )
@@ -369,7 +369,7 @@ def register_groups_parser(parser: argparse.ArgumentParser) -> None:
help="the name of the user to add", help="the name of the user to add",
type=user_name_type, type=user_name_type,
) )
from clan_cli.completions import ( from clan_cli.completions import ( # noqa: PLC0415
add_dynamic_completer, add_dynamic_completer,
complete_users, complete_users,
) )
@@ -388,7 +388,7 @@ def register_groups_parser(parser: argparse.ArgumentParser) -> None:
help="the name of the user to remove", help="the name of the user to remove",
type=user_name_type, type=user_name_type,
) )
from clan_cli.completions import ( from clan_cli.completions import ( # noqa: PLC0415
add_dynamic_completer, add_dynamic_completer,
complete_users, complete_users,
) )
@@ -407,7 +407,7 @@ def register_groups_parser(parser: argparse.ArgumentParser) -> None:
help="the name of the secret", help="the name of the secret",
type=secret_name_type, type=secret_name_type,
) )
from clan_cli.completions import ( from clan_cli.completions import ( # noqa: PLC0415
add_dynamic_completer, add_dynamic_completer,
complete_secrets, complete_secrets,
) )
@@ -426,7 +426,7 @@ def register_groups_parser(parser: argparse.ArgumentParser) -> None:
help="the name of the secret", help="the name of the secret",
type=secret_name_type, type=secret_name_type,
) )
from clan_cli.completions import ( from clan_cli.completions import ( # noqa: PLC0415
add_dynamic_completer, add_dynamic_completer,
complete_secrets, complete_secrets,
) )

View File

@@ -69,7 +69,7 @@ def register_import_sops_parser(parser: argparse.ArgumentParser) -> None:
default=[], default=[],
help="the group to import the secrets to", help="the group to import the secrets to",
) )
from clan_cli.completions import ( from clan_cli.completions import ( # noqa: PLC0415
add_dynamic_completer, add_dynamic_completer,
complete_groups, complete_groups,
) )
@@ -82,7 +82,7 @@ def register_import_sops_parser(parser: argparse.ArgumentParser) -> None:
default=[], default=[],
help="the machine to import the secrets to", help="the machine to import the secrets to",
) )
from clan_cli.completions import ( from clan_cli.completions import ( # noqa: PLC0415
add_dynamic_completer, add_dynamic_completer,
complete_machines, complete_machines,
) )
@@ -95,7 +95,7 @@ def register_import_sops_parser(parser: argparse.ArgumentParser) -> None:
default=[], default=[],
help="the user to import the secrets to", help="the user to import the secrets to",
) )
from clan_cli.completions import ( from clan_cli.completions import ( # noqa: PLC0415
add_dynamic_completer, add_dynamic_completer,
complete_users, complete_users,
) )

View File

@@ -172,7 +172,7 @@ def register_machines_parser(parser: argparse.ArgumentParser) -> None:
help="the name of the machine", help="the name of the machine",
type=machine_name_type, type=machine_name_type,
) )
from clan_cli.completions import ( from clan_cli.completions import ( # noqa: PLC0415
add_dynamic_completer, add_dynamic_completer,
complete_machines, complete_machines,
) )
@@ -192,7 +192,7 @@ def register_machines_parser(parser: argparse.ArgumentParser) -> None:
help="the name of the machine", help="the name of the machine",
type=machine_name_type, type=machine_name_type,
) )
from clan_cli.completions import ( from clan_cli.completions import ( # noqa: PLC0415
add_dynamic_completer, add_dynamic_completer,
complete_machines, complete_machines,
) )
@@ -207,7 +207,7 @@ def register_machines_parser(parser: argparse.ArgumentParser) -> None:
help="the name of the machine", help="the name of the machine",
type=machine_name_type, type=machine_name_type,
) )
from clan_cli.completions import ( from clan_cli.completions import ( # noqa: PLC0415
add_dynamic_completer, add_dynamic_completer,
complete_machines, complete_machines,
) )
@@ -225,7 +225,7 @@ def register_machines_parser(parser: argparse.ArgumentParser) -> None:
help="the name of the machine", help="the name of the machine",
type=machine_name_type, type=machine_name_type,
) )
from clan_cli.completions import ( from clan_cli.completions import ( # noqa: PLC0415
add_dynamic_completer, add_dynamic_completer,
complete_machines, complete_machines,
complete_secrets, complete_secrets,
@@ -250,7 +250,7 @@ def register_machines_parser(parser: argparse.ArgumentParser) -> None:
help="the name of the machine", help="the name of the machine",
type=machine_name_type, type=machine_name_type,
) )
from clan_cli.completions import ( from clan_cli.completions import ( # noqa: PLC0415
add_dynamic_completer, add_dynamic_completer,
complete_machines, complete_machines,
complete_secrets, complete_secrets,

View File

@@ -255,7 +255,7 @@ def add_secret_argument(parser: argparse.ArgumentParser, autocomplete: bool) ->
type=secret_name_type, type=secret_name_type,
) )
if autocomplete: if autocomplete:
from clan_cli.completions import ( from clan_cli.completions import ( # noqa: PLC0415
add_dynamic_completer, add_dynamic_completer,
complete_secrets, complete_secrets,
) )
@@ -467,7 +467,7 @@ def register_secrets_parser(subparser: argparse._SubParsersAction) -> None:
default=[], default=[],
help="the group to import the secrets to (can be repeated)", help="the group to import the secrets to (can be repeated)",
) )
from clan_cli.completions import ( from clan_cli.completions import ( # noqa: PLC0415
add_dynamic_completer, add_dynamic_completer,
complete_groups, complete_groups,
) )
@@ -480,7 +480,7 @@ def register_secrets_parser(subparser: argparse._SubParsersAction) -> None:
default=[], default=[],
help="the machine to import the secrets to (can be repeated)", help="the machine to import the secrets to (can be repeated)",
) )
from clan_cli.completions import ( from clan_cli.completions import ( # noqa: PLC0415
add_dynamic_completer, add_dynamic_completer,
complete_machines, complete_machines,
) )
@@ -493,7 +493,7 @@ def register_secrets_parser(subparser: argparse._SubParsersAction) -> None:
default=[], default=[],
help="the user to import the secrets to (can be repeated)", help="the user to import the secrets to (can be repeated)",
) )
from clan_cli.completions import ( from clan_cli.completions import ( # noqa: PLC0415
add_dynamic_completer, add_dynamic_completer,
complete_users, complete_users,
) )

View File

@@ -281,7 +281,7 @@ def register_users_parser(parser: argparse.ArgumentParser) -> None:
help="the name of the user", help="the name of the user",
type=user_name_type, type=user_name_type,
) )
from clan_cli.completions import ( from clan_cli.completions import ( # noqa: PLC0415
add_dynamic_completer, add_dynamic_completer,
complete_users, complete_users,
) )
@@ -295,7 +295,7 @@ def register_users_parser(parser: argparse.ArgumentParser) -> None:
help="the name of the user", help="the name of the user",
type=user_name_type, type=user_name_type,
) )
from clan_cli.completions import ( from clan_cli.completions import ( # noqa: PLC0415
add_dynamic_completer, add_dynamic_completer,
complete_users, complete_users,
) )
@@ -312,7 +312,7 @@ def register_users_parser(parser: argparse.ArgumentParser) -> None:
help="the name of the user", help="the name of the user",
type=user_name_type, type=user_name_type,
) )
from clan_cli.completions import ( from clan_cli.completions import ( # noqa: PLC0415
add_dynamic_completer, add_dynamic_completer,
complete_secrets, complete_secrets,
complete_users, complete_users,
@@ -336,7 +336,7 @@ def register_users_parser(parser: argparse.ArgumentParser) -> None:
help="the name of the group", help="the name of the group",
type=user_name_type, type=user_name_type,
) )
from clan_cli.completions import ( from clan_cli.completions import ( # noqa: PLC0415
add_dynamic_completer, add_dynamic_completer,
complete_secrets, complete_secrets,
complete_users, complete_users,
@@ -360,7 +360,7 @@ def register_users_parser(parser: argparse.ArgumentParser) -> None:
help="the name of the user", help="the name of the user",
type=user_name_type, type=user_name_type,
) )
from clan_cli.completions import ( from clan_cli.completions import ( # noqa: PLC0415
add_dynamic_completer, add_dynamic_completer,
complete_users, complete_users,
) )
@@ -378,7 +378,7 @@ def register_users_parser(parser: argparse.ArgumentParser) -> None:
help="the name of the user", help="the name of the user",
type=user_name_type, type=user_name_type,
) )
from clan_cli.completions import ( from clan_cli.completions import ( # noqa: PLC0415
add_dynamic_completer, add_dynamic_completer,
complete_users, complete_users,
) )

View File

@@ -0,0 +1,24 @@
{
# Use this path to our repo root e.g. for UI test
# inputs.clan-core.url = "../../../../.";
# this placeholder is replaced by the path to nixpkgs
inputs.clan-core.url = "__CLAN_CORE__";
outputs =
{ self, clan-core }:
let
clan = clan-core.lib.clan {
inherit self;
meta.name = "test_flake_with_core_dynamic_machines";
machines =
let
machineModules = builtins.readDir (self + "/machines");
in
builtins.mapAttrs (name: _type: import (self + "/machines/${name}")) machineModules;
};
in
{
inherit (clan.config) nixosConfigurations nixosModules clanInternals;
};
}

View File

@@ -1,6 +1,5 @@
import json import json
import logging import logging
import os
import shutil import shutil
import subprocess import subprocess
import time import time
@@ -430,43 +429,9 @@ def test_generated_shared_secret_sops(
machine1 = Machine(name="machine1", flake=Flake(str(flake.path))) machine1 = Machine(name="machine1", flake=Flake(str(flake.path)))
machine2 = Machine(name="machine2", flake=Flake(str(flake.path))) machine2 = Machine(name="machine2", flake=Flake(str(flake.path)))
cli.run(["vars", "generate", "--flake", str(flake.path), "machine1"]) cli.run(["vars", "generate", "--flake", str(flake.path), "machine1"])
assert check_vars(machine1.name, machine1.flake)
# Get the initial state of the flake directory after generation
def get_file_mtimes(path: str) -> dict[str, float]:
"""Get modification times of all files in a directory tree."""
mtimes = {}
for root, _dirs, files in os.walk(path):
# Skip .git directory
if ".git" in root:
continue
for file in files:
filepath = Path(root) / file
mtimes[str(filepath)] = filepath.stat().st_mtime
return mtimes
initial_mtimes = get_file_mtimes(str(flake.path))
# First check_vars should not write anything
assert check_vars(machine1.name, machine1.flake), (
"machine1 has already generated vars, so check_vars should return True\n"
f"Check result:\n{check_vars(machine1.name, machine1.flake)}"
)
# Verify no files were modified
after_check_mtimes = get_file_mtimes(str(flake.path))
assert initial_mtimes == after_check_mtimes, (
"check_vars should not modify any files when vars are already valid"
)
assert not check_vars(machine2.name, machine2.flake), (
"machine2 has not generated vars yet, so check_vars should return False"
)
# Verify no files were modified
after_check_mtimes_2 = get_file_mtimes(str(flake.path))
assert initial_mtimes == after_check_mtimes_2, (
"check_vars should not modify any files when vars are not valid"
)
cli.run(["vars", "generate", "--flake", str(flake.path), "machine2"]) cli.run(["vars", "generate", "--flake", str(flake.path), "machine2"])
assert check_vars(machine2.name, machine2.flake)
m1_sops_store = sops.SecretStore(machine1.flake) m1_sops_store = sops.SecretStore(machine1.flake)
m2_sops_store = sops.SecretStore(machine2.flake) m2_sops_store = sops.SecretStore(machine2.flake)
# Create generators with machine context for testing # Create generators with machine context for testing

View File

@@ -171,7 +171,7 @@ class StoreBase(ABC):
if generator.share: if generator.share:
log_info = log.info log_info = log.info
else: else:
from clan_lib.machines.machines import Machine from clan_lib.machines.machines import Machine # noqa: PLC0415
machine_obj = Machine(name=generator.machines[0], flake=self.flake) machine_obj = Machine(name=generator.machines[0], flake=self.flake)
log_info = machine_obj.info log_info = machine_obj.info

View File

@@ -3,7 +3,6 @@ import logging
from typing import TYPE_CHECKING from typing import TYPE_CHECKING
from clan_cli.completions import add_dynamic_completer, complete_machines from clan_cli.completions import add_dynamic_completer, complete_machines
from clan_cli.vars.secret_modules import sops
from clan_lib.errors import ClanError from clan_lib.errors import ClanError
from clan_lib.flake import Flake, require_flake from clan_lib.flake import Flake, require_flake
from clan_lib.machines.machines import Machine from clan_lib.machines.machines import Machine
@@ -27,33 +26,13 @@ class VarStatus:
self.unfixed_secret_vars = unfixed_secret_vars self.unfixed_secret_vars = unfixed_secret_vars
self.invalid_generators = invalid_generators self.invalid_generators = invalid_generators
def text(self) -> str:
log = ""
if self.missing_secret_vars:
log += "Missing secret vars:\n"
for var in self.missing_secret_vars:
log += f" - {var.id}\n"
if self.missing_public_vars:
log += "Missing public vars:\n"
for var in self.missing_public_vars:
log += f" - {var.id}\n"
if self.unfixed_secret_vars:
log += "Unfixed secret vars:\n"
for var in self.unfixed_secret_vars:
log += f" - {var.id}\n"
if self.invalid_generators:
log += "Invalid generators (outdated invalidation hash):\n"
for gen in self.invalid_generators:
log += f" - {gen}\n"
return log if log else "All vars are present and valid."
def vars_status( def vars_status(
machine_name: str, machine_name: str,
flake: Flake, flake: Flake,
generator_name: None | str = None, generator_name: None | str = None,
) -> VarStatus: ) -> VarStatus:
from clan_cli.vars.generator import Generator from clan_cli.vars.generator import Generator # noqa: PLC0415
machine = Machine(name=machine_name, flake=flake) machine = Machine(name=machine_name, flake=flake)
missing_secret_vars = [] missing_secret_vars = []
@@ -87,32 +66,15 @@ def vars_status(
f"Secret var '{file.name}' for service '{generator.name}' in machine {machine.name} is missing.", f"Secret var '{file.name}' for service '{generator.name}' in machine {machine.name} is missing.",
) )
missing_secret_vars.append(file) missing_secret_vars.append(file)
if (
isinstance(machine.secret_vars_store, sops.SecretStore)
and generator.share
and file.exists
and not machine.secret_vars_store.machine_has_access(
generator=generator,
secret_name=file.name,
machine=machine.name,
)
):
msg = (
f"Secret var '{generator.name}/{file.name}' is marked for deployment to machine '{machine.name}', but the machine does not have access to it.\n"
f"Run 'clan vars generate {machine.name}' to fix this.\n"
)
machine.info(msg)
missing_secret_vars.append(file)
else: else:
health_msg = machine.secret_vars_store.health_check( msg = machine.secret_vars_store.health_check(
machine=machine.name, machine=machine.name,
generators=[generator], generators=[generator],
file_name=file.name, file_name=file.name,
) )
if health_msg is not None: if msg:
machine.info( machine.info(
f"Secret var '{file.name}' for service '{generator.name}' in machine {machine.name} needs update: {health_msg}", f"Secret var '{file.name}' for service '{generator.name}' in machine {machine.name} needs update: {msg}",
) )
unfixed_secret_vars.append(file) unfixed_secret_vars.append(file)
@@ -144,7 +106,6 @@ def check_vars(
generator_name: None | str = None, generator_name: None | str = None,
) -> bool: ) -> bool:
status = vars_status(machine_name, flake, generator_name=generator_name) status = vars_status(machine_name, flake, generator_name=generator_name)
log.info(f"Check results for machine '{machine_name}': \n{status.text()}")
return not ( return not (
status.missing_secret_vars status.missing_secret_vars
or status.missing_public_vars or status.missing_public_vars

View File

@@ -259,10 +259,6 @@ class Generator:
_secret_store=sec_store, _secret_store=sec_store,
) )
# link generator to its files
for file in files:
file.generator(generator)
if share: if share:
# For shared generators, check if we already created it # For shared generators, check if we already created it
existing = next( existing = next(
@@ -482,7 +478,7 @@ class Generator:
if sys.platform == "linux" and bwrap.bubblewrap_works(): if sys.platform == "linux" and bwrap.bubblewrap_works():
cmd = bubblewrap_cmd(str(final_script), tmpdir) cmd = bubblewrap_cmd(str(final_script), tmpdir)
elif sys.platform == "darwin": elif sys.platform == "darwin":
from clan_lib.sandbox_exec import sandbox_exec_cmd from clan_lib.sandbox_exec import sandbox_exec_cmd # noqa: PLC0415
cmd = stack.enter_context(sandbox_exec_cmd(str(final_script), tmpdir)) cmd = stack.enter_context(sandbox_exec_cmd(str(final_script), tmpdir))
else: else:

View File

@@ -54,7 +54,7 @@ class SecretStore(StoreBase):
def ensure_machine_key(self, machine: str) -> None: def ensure_machine_key(self, machine: str) -> None:
"""Ensure machine has sops keys initialized.""" """Ensure machine has sops keys initialized."""
# no need to generate keys if we don't manage secrets # no need to generate keys if we don't manage secrets
from clan_cli.vars.generator import Generator from clan_cli.vars.generator import Generator # noqa: PLC0415
vars_generators = Generator.get_machine_generators([machine], self.flake) vars_generators = Generator.get_machine_generators([machine], self.flake)
if not vars_generators: if not vars_generators:
@@ -98,8 +98,7 @@ class SecretStore(StoreBase):
def machine_has_access( def machine_has_access(
self, generator: Generator, secret_name: str, machine: str self, generator: Generator, secret_name: str, machine: str
) -> bool: ) -> bool:
if not has_machine(self.flake.path, machine): self.ensure_machine_key(machine)
return False
key_dir = sops_machines_folder(self.flake.path) / machine key_dir = sops_machines_folder(self.flake.path) / machine
return self.key_has_access(key_dir, generator, secret_name) return self.key_has_access(key_dir, generator, secret_name)
@@ -143,7 +142,7 @@ class SecretStore(StoreBase):
""" """
if generators is None: if generators is None:
from clan_cli.vars.generator import Generator from clan_cli.vars.generator import Generator # noqa: PLC0415
generators = Generator.get_machine_generators([machine], self.flake) generators = Generator.get_machine_generators([machine], self.flake)
file_found = False file_found = False
@@ -157,6 +156,8 @@ class SecretStore(StoreBase):
else: else:
continue continue
if file.secret and self.exists(generator, file.name): if file.secret and self.exists(generator, file.name):
if file.deploy:
self.ensure_machine_has_access(generator, file.name, machine)
needs_update, msg = self.needs_fix(generator, file.name, machine) needs_update, msg = self.needs_fix(generator, file.name, machine)
if needs_update: if needs_update:
outdated.append((generator.name, file.name, msg)) outdated.append((generator.name, file.name, msg))
@@ -218,7 +219,7 @@ class SecretStore(StoreBase):
return [store_folder] return [store_folder]
def populate_dir(self, machine: str, output_dir: Path, phases: list[str]) -> None: def populate_dir(self, machine: str, output_dir: Path, phases: list[str]) -> None:
from clan_cli.vars.generator import Generator from clan_cli.vars.generator import Generator # noqa: PLC0415
vars_generators = Generator.get_machine_generators([machine], self.flake) vars_generators = Generator.get_machine_generators([machine], self.flake)
if "users" in phases or "services" in phases: if "users" in phases or "services" in phases:
@@ -282,7 +283,6 @@ class SecretStore(StoreBase):
) -> None: ) -> None:
if self.machine_has_access(generator, name, machine): if self.machine_has_access(generator, name, machine):
return return
self.ensure_machine_key(machine)
secret_folder = self.secret_path(generator, name) secret_folder = self.secret_path(generator, name)
add_secret( add_secret(
self.flake.path, self.flake.path,
@@ -292,7 +292,7 @@ class SecretStore(StoreBase):
) )
def collect_keys_for_secret(self, machine: str, path: Path) -> set[sops.SopsKey]: def collect_keys_for_secret(self, machine: str, path: Path) -> set[sops.SopsKey]:
from clan_cli.secrets.secrets import ( from clan_cli.secrets.secrets import ( # noqa: PLC0415
collect_keys_for_path, collect_keys_for_path,
collect_keys_for_type, collect_keys_for_type,
) )
@@ -354,10 +354,10 @@ class SecretStore(StoreBase):
ClanError: If the specified file_name is not found ClanError: If the specified file_name is not found
""" """
from clan_cli.secrets.secrets import update_keys from clan_cli.secrets.secrets import update_keys # noqa: PLC0415
if generators is None: if generators is None:
from clan_cli.vars.generator import Generator from clan_cli.vars.generator import Generator # noqa: PLC0415
generators = Generator.get_machine_generators([machine], self.flake) generators = Generator.get_machine_generators([machine], self.flake)
file_found = False file_found = False

View File

@@ -319,9 +319,9 @@ def load_in_all_api_functions() -> None:
We have to make sure python loads every wrapped function at least once. We have to make sure python loads every wrapped function at least once.
This is done by importing all modules from the clan_lib and clan_cli packages. This is done by importing all modules from the clan_lib and clan_cli packages.
""" """
import clan_cli # Avoid circular imports - many modules import from clan_lib.api import clan_cli # noqa: PLC0415 # Avoid circular imports - many modules import from clan_lib.api
import clan_lib # Avoid circular imports - many modules import from clan_lib.api import clan_lib # noqa: PLC0415 # Avoid circular imports - many modules import from clan_lib.api
import_all_modules_from_package(clan_lib) import_all_modules_from_package(clan_lib)
import_all_modules_from_package(clan_cli) import_all_modules_from_package(clan_cli)

View File

@@ -88,7 +88,7 @@ def list_system_storage_devices() -> Blockdevices:
A list of detected block devices with metadata like size, path, type, etc. A list of detected block devices with metadata like size, path, type, etc.
""" """
from clan_lib.nix import nix_shell from clan_lib.nix import nix_shell # noqa: PLC0415
cmd = nix_shell( cmd = nix_shell(
["util-linux"], ["util-linux"],
@@ -124,7 +124,7 @@ def get_clan_directory_relative(flake: Flake) -> str:
ClanError: If the flake evaluation fails or directories cannot be found ClanError: If the flake evaluation fails or directories cannot be found
""" """
from clan_lib.dirs import get_clan_directories from clan_lib.dirs import get_clan_directories # noqa: PLC0415
_, relative_dir = get_clan_directories(flake) _, relative_dir = get_clan_directories(flake)
return relative_dir return relative_dir

View File

@@ -1162,7 +1162,7 @@ class Flake:
opts: "ListOptions | None" = None, opts: "ListOptions | None" = None,
) -> "dict[str, MachineResponse]": ) -> "dict[str, MachineResponse]":
"""List machines of a clan""" """List machines of a clan"""
from clan_lib.machines.actions import list_machines from clan_lib.machines.actions import list_machines # noqa: PLC0415
return list_machines(self, opts) return list_machines(self, opts)

View File

@@ -18,14 +18,14 @@ def locked_open(filename: Path, mode: str = "r") -> Generator:
def write_history_file(data: Any) -> None: def write_history_file(data: Any) -> None:
from clan_lib.dirs import user_history_file from clan_lib.dirs import user_history_file # noqa: PLC0415
with locked_open(user_history_file(), "w+") as f: with locked_open(user_history_file(), "w+") as f:
f.write(json.dumps(data, cls=ClanJSONEncoder, indent=4)) f.write(json.dumps(data, cls=ClanJSONEncoder, indent=4))
def read_history_file() -> list[dict]: def read_history_file() -> list[dict]:
from clan_lib.dirs import user_history_file from clan_lib.dirs import user_history_file # noqa: PLC0415
with locked_open(user_history_file(), "r") as f: with locked_open(user_history_file(), "r") as f:
content: str = f.read() content: str = f.read()

View File

@@ -119,9 +119,6 @@ def run_machine_hardware_info_init(
if opts.debug: if opts.debug:
cmd += ["--debug"] cmd += ["--debug"]
# Add nix options to nixos-anywhere
cmd.extend(opts.machine.flake.nix_options or [])
cmd += [target_host.target] cmd += [target_host.target]
cmd = nix_shell( cmd = nix_shell(
["nixos-anywhere"], ["nixos-anywhere"],

View File

@@ -33,7 +33,7 @@ class Machine:
def get_inv_machine(self) -> "InventoryMachine": def get_inv_machine(self) -> "InventoryMachine":
# Import on demand to avoid circular imports # Import on demand to avoid circular imports
from clan_lib.machines.actions import get_machine from clan_lib.machines.actions import get_machine # noqa: PLC0415
return get_machine(self.flake, self.name) return get_machine(self.flake, self.name)
@@ -95,7 +95,7 @@ class Machine:
@cached_property @cached_property
def secret_vars_store(self) -> StoreBase: def secret_vars_store(self) -> StoreBase:
from clan_cli.vars.secret_modules import password_store from clan_cli.vars.secret_modules import password_store # noqa: PLC0415
secret_module = self.select("config.clan.core.vars.settings.secretModule") secret_module = self.select("config.clan.core.vars.settings.secretModule")
module = importlib.import_module(secret_module) module = importlib.import_module(secret_module)
@@ -126,7 +126,7 @@ class Machine:
return self.flake.path return self.flake.path
def target_host(self) -> Remote: def target_host(self) -> Remote:
from clan_lib.network.network import get_best_remote from clan_lib.network.network import get_best_remote # noqa: PLC0415
with get_best_remote(self) as remote: with get_best_remote(self) as remote:
return remote return remote

View File

@@ -42,7 +42,7 @@ def _suggest_similar_names(
def get_available_machines(flake: Flake) -> list[str]: def get_available_machines(flake: Flake) -> list[str]:
from clan_lib.machines.list import list_machines from clan_lib.machines.list import list_machines # noqa: PLC0415
machines = list_machines(flake) machines = list_machines(flake)
return list(machines.keys()) return list(machines.keys())

View File

@@ -34,7 +34,7 @@ class Peer:
_var: dict[str, str] = self._host["var"] _var: dict[str, str] = self._host["var"]
machine_name = _var["machine"] machine_name = _var["machine"]
generator = _var["generator"] generator = _var["generator"]
from clan_lib.machines.machines import Machine from clan_lib.machines.machines import Machine # noqa: PLC0415
machine = Machine(name=machine_name, flake=self.flake) machine = Machine(name=machine_name, flake=self.flake)
var = get_machine_var( var = get_machine_var(

View File

@@ -88,7 +88,7 @@ def nix_eval(flags: list[str]) -> list[str]:
], ],
) )
if os.environ.get("IN_NIX_SANDBOX"): if os.environ.get("IN_NIX_SANDBOX"):
from clan_lib.dirs import nixpkgs_source from clan_lib.dirs import nixpkgs_source # noqa: PLC0415
return [ return [
*default_flags, *default_flags,
@@ -169,7 +169,7 @@ def nix_shell(packages: list[str], cmd: list[str]) -> list[str]:
if not missing_packages: if not missing_packages:
return cmd return cmd
from clan_lib.dirs import nixpkgs_flake from clan_lib.dirs import nixpkgs_flake # noqa: PLC0415
return [ return [
*nix_command(["shell", "--inputs-from", f"{nixpkgs_flake()!s}"]), *nix_command(["shell", "--inputs-from", f"{nixpkgs_flake()!s}"]),

View File

@@ -0,0 +1,53 @@
let
lib = import /home/johannes/git/nixpkgs/lib;
clanLib = import ../../../../lib { inherit lib; };
inherit (lib) evalModules mkOption types;
eval = evalModules {
modules = [
{
options.foos = mkOption {
type = types.attrsOf (
types.submodule {
options.bar = mkOption { };
}
);
};
# config.foos = lib.mkForce { this.bar = 42; };
config.instances.a = { };
# config.foo = lib.mkForce {
# bar = 42;
# };
}
{
_file = "inventory.json";
# instances.a = { setting = };
}
# {
# options.foo = mkOption {
# type = types.attrsOf (types.attrsOf (types.submoduleWith { modules = [
# {
# options.bar = mkOption {};
# }
# ]; }));
# default = { bar = { }; };
# };
# }
# {
# _file = "static.nix";
# foo.static.thing = { bar = 1; }; # <- Can: Op.Modify
# }
# {
# _file = "inventory.json";
# foo.managed.thing = { bar = 1; }; # <- Can: Op.Delete, Op.Modify
# #
# }
];
};
in
{
inherit clanLib eval;
}

View File

@@ -1,7 +1,11 @@
from enum import Enum
from typing import Any, TypedDict from typing import Any, TypedDict
from clan_lib.errors import ClanError from clan_lib.errors import ClanError
from clan_lib.persist.path_utils import PathTuple, path_to_string from clan_lib.persist.path_utils import (
PathTuple,
path_to_string,
)
WRITABLE_PRIORITY_THRESHOLD = 100 # Values below this are not writeable WRITABLE_PRIORITY_THRESHOLD = 100 # Values below this are not writeable
@@ -189,3 +193,69 @@ def compute_write_map(
""" """
return _determine_writeability_recursive(priorities, all_values, persisted) return _determine_writeability_recursive(priorities, all_values, persisted)
class RawAttributes(TypedDict):
headType: str
nullable: bool
prio: int
total: bool
files: list[str]
class OpType(Enum):
MODIFY = "modify"
DELETE = "delete"
def transform_attribute_properties(
introspection: dict[str, Any],
all_values: dict[str, Any],
persisted: dict[str, Any],
# Passthrough for recursion
curr_path: PathTuple = (),
parent_attributes: RawAttributes | None = None,
) -> dict[PathTuple, set[OpType]]:
"""Transform attribute properties to ensure correct types and defaults."""
results: dict[PathTuple, set[OpType]] = {}
for key, key_meta in introspection.items():
if key in {"__this", "__list"}:
continue
path = (*curr_path, key)
results[path] = set()
local_attributes: RawAttributes = key_meta.get("__this")
key_priority = local_attributes["prio"] or None
effective_priority = key_priority or (
parent_attributes["prio"] if parent_attributes else None
)
if effective_priority is None:
msg = f"Priority for path '{path_to_string(path)}' is not defined and no parent to inherit from. Cannot determine effective priority."
raise ClanError(msg)
if isinstance(key_meta, dict):
subattrs = transform_attribute_properties(
key_meta,
all_values.get(key, {}),
persisted.get(key, {}),
curr_path=path,
parent_attributes=local_attributes,
)
results.update(dict(subattrs.items()))
return results
# Only defined in inventory.json -> We might be able to delete it, because we defined it.
# But we could also have some option default somewhere else, so we cannot be sure.
# if all(f.endswith("inventory.json") for f in raw_attributes["files"]):
# operations.add(OpType.DELETE)
# if (
# raw_attributes["prio"] >= WRITABLE_PRIORITY_THRESHOLD
# or ".json" in raw_attributes["files"]
# ):
# operations.add(OpType.MODIFY)

View File

@@ -5,11 +5,110 @@ import pytest
from clan_lib.flake.flake import Flake from clan_lib.flake.flake import Flake
from clan_lib.persist.inventory_store import InventoryStore from clan_lib.persist.inventory_store import InventoryStore
from clan_lib.persist.write_rules import compute_write_map from clan_lib.persist.write_rules import (
compute_write_map,
transform_attribute_properties,
)
if TYPE_CHECKING: if TYPE_CHECKING:
from clan_lib.nix_models.clan import Clan from clan_lib.nix_models.clan import Clan
# foos.this = lib.mkForce { bar = 42; };
# ->
# {
# foos = {
# __this = {
# files = [
# "inventory.json"
# "<unknown-file>"
# ];
# headType = "attrsOf";
# nullable = false;
# prio = 100;
# total = false;
# };
# this = {
# __this = {
# files = [ "<unknown-file>" ];
# headType = "submodule";
# nullable = false;
# prio = 50;
# total = true;
# };
# bar = {
# __this = {
# files = [ "<unknown-file>" ];
# headType = "unspecified";
# nullable = false;
# prio = 100;
# total = false;
# };
# };
# };
# };
# }
def test_write_new() -> None:
all_data: dict = {"foo": {"bar": 42}}
persisted_data: dict = {}
introspection: dict = {
"foo": {
"__this": {
"files": ["/dir/file.nix"],
"headType": "unspecified",
"nullable": False,
"prio": 100, # <- default prio
"total": False,
},
"bar": {
"__this": {
"files": ["/dir/file.nix"],
"headType": "int",
"nullable": False,
"prio": 100, # <- default prio
"total": False,
}
},
}
}
res = transform_attribute_properties(introspection, all_data, persisted_data)
breakpoint()
# No operations allowed, because mkForce
# We cannot modify this value in ANY possible way.
# inventory.json definitions and children definition are filtered out by the module system
# assert attributes == {"operations": set(), "path": ["foo", "bar"]}
# normal_prio_attrs: RawAttributes = {
# "files": ["/dir/file.nix"],
# "headType": "attrsOf",
# "nullable": False,
# "prio": 100, # <- default prio
# "total": False,
# }
# attributes = transform_attribute_properties(("foo", "bar"), normal_prio_attrs)
# # We can modify this value, because its a normal prio
# # This means keys can be added/removed/changed respecting their individual local constraints
# assert attributes == {"operations": { OpType.MODIFY }, "path": ["foo", "bar"]}
# default_prio_attrs: RawAttributes = {
# "files": ["/dir/file.nix"],
# "headType": "attrsOf",
# "nullable": False,
# "prio": 100, # <- default prio
# "total": False,
# }
# attributes = transform_attribute_properties(("foo", "bar"), default_prio_attrs)
# # We can modify this value, because its a normal prio
# # This means keys can be added/removed/changed respecting their individual local constraints
# assert attributes == {"operations": { OpType.MODIFY, OpType.DELETE }, "path": ["foo", "bar"]}
# Integration test # Integration test
@pytest.mark.with_core @pytest.mark.with_core

View File

@@ -464,12 +464,12 @@ class Remote:
self, self,
opts: "ConnectionOptions | None" = None, opts: "ConnectionOptions | None" = None,
) -> None: ) -> None:
from clan_lib.network.check import check_machine_ssh_reachable from clan_lib.network.check import check_machine_ssh_reachable # noqa: PLC0415
return check_machine_ssh_reachable(self, opts) return check_machine_ssh_reachable(self, opts)
def check_machine_ssh_login(self) -> None: def check_machine_ssh_login(self) -> None:
from clan_lib.network.check import check_machine_ssh_login from clan_lib.network.check import check_machine_ssh_login # noqa: PLC0415
return check_machine_ssh_login(self) return check_machine_ssh_login(self)

View File

@@ -5,7 +5,6 @@ from clan_cli.vars import graph
from clan_cli.vars.generator import Generator from clan_cli.vars.generator import Generator
from clan_cli.vars.graph import requested_closure from clan_cli.vars.graph import requested_closure
from clan_cli.vars.migration import check_can_migrate, migrate_files from clan_cli.vars.migration import check_can_migrate, migrate_files
from clan_cli.vars.secret_modules import sops
from clan_lib.api import API from clan_lib.api import API
from clan_lib.errors import ClanError from clan_lib.errors import ClanError
@@ -153,15 +152,15 @@ def run_generators(
if not machines: if not machines:
msg = "At least one machine must be provided" msg = "At least one machine must be provided"
raise ClanError(msg) raise ClanError(msg)
all_generators = get_generators(machines, full_closure=True)
if isinstance(generators, list): if isinstance(generators, list):
# List of generator names - use them exactly as provided # List of generator names - use them exactly as provided
if len(generators) == 0: if len(generators) == 0:
return return
generators_to_run = [g for g in all_generators if g.key.name in generators] all_generators = get_generators(machines, full_closure=True)
generator_objects = [g for g in all_generators if g.key.name in generators]
else: else:
# None or single string - use get_generators with closure parameter # None or single string - use get_generators with closure parameter
generators_to_run = get_generators( generator_objects = get_generators(
machines, machines,
full_closure=full_closure, full_closure=full_closure,
generator_name=generators, generator_name=generators,
@@ -171,30 +170,13 @@ def run_generators(
# TODO: make this more lazy and ask for every generator on execution # TODO: make this more lazy and ask for every generator on execution
if callable(prompt_values): if callable(prompt_values):
prompt_values = { prompt_values = {
generator.name: prompt_values(generator) for generator in generators_to_run generator.name: prompt_values(generator) for generator in generator_objects
} }
# execute health check # execute health check
for machine in machines: for machine in machines:
_ensure_healthy(machine=machine) _ensure_healthy(machine=machine)
# ensure all selected machines have access to all selected shared generators
for machine in machines:
# This is only relevant for the sops store
# TODO: improve store abstraction to use Protocols and introduce a proper SecretStore interface
if not isinstance(machine.secret_vars_store, sops.SecretStore):
continue
for generator in all_generators:
if generator.share:
for file in generator.files:
if not file.secret or not file.exists:
continue
machine.secret_vars_store.ensure_machine_has_access(
generator,
file.name,
machine.name,
)
# get the flake via any machine (they are all the same) # get the flake via any machine (they are all the same)
flake = machines[0].flake flake = machines[0].flake
@@ -206,13 +188,13 @@ def run_generators(
# preheat the select cache, to reduce repeated calls during execution # preheat the select cache, to reduce repeated calls during execution
selectors = [] selectors = []
for generator in generators_to_run: for generator in generator_objects:
machine = get_generator_machine(generator) machine = get_generator_machine(generator)
selectors.append(generator.final_script_selector(machine.name)) selectors.append(generator.final_script_selector(machine.name))
flake.precache(selectors) flake.precache(selectors)
# execute generators # execute generators
for generator in generators_to_run: for generator in generator_objects:
machine = get_generator_machine(generator) machine = get_generator_machine(generator)
if check_can_migrate(machine, generator): if check_can_migrate(machine, generator):
migrate_files(machine, generator) migrate_files(machine, generator)

View File

@@ -290,7 +290,9 @@ def collect_commands() -> list[Category]:
# 3. sort by title alphabetically # 3. sort by title alphabetically
return (c.title.split(" ")[0], c.title, weight) return (c.title.split(" ")[0], c.title, weight)
return sorted(result, key=weight_cmd_groups) result = sorted(result, key=weight_cmd_groups)
return result
def build_command_reference() -> None: def build_command_reference() -> None:

View File

@@ -36,7 +36,7 @@ class MPProcess:
def _set_proc_name(name: str) -> None: def _set_proc_name(name: str) -> None:
if sys.platform != "linux": if sys.platform != "linux":
return return
import ctypes import ctypes # noqa: PLC0415
# Define the prctl function with the appropriate arguments and return type # Define the prctl function with the appropriate arguments and return type
libc = ctypes.CDLL("libc.so.6") libc = ctypes.CDLL("libc.so.6")

View File

@@ -759,12 +759,12 @@ class Win32Implementation(BaseImplementation):
SM_CXSMICON = 49 SM_CXSMICON = 49
if sys.platform == "win32": if sys.platform == "win32":
from ctypes import Structure from ctypes import Structure # noqa: PLC0415
class WNDCLASSW(Structure): class WNDCLASSW(Structure):
"""Windows class structure for window registration.""" """Windows class structure for window registration."""
from ctypes import CFUNCTYPE, wintypes from ctypes import CFUNCTYPE, wintypes # noqa: PLC0415
LPFN_WND_PROC = CFUNCTYPE( LPFN_WND_PROC = CFUNCTYPE(
wintypes.INT, wintypes.INT,
@@ -789,7 +789,7 @@ class Win32Implementation(BaseImplementation):
class MENUITEMINFOW(Structure): class MENUITEMINFOW(Structure):
"""Windows menu item information structure.""" """Windows menu item information structure."""
from ctypes import wintypes from ctypes import wintypes # noqa: PLC0415
_fields_: ClassVar = [ _fields_: ClassVar = [
("cb_size", wintypes.UINT), ("cb_size", wintypes.UINT),
@@ -809,7 +809,7 @@ class Win32Implementation(BaseImplementation):
class NOTIFYICONDATAW(Structure): class NOTIFYICONDATAW(Structure):
"""Windows notification icon data structure.""" """Windows notification icon data structure."""
from ctypes import wintypes from ctypes import wintypes # noqa: PLC0415
_fields_: ClassVar = [ _fields_: ClassVar = [
("cb_size", wintypes.DWORD), ("cb_size", wintypes.DWORD),
@@ -1061,7 +1061,7 @@ class Win32Implementation(BaseImplementation):
if sys.platform != "win32": if sys.platform != "win32":
return return
from ctypes import wintypes from ctypes import wintypes # noqa: PLC0415
if self._menu is None: if self._menu is None:
self.update_menu() self.update_menu()
@@ -1110,7 +1110,7 @@ class Win32Implementation(BaseImplementation):
if sys.platform != "win32": if sys.platform != "win32":
return 0 return 0
from ctypes import wintypes from ctypes import wintypes # noqa: PLC0415
if msg == self.WM_TRAYICON: if msg == self.WM_TRAYICON:
if l_param == self.WM_RBUTTONUP: if l_param == self.WM_RBUTTONUP: