Compare commits

..

1 Commits

Author SHA1 Message Date
Qubasa
e60c3d984d clan-cli: Refactor the API to use the Flake object 2025-05-07 15:03:37 +02:00
121 changed files with 3890 additions and 4442 deletions

View File

@@ -1,29 +0,0 @@
name: "Update pinned clan-core for checks"
on:
repository_dispatch:
workflow_dispatch:
schedule:
- cron: "51 2 * * *"
jobs:
update-pinned-clan-core:
runs-on: nix
steps:
- uses: actions/checkout@v4
with:
submodules: true
- name: Update clan-core for checks
run: nix run .#update-clan-core-for-checks
- name: Create pull request
run: |
git commit -am ""
git push origin HEAD:update-clan-core-for-checks
curl -X POST \
-H "Authorization: token $GITEA_TOKEN" \
-H "Content-Type: application/json" \
-d '{
"head": "update-clan-core-branch",
"base": "main",
"title": "Automated Update: Clan Core",
"body": "This PR updates the pinned clan-core for checks."
}' \
"${GITEA_SERVER_URL}/api/v1/repos/${GITEA_OWNER}/${GITEA_REPO}/pulls"

View File

@@ -1,64 +0,0 @@
{
pkgs,
self,
clanLib,
...
}:
let
public-key = "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAII6zj7ubTg6z/aDwRNwvM/WlQdUocMprQ8E92NWxl6t+ test@test";
in
clanLib.test.makeTestClan {
inherit pkgs self;
nixosTest = (
{ ... }:
{
name = "admin";
clan = {
directory = ./.;
modules."@clan/admin" = ../../clanServices/admin/default.nix;
inventory = {
machines.client = { };
machines.server = { };
instances = {
ssh-test-one = {
module.name = "@clan/admin";
roles.default.machines."server".settings = {
allowedKeys.testkey = public-key;
};
};
};
};
};
nodes = {
client.environment.etc.private-test-key.source = ./private-test-key;
server = {
services.openssh = {
enable = true;
settings.UsePAM = false;
};
};
};
testScript = ''
start_all()
machines = [client, server]
for m in machines:
m.systemctl("start network-online.target")
for m in machines:
m.wait_for_unit("network-online.target")
client.succeed(f"ssh -F /dev/null -i /etc/private-test-key -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -o BatchMode=yes root@server true &>/dev/null")
'';
}
);
}

View File

@@ -1,8 +0,0 @@
-----BEGIN OPENSSH PRIVATE KEY-----
b3BlbnNzaC1rZXktdjEAAAAABG5vbmUAAAAEbm9uZQAAAAAAAAABAAAAMwAAAAtzc2gtZW
QyNTUxOQAAACCOs4+7m04Os/2g8ETcLzP1pUHVKHDKa0PBPdjVsZerfgAAAJDXdRkm13UZ
JgAAAAtzc2gtZWQyNTUxOQAAACCOs4+7m04Os/2g8ETcLzP1pUHVKHDKa0PBPdjVsZerfg
AAAECIgb2FQcgBKMniA+6zm2cwGre60ATu3Sg1GivgAqVJlI6zj7ubTg6z/aDwRNwvM/Wl
QdUocMprQ8E92NWxl6t+AAAAC3BpbnBveEBraXdpAQI=
-----END OPENSSH PRIVATE KEY-----

View File

@@ -1,6 +0,0 @@
[
{
"publickey": "age1q4e7nsw5z6mqeqk5u5kug8lwhpq3f276s0t0npwfffwdkfh58gkqxknhjg",
"type": "age"
}
]

View File

@@ -1,15 +0,0 @@
{
"data": "ENC[AES256_GCM,data:ET/FggP6t7L60krfVRvtMjv++xr3zqRsJ58AfnPS1zjTovV5tE9RgnboGY1ieS7fCs4VOL2S6ELtwV1+BTLDQX9s0c5A9cKqjnc=,iv:6EQ6DOqxUdHcOziTxf8kl0sp1Pggu720s5BJ8zA9Je0=,tag:hQMPWaWb4igqDYjwNehlqQ==,type:str]",
"sops": {
"age": [
{
"recipient": "age1qm0p4vf9jvcnn43s6l4prk8zn6cx0ep9gzvevxecv729xz540v8qa742eg",
"enc": "-----BEGIN AGE ENCRYPTED FILE-----\nYWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSBRWjhuZkgwNEZTL3JXZHFE\nTC9jSXJGcVd2bnkvOE1qV0d6TzNobFZobndvCmF1UmhVUWtKeVVwS29NY21ONkRn\nZU5sM01kTU9rQVNENi9paUFWbERoWnMKLS0tIEdjZzgwQjFtWlVtRGZwdW9GY0FK\nSER1TTFNVGxFa0ZrclR4MitWVERiSGMK9DNLzlJZelcpP0klwSDMggTAy5ZVOmsZ\niuu8dXMSdIeTd7l8rpZZN27BaKUm8yEDpUmot5Vq9rbZl6SO3ncX+A==\n-----END AGE ENCRYPTED FILE-----\n"
}
],
"lastmodified": "2025-05-07T11:45:41Z",
"mac": "ENC[AES256_GCM,data:m8eTnPtMzrooEah43mvjwHxQIwR/aq+A1wYyG/rQ75COq/TQepfMiDSrCJKW8x+OKmN/3HZs1b9k659jNNMF+RtMag0+/ovTmr7PQux3IkzWl+R2kU3Y7WDOMweBKY3mTMu6reICE1YVME8vJwhDDbA5JCXJv64rkTz2tfGt4CQ=,iv:/vrwJyEVsfm1cUK//TesY24Makt8YI8mwx5GIhn4038=,tag:H2tS9ohvWJ4TWB6LghcZNg==,type:str]",
"unencrypted_suffix": "_unencrypted",
"version": "3.10.2"
}
}

View File

@@ -1 +0,0 @@
../../../users/admin

View File

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

View File

@@ -1 +0,0 @@
ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAICVVQjCEuryZii1LmJyjx9DX44eJh3qwTTEWlahYONsz nixbld@kiwi

View File

@@ -1 +0,0 @@
../../../../../../sops/machines/server

View File

@@ -1,19 +0,0 @@
{
"data": "ENC[AES256_GCM,data:yH7IQixe4nudnK4QOsr7VYoJ1YrVLP0Ufvgu7TNWSJnc55khKZHvQiDIlxzCIrAyMgUYwPNmrrZn9PZhgjAQZm7/o6SmP91Efb0yWM55o861El6v59yw0fseo3z6xAisjlg3KwTd5KMrRhzT0HzrjLn89SYRVh7DAWK+Cs7HVGvKVJ1E6AWiJmFPXIB7YaqJ7P4jZW9u7bEMCZabsRRqgS8dWXVXw9VS5ll4bNYQY4x5p2eg6e81zdeY2Y9Gbi5ty1Whqpzko2Pvggu6K4zUDXikM4lWggvIXzfrJA7HNE3xzXw94J45woj1y5FVOzn1Ve5kCc8PjVGaJ32poGkZiiD07kd5PxZuyVexREJpgz29lyB6nRJJeau4gpSG1VHOyNdwwBsBBm+zn6v2rlVzJPTlqmCV1+5UKf8JZKziIDFfi/78kSdtaeX+miJJvyDRkqNpQ7htEI0TAS8yQrkjWEIyaPAWQ2Usa8g1UrEftTlGUi/aMC2ob0qTLQQbhNhlSV/dImzI/qRMqSy2RWeS,iv:EuprKOFKzNLZrGlPtU2mEjmtNPNOcuVDbuvrtYyrerc=,tag:ny/q1AMHIQ8OgUNEE0Cc8w==,type:str]",
"sops": {
"age": [
{
"recipient": "age1q4e7nsw5z6mqeqk5u5kug8lwhpq3f276s0t0npwfffwdkfh58gkqxknhjg",
"enc": "-----BEGIN AGE ENCRYPTED FILE-----\nYWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSBLODFxUjREa2tOYW9xaHYw\nQlhWZ282UVhiOGRndk0xYnlCQWRYR01qS2hJCllySUZyblJmTkgyZXd5bjVINDBo\nbEhIWmxycVdOVW0xTUxkalF5Y1k2bXcKLS0tIGRRS1VqOG5sanh2dXR5a2FGeXRs\nK3ZUdERCdEkvMmt3ZndPZEM3QUxJZzAKutOr9jHPCL86zEdMWJ6YZmplcr4tDAcN\nncQfC5rddYDW+0y/crwepKTa2FZjQheOY7jobZanU19ai521hqDSVw==\n-----END AGE ENCRYPTED FILE-----\n"
},
{
"recipient": "age1qm0p4vf9jvcnn43s6l4prk8zn6cx0ep9gzvevxecv729xz540v8qa742eg",
"enc": "-----BEGIN AGE ENCRYPTED FILE-----\nYWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSBxc3NxNGhRYmU3eFNodDZ4\ndnNTeHFnNXBKbUxmNHBjRlFpNG0zdVNpS2d3CjhrOUlSQU5BZVlSdWR3dnNyODZO\nRFBKZWpwWHlOUW03OGlVZlRQUmMrMzQKLS0tIEd6ei9LU3ZFTzlWTUk1c3huS1RQ\nbG1vQzI4ODJkeFcyRnJaQWp1Wk9zSkUKXefMOk/ZT4P6DItfnM82RoOvX4SBn7Fn\nlAoMnSzaRCunDwq7ha05G45gcI2Wjv3urjt0tmdmrmTnFtBSSt23TQ==\n-----END AGE ENCRYPTED FILE-----\n"
}
],
"lastmodified": "2025-05-07T11:45:47Z",
"mac": "ENC[AES256_GCM,data:ORCANHbEX13O+zBVLOYyPxYIr1RS3NybTBb23ES7RbiGhSl2t/TXcfPWU5Smuqee0tfcrxL0u1FELZta4IysySW54JlD2907E9OUJWlQ6seOxADla4TMukW2pwhSsUJ9XfjEwC07zYB0alHzO3pY+LG3OAWzyhAlWzHlB5+WqIA=,iv:As+CjAJxKht0PJs3S2WWzho7UBqaUUltBIrYvlzBAbM=,tag:PSyUKaPZZNCxqd6XLPJSCw==,type:str]",
"unencrypted_suffix": "_unencrypted",
"version": "3.10.2"
}
}

View File

@@ -1 +0,0 @@
../../../../../../sops/users/admin

View File

@@ -1 +0,0 @@
../../../../../../sops/machines/server

View File

@@ -1,19 +0,0 @@
{
"data": "ENC[AES256_GCM,data:5Fa0TQN/Whj311JZuVWXnp+2KJaNZPb/TOnP23T+KktulabcBA9go+/F+8wJbsEH2mf6UDq656p6C+kLIvfBFl2O/WwSOhsl23as9TLbgB6gBq73GjyV81VFsnLYNLHKMq+8nfJHM/WekA==,iv:n5vz3q5N6DplLWibdiCcYDdiN7q1VggzPoIYy9r2ZJw=,tag:FoGXrrJfjHZCUVTS2RESmw==,type:str]",
"sops": {
"age": [
{
"recipient": "age1q4e7nsw5z6mqeqk5u5kug8lwhpq3f276s0t0npwfffwdkfh58gkqxknhjg",
"enc": "-----BEGIN AGE ENCRYPTED FILE-----\nYWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSBheXZvUW9YbjBFMi9mZnVk\ncGFPQzFOZkNPMU1HckhtSGtDWExpWVNYRlV3CjdDaDlSd2wzVnhKZGU0aFY0UnZY\nQStPSkxuSmlyOU9aeUdRaEJ2UTRRSm8KLS0tIFd3SG9YdEU5T2tzNk16b2s1SUNj\nWkh2cng5eWd3ZmxVZDhSR2Y1QnFySDgKGb/t+8NqiSGgmFOJc1NmDYZ+PXlANy8V\nuFwUTeqWAv7pOiGC8oessfyTPaJ7gWjz+XfKV5JVVikK2l3J4eAGxg==\n-----END AGE ENCRYPTED FILE-----\n"
},
{
"recipient": "age1qm0p4vf9jvcnn43s6l4prk8zn6cx0ep9gzvevxecv729xz540v8qa742eg",
"enc": "-----BEGIN AGE ENCRYPTED FILE-----\nYWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSBWM0daWmxCTjAyQStwQ2lM\nNkcyZW9hRmpDelRJR0VVTWhNTGFuZWhCc1RJCm81ZXowZjBhWGpIQTBhQnZLSmQy\nVUNNYjI0bVpqQ21YZS95TW53OUx1YUkKLS0tIDRUUE1zczBDeFJTOTQyVXVkMkYy\ncVVTN3J6TWtwcXVpM0M5c0gxUXpmV2cKwlWrbGLtkO2+PXKoMoHTV5aJpnfVy3RP\n6i8DDpLPGYfVUtWxHx+L+NmMxmw1AvmKSbdB4Y7aSbBW2mea3j1YCg==\n-----END AGE ENCRYPTED FILE-----\n"
}
],
"lastmodified": "2025-05-07T11:45:50Z",
"mac": "ENC[AES256_GCM,data:rwdbGOg8l8fWT2GYFx+PgV3oPxt5+NCHJf3PhG3V2lrRMPRisyf1nKwDsYavTuhv+bZC/qo4LrGylcXsHWdkCe/xBX+/jYLMf6nJZPk8BPzfUpiDnEKwRl05qfRfkIDusnQrlBrE+tqtcool65js7hYIzSi92O/hxbzzfsCUpqk=,iv:lUTNJkr6Zh3MQm/h7Ven4N6xVn4VeTXOEKzxd0HSsCk=,tag:Bwbi4HD9vzso6306y7EZOg==,type:str]",
"unencrypted_suffix": "_unencrypted",
"version": "3.10.2"
}
}

View File

@@ -1 +0,0 @@
../../../../../../sops/users/admin

View File

@@ -1,15 +0,0 @@
{
"data": "ENC[AES256_GCM,data:sPh+BuT2we+d/GaMv4zPWc3rPhlMsJQC,iv:VwcHUOMaNiao+R8RBtUINffEUhutktKD6KEWLkFxyp4=,tag:SNVKLjjDv+u5XTVczs2/Uw==,type:str]",
"sops": {
"age": [
{
"recipient": "age1qm0p4vf9jvcnn43s6l4prk8zn6cx0ep9gzvevxecv729xz540v8qa742eg",
"enc": "-----BEGIN AGE ENCRYPTED FILE-----\nYWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSBJVWNYRGEwVWxDSmE4bTNL\nRlZPeGZabFZZNGFsMEwzV1ZmT1pqNVk4STMwCkg5UER0Vjk3K1RMazVVYjF3SDc2\ndDZHa3VtYjRiWUJET25weXprc0JNUjAKLS0tIDdVb2xNdWxCcjhpSGtGWDV0d2ti\nZENkZGNpSTNzMVVTZVN0ZktLc2VackEKdexhI37pwcnbZbcy30k9Uo5Z7z3NLqlx\nspxJ87SzEwdStTMhiH1iYf62vcyAOTa4HwfXu97MGVPFNw13/VfgCw==\n-----END AGE ENCRYPTED FILE-----\n"
}
],
"lastmodified": "2025-05-07T11:45:50Z",
"mac": "ENC[AES256_GCM,data:tZRh8qj7JUnhXCfqCHJKWEFQ8XLtmo/p0C+eFIK+34enxfB5lG5Lq83wBXLa0D/nqrr58z1rLO+UVDOI5LH1jFxARBZZnUKrVJNTDHa5pUnlnVOFEOoc+R0h2E5Xw9OHaq7aDUh4fT9+gNDpguKggI5fS9KqRnmZ4VrpNccjnkw=,iv:2yI25fcWMog91EMD7bYQy3GS30a7gZHnif93MaE3sZo=,tag:tYqa6zssiU3BCFU5xmDYZQ==,type:str]",
"unencrypted_suffix": "_unencrypted",
"version": "3.10.2"
}
}

View File

@@ -1 +0,0 @@
../../../../../../sops/users/admin

View File

@@ -147,7 +147,25 @@
perSystem =
{ pkgs, ... }:
let
clanCore = self.checks.x86_64-linux.clan-core-for-checks;
clanCore = self.filter {
include = [
"checks/backups"
"checks/flake-module.nix"
"clanModules/borgbackup"
"clanModules/flake-module.nix"
"clanModules/localbackup"
"clanModules/packages"
"clanModules/single-disk"
"clanModules/zerotier"
"flake.lock"
"flakeModules"
"inventory.json"
"nixosModules"
# Just include everything in 'lib'
# If anything changes in /lib that may affect everything
"lib"
];
};
in
{
checks = pkgs.lib.mkIf pkgs.stdenv.isLinux {
@@ -164,6 +182,11 @@
# import the inventory generated nixosModules
self.clanInternals.inventoryClass.machines.test-backup.machineImports;
clan.core.settings.directory = ./.;
environment.systemPackages = [
(pkgs.writeShellScriptBin "foo" ''
echo ${clanCore}
'')
];
};
testScript = ''

View File

@@ -1,6 +0,0 @@
{ fetchgit }:
fetchgit {
url = "https://git.clan.lol/clan/clan-core.git";
rev = "1e8b9def2a021877342491ca1f4c45533a580759";
sha256 = "0f12vwr1abwa1iwjbb5z5xx8jlh80d9njwdm6iaw1z1h2m76xgzc";
}

View File

@@ -34,33 +34,33 @@ clanLib.test.makeTestClan {
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;
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
'';
};
};
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
'';
};
};
};

View File

@@ -14,7 +14,7 @@ in
./installation/flake-module.nix
./morph/flake-module.nix
./nixos-documentation/flake-module.nix
./dont-depend-on-repo-root.nix
./sanity-checks/dont-depend-on-repo-root.nix
];
perSystem =
{
@@ -34,33 +34,27 @@ in
inherit self;
inherit (self) clanLib;
};
nixosTests =
lib.optionalAttrs (pkgs.stdenv.isLinux) {
# Deltachat is currently marked as broken
# deltachat = import ./deltachat nixosTestArgs;
nixosTests = lib.optionalAttrs (pkgs.stdenv.isLinux) {
# Deltachat is currently marked as broken
# deltachat = import ./deltachat nixosTestArgs;
# Base Tests
secrets = self.clanLib.test.baseTest ./secrets nixosTestArgs;
borgbackup = self.clanLib.test.baseTest ./borgbackup nixosTestArgs;
wayland-proxy-virtwl = self.clanLib.test.baseTest ./wayland-proxy-virtwl nixosTestArgs;
# Base Tests
secrets = self.clanLib.test.baseTest ./secrets nixosTestArgs;
borgbackup = self.clanLib.test.baseTest ./borgbackup nixosTestArgs;
wayland-proxy-virtwl = self.clanLib.test.baseTest ./wayland-proxy-virtwl nixosTestArgs;
# Container Tests
container = self.clanLib.test.containerTest ./container nixosTestArgs;
zt-tcp-relay = self.clanLib.test.containerTest ./zt-tcp-relay nixosTestArgs;
matrix-synapse = self.clanLib.test.containerTest ./matrix-synapse nixosTestArgs;
postgresql = self.clanLib.test.containerTest ./postgresql nixosTestArgs;
# Container Tests
container = self.clanLib.test.containerTest ./container nixosTestArgs;
zt-tcp-relay = self.clanLib.test.containerTest ./zt-tcp-relay nixosTestArgs;
matrix-synapse = self.clanLib.test.containerTest ./matrix-synapse nixosTestArgs;
postgresql = self.clanLib.test.containerTest ./postgresql nixosTestArgs;
# Clan Tests
dummy-inventory-test = import ./dummy-inventory-test nixosTestArgs;
admin = import ./admin nixosTestArgs;
data-mesher = import ./data-mesher nixosTestArgs;
syncthing = import ./syncthing nixosTestArgs;
}
// lib.optionalAttrs (pkgs.stdenv.hostPlatform.system == "aarch64-linux") {
# for some reason this hangs in an odd place in CI, but it works on my machine ...
# on aarch64-linux it works though
mumble = import ./mumble nixosTestArgs;
};
# Clan Tests
mumble = import ./mumble nixosTestArgs;
dummy-inventory-test = import ./dummy-inventory-test nixosTestArgs;
data-mesher = import ./data-mesher nixosTestArgs;
syncthing = import ./syncthing nixosTestArgs;
};
packagesToBuild = lib.removeAttrs self'.packages [
# exclude the check that checks that nothing depends on the repo root
@@ -107,12 +101,6 @@ in
mkdir -p $out
cat $schemaFile > $out/allSchemas.json
'';
clan-core-for-checks = pkgs.runCommand "clan-core-for-checks" { } ''
cp -r ${pkgs.callPackage ./clan-core-for-checks.nix { }} $out
chmod +w $out/flake.lock
cp ${../flake.lock} $out/flake.lock
'';
};
legacyPackages = {
nixosTests =

View File

@@ -43,7 +43,6 @@
let
dependencies = [
pkgs.disko
pkgs.buildPackages.xorg.lndir
self.nixosConfigurations."test-flash-machine-${pkgs.hostPlatform.system}".pkgs.perlPackages.ConfigIniFiles
self.nixosConfigurations."test-flash-machine-${pkgs.hostPlatform.system}".pkgs.perlPackages.FileSlurp
@@ -81,7 +80,7 @@
# Some distros like to automount disks with spaces
machine.succeed('mkdir -p "/mnt/with spaces" && mkfs.ext4 /dev/vdb && mount /dev/vdb "/mnt/with spaces"')
machine.succeed("clan flash write --debug --flake ${self.checks.x86_64-linux.clan-core-for-checks} --yes --disk main /dev/vdb test-flash-machine-${pkgs.hostPlatform.system}")
machine.succeed("clan flash write --debug --flake ${../..} --yes --disk main /dev/vdb test-flash-machine-${pkgs.hostPlatform.system}")
'';
} { inherit pkgs self; };
};

View File

@@ -15,7 +15,6 @@ let
pkgs.bash.drvPath
pkgs.nixos-anywhere
pkgs.bubblewrap
pkgs.buildPackages.xorg.lndir
] ++ builtins.map (i: i.outPath) (builtins.attrValues self.inputs);
closureInfo = pkgs.closureInfo { rootPaths = dependencies; };
in
@@ -198,7 +197,7 @@ in
installer.succeed("${pkgs.coreutils}/bin/install -Dm 600 ${../assets/ssh/privkey} /root/.ssh/id_ed25519")
installer.wait_until_succeeds("timeout 2 ssh -o StrictHostKeyChecking=accept-new -v nonrootuser@localhost hostname")
installer.succeed("cp -r ${self.checks.x86_64-linux.clan-core-for-checks} test-flake && chmod -R +w test-flake")
installer.succeed("cp -r ${../..} test-flake && chmod -R +w test-flake")
installer.succeed("clan machines install --no-reboot --debug --flake test-flake --yes test-install-machine-without-system --target-host nonrootuser@localhost --update-hardware-config nixos-facter >&2")
installer.shutdown()
@@ -218,7 +217,7 @@ in
installer.start()
installer.succeed("${pkgs.coreutils}/bin/install -Dm 600 ${../assets/ssh/privkey} /root/.ssh/id_ed25519")
installer.wait_until_succeeds("timeout 2 ssh -o StrictHostKeyChecking=accept-new -v nonrootuser@localhost hostname")
installer.succeed("cp -r ${self.checks.x86_64-linux.clan-core-for-checks} test-flake && chmod -R +w test-flake")
installer.succeed("cp -r ${../..} test-flake && chmod -R +w test-flake")
installer.fail("test -f test-flake/machines/test-install-machine/hardware-configuration.nix")
installer.fail("test -f test-flake/machines/test-install-machine/facter.json")

View File

@@ -32,6 +32,7 @@
{ pkgs, ... }:
let
dependencies = [
self
pkgs.stdenv.drvPath
pkgs.stdenvNoCC
self.nixosConfigurations.test-morph-machine.config.system.build.toplevel
@@ -54,7 +55,7 @@
testScript = ''
start_all()
actual.fail("cat /etc/testfile")
actual.succeed("env CLAN_DIR=${self.checks.x86_64-linux.clan-core-for-checks} clan machines morph test-morph-template --i-will-be-fired-for-using-this --debug --name test-morph-machine")
actual.succeed("env CLAN_DIR=${self} clan machines morph test-morph-template --i-will-be-fired-for-using-this --debug --name test-morph-machine")
assert actual.succeed("cat /etc/testfile") == "morphed"
'';
} { inherit pkgs self; };

View File

@@ -47,20 +47,6 @@ clanLib.test.makeTestClan {
nodes.peer2 = common;
testScript = ''
import time
import re
def machine_has_text(machine: Machine, regex: str) -> bool:
variants = machine.get_screen_text_variants()
# for debugging
# machine.screenshot(f"/tmp/{machine.name}.png")
for text in variants:
print(f"Expecting '{regex}' in '{text}'")
if re.search(regex, text) is not None:
return True
return False
start_all()
with subtest("Waiting for x"):
@@ -77,53 +63,41 @@ clanLib.test.makeTestClan {
peer2.execute("mumble >&2 &")
with subtest("Wait for Mumble"):
peer1.wait_for_window(r"Mumble")
peer2.wait_for_window(r"Mumble")
peer1.wait_for_window(r"^Mumble$")
peer2.wait_for_window(r"^Mumble$")
with subtest("Wait for certificate creation"):
peer1.wait_for_window(r"Mumble")
peer2.wait_for_window(r"Mumble")
peer1.wait_for_window(r"^Mumble$")
peer1.sleep(3) # mumble is slow to register handlers
peer1.send_chars("\n")
peer1.send_chars("\n")
peer2.wait_for_window(r"^Mumble$")
peer2.sleep(3) # mumble is slow to register handlers
peer2.send_chars("\n")
peer2.send_chars("\n")
for i in range(20):
time.sleep(1)
peer1.send_chars("\n")
peer1.send_chars("\n")
peer2.send_chars("\n")
peer2.send_chars("\n")
if machine_has_text(peer1, r"Mumble Server Connect") and \
machine_has_text(peer2, r"Mumble Server Connect"):
break
else:
raise Exception("Timeout waiting for certificate creation")
with subtest("Wait for server connect"):
peer1.wait_for_window(r"^Mumble Server Connect$")
peer2.wait_for_window(r"^Mumble Server Connect$")
with subtest("Check validity of server certificates"):
peer1.execute("killall .mumble-wrapped")
peer1.sleep(1)
peer1.execute("mumble mumble://peer2 >&2 &")
peer1.wait_for_window(r"Mumble")
for i in range(20):
time.sleep(1)
peer1.send_chars("\n")
peer1.send_chars("\n")
if machine_has_text(peer1, "Connected."):
break
else:
raise Exception("Timeout waiting for certificate creation")
peer1.wait_for_window(r"^Mumble$")
peer1.sleep(3) # mumble is slow to register handlers
peer1.send_chars("\n")
peer1.send_chars("\n")
peer1.wait_for_text("Connected.")
peer2.execute("killall .mumble-wrapped")
peer2.sleep(1)
peer2.execute("mumble mumble://peer1 >&2 &")
peer2.wait_for_window(r"Mumble")
for i in range(20):
time.sleep(1)
peer2.send_chars("\n")
peer2.send_chars("\n")
if machine_has_text(peer2, "Connected."):
break
else:
raise Exception("Timeout waiting for certificate creation")
peer2.wait_for_window(r"^Mumble$")
peer2.sleep(3) # mumble is slow to register handlers
peer2.send_chars("\n")
peer2.send_chars("\n")
peer2.wait_for_text("Connected.")
'';
}
);

View File

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

View File

@@ -0,0 +1,22 @@
{ lib, config, ... }:
{
options.clan.admin = {
allowedKeys = lib.mkOption {
default = { };
type = lib.types.attrsOf lib.types.str;
description = "The allowed public keys for ssh access to the admin user";
example = {
"key_1" = "ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABAQD...";
};
};
};
# Bad practice.
# Should we add 'clanModules' to specialArgs?
imports = [
../../sshd
../../root-password
];
config = {
users.users.root.openssh.authorizedKeys.keys = builtins.attrValues config.clan.admin.allowedKeys;
};
}

View File

@@ -105,7 +105,10 @@ in
private_key = {
inherit owner;
};
public_key.secret = false;
public_key = {
inherit owner;
secret = false;
};
};
runtimeInputs = [
@@ -131,7 +134,10 @@ in
private_key = {
inherit owner;
};
public_key.secret = false;
public_key = {
inherit owner;
secret = false;
};
};
runtimeInputs = [

View File

@@ -8,6 +8,7 @@ in
{
# only import available files, as this allows to filter the files for tests.
flake.clanModules = filterAttrs (_name: pathExists) {
admin = ./admin;
auto-upgrade = ./auto-upgrade;
borgbackup = ./borgbackup;
borgbackup-static = ./borgbackup-static;

View File

@@ -1,37 +0,0 @@
{ ... }:
{
_class = "clan.service";
manifest.name = "clan-core/admin";
roles.default = {
interface =
{ lib, ... }:
{
options.allowedKeys = lib.mkOption {
default = { };
type = lib.types.attrsOf lib.types.str;
description = "The allowed public keys for ssh access to the admin user";
example = {
"key_1" = "ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABAQD...";
};
};
};
perInstance =
{ settings, ... }:
{
nixosModule =
{ ... }:
{
imports = [
../../clanModules/sshd
../../clanModules/root-password
];
users.users.root.openssh.authorizedKeys.keys = builtins.attrValues settings.allowedKeys;
};
};
};
}

View File

@@ -1,10 +1,5 @@
{ lib, ... }:
{
imports = [
./hello-world/flake-module.nix
];
clan.modules = {
admin = lib.modules.importApply ./admin/default.nix { };
};
}

View File

@@ -10,6 +10,9 @@ let
};
in
{
clan.inventory.modules = {
hello-world = module;
};
clan.modules = {
hello-world = module;
};

View File

@@ -18,7 +18,7 @@ let
};
# Register the module for the test
modules.hello-world = module;
inventory.modules.hello-world = module;
# Use the module in the test
inventory.instances = {

View File

@@ -14,9 +14,6 @@ clanLib.test.makeTestClan {
clan = {
directory = ./.;
modules = {
hello-service = module;
};
inventory = {
machines.peer1 = { };
@@ -24,6 +21,10 @@ clanLib.test.makeTestClan {
module.name = "hello-service";
roles.peer.machines.peer1 = { };
};
modules = {
hello-service = module;
};
};
};

View File

@@ -58,7 +58,7 @@ nav:
- Autoincludes: manual/adding-machines.md
- Inventory:
- Inventory: manual/inventory.md
- Services: manual/distributed-services.md
- Instances: manual/distributed-services.md
- Secure Boot: manual/secure-boot.md
- Flake-parts: manual/flake-parts.md
- Authoring:
@@ -71,16 +71,14 @@ nav:
- Testing: contributing/testing.md
- Repo Layout: manual/repo-layout.md
- Migrate existing Flakes: manual/migration-guide.md
- Migrate inventory Services: guides/migrate-inventory-services.md
- Reference:
- Overview: reference/index.md
- Clan Modules:
- Overview:
- reference/clanModules/index.md
- reference/clanModules/frontmatter/index.md
# TODO: display the docs of the clan.service modules
# - reference/clanServices/admin.md
# This is the module overview and should stay at the top
- reference/clanModules/admin.md
- reference/clanModules/borgbackup-static.md
- reference/clanModules/data-mesher.md
- reference/clanModules/borgbackup.md

View File

@@ -12,7 +12,7 @@ We discussed the initial architecture in [01-clan-service-modules](https://git.c
### A Minimal module
First of all we need to register our module into the `clan.modules` attribute. Make sure to choose a unique name so the module doesn't have a name collision with any of the core modules.
First of all we need to register our module into the `inventory.modules` attribute. Make sure to choose a unique name so the module doesn't have a name collision with any of the core modules.
While not required we recommend to prefix your module attribute name.
@@ -22,15 +22,20 @@ i.e. `@hsjobeki/customNetworking`
```nix title="flake.nix"
# ...
outputs = inputs: inputs.flake-parts.lib.mkFlake { inherit inputs; } ({
outputs = inputs: flake-parts.lib.mkFlake { inherit inputs; } ({
imports = [ inputs.clan-core.flakeModules.default ];
# ...
clan = {
inventory = {
# We could also inline the complete module spec here
# For example
# {...}: { _class = "clan.service"; ... };
modules."@hsjobeki/customNetworking" = import ./service-modules/networking.nix;
};
# If needed: Exporting the module for other people
modules."@hsjobeki/customNetworking" = import ./service-modules/networking.nix;
# We could also inline the complete module spec here
# For example
# {...}: { _class = "clan.service"; ... };
};
})
```
@@ -216,6 +221,9 @@ outputs = inputs: flake-parts.lib.mkFlake { inherit inputs; } ({self, lib, ...}:
# ...
clan = {
# Register the module
inventory.modules."@hsjobeki/messaging" = lib.importApply ./service-modules/messaging.nix { inherit self; };
# Expose the module for downstream users, 'self' would always point to this flake.
modules."@hsjobeki/messaging" = lib.importApply ./service-modules/messaging.nix { inherit self; };
};
})
@@ -242,7 +250,7 @@ outputs = inputs: flake-parts.lib.mkFlake { inherit inputs; } ({self, lib, ...}:
# ...
clan = {
# Register the module
modules."@hsjobeki/messaging" = {
inventory.modules."@hsjobeki/messaging" = {
# Create an option 'myClan' and assign it to 'self'
options.myClan = lib.mkOption {
default = self;

View File

@@ -32,7 +32,7 @@ VM tests should be avoided wherever it is possible to implement a cheaper unit t
Existing nixos vm tests in clan-core can be found by using ripgrep:
```shellSession
rg self.clanLib.test.baseTest
rg "import.*/lib/test-base.nix"
```
### Locating definitions of failing VM tests
@@ -50,7 +50,7 @@ example: locating the vm test named `borgbackup`:
```shellSession
$ rg "borgbackup =" ./checks
./checks/flake-module.nix
44- wayland-proxy-virtwl = self.clanLib.test.baseTest ./wayland-proxy-virtwl nixosTestArgs;
41: borgbackup = import ./borgbackup nixosTestArgs;
```
-> the location of that test is `/checks/flake-module.nix` line `41`.
@@ -99,15 +99,15 @@ Basically everything stated under the NixOS VM tests sections applies here, exce
Limitations:
- Cannot run in interactive mode, however while the container test runs, it logs a nsenter command that can be used to log into each of the container.
- setuid binaries don't work
- does not yet support networking
- supports only one machine as of now
### Where to find examples for NixOS container tests
Existing nixos container tests in clan-core can be found by using ripgrep:
```shellSession
rg self.clanLib.test.containerTest
rg "import.*/lib/container-test.nix"
```

View File

@@ -1,7 +0,0 @@
# How to migrate `Inventory.services`
## Further reference
- [Authoring a 'clan.service' module](../authoring/clanServices/index.md)
- [Setting up `inventory.instances`](../manual/distributed-services.md)
- [Inventory Reference](../reference/nix-api/inventory.md)

View File

@@ -1,10 +1,8 @@
# Setting up `inventory.instances`
In Clan *distributed services* can be declaratively deployed using the `inventory.instances` attribute
# Instances
First of all it might be needed to explain what we mean by the term *distributed service*
## What is considered a distributed service?
## What is considered a service?
A **distributed service** is a system where multiple machines work together to provide a certain functionality, abstracting complexity and allowing for declarative configuration and management.

32
flake.lock generated
View File

@@ -16,11 +16,11 @@
]
},
"locked": {
"lastModified": 1747008053,
"narHash": "sha256-rob/qftmEuk+/JVGCIrOpv+LWjdmayFtebEKqRZXVAI=",
"rev": "2666bb11f4287cfbdf3b7c5f55231c6b5772a436",
"lastModified": 1746459034,
"narHash": "sha256-VHHc8EFPu2uk8mf4ItTHwxgrQxFixNHkclPQMXZfYig=",
"rev": "d63db1621463918966e8e0ec2eb7ddbe8aae332e",
"type": "tarball",
"url": "https://git.clan.lol/api/v1/repos/clan/data-mesher/archive/2666bb11f4287cfbdf3b7c5f55231c6b5772a436.tar.gz"
"url": "https://git.clan.lol/api/v1/repos/clan/data-mesher/archive/d63db1621463918966e8e0ec2eb7ddbe8aae332e.tar.gz"
},
"original": {
"type": "tarball",
@@ -34,11 +34,11 @@
]
},
"locked": {
"lastModified": 1746729224,
"narHash": "sha256-9R4sOLAK1w3Bq54H3XOJogdc7a6C2bLLmatOQ+5pf5w=",
"lastModified": 1746411114,
"narHash": "sha256-mLlkVX1kKbAa/Ns5u26wDYw4YW4ziMFM21fhtRmfirU=",
"owner": "nix-community",
"repo": "disko",
"rev": "85555d27ded84604ad6657ecca255a03fd878607",
"rev": "b5d1320ebc2f34dbea4655f95167f55e2130cdb3",
"type": "github"
},
"original": {
@@ -74,11 +74,11 @@
]
},
"locked": {
"lastModified": 1746708654,
"narHash": "sha256-GeC99gu5H6+AjBXsn5dOhP4/ApuioGCBkufdmEIWPRs=",
"lastModified": 1746254942,
"narHash": "sha256-Y062AuRx6l+TJNX8wxZcT59SSLsqD9EedAY0mqgTtQE=",
"owner": "nix-darwin",
"repo": "nix-darwin",
"rev": "6cb36e8327421c61e5a3bbd08ed63491b616364a",
"rev": "760a11c87009155afa0140d55c40e7c336d62d7a",
"type": "github"
},
"original": {
@@ -118,10 +118,10 @@
"nixpkgs": {
"locked": {
"lastModified": 315532800,
"narHash": "sha256-kgy4FnRFGj62QO3kI6a6glFl8XUtKMylWGybnVCvycM=",
"rev": "b3582c75c7f21ce0b429898980eddbbf05c68e55",
"narHash": "sha256-EbVl0wIdDYZWrxpQoxPlXfliaR4KHA9xP5dVjG1CZxI=",
"rev": "ed30f8aba41605e3ab46421e3dcb4510ec560ff8",
"type": "tarball",
"url": "https://releases.nixos.org/nixpkgs/nixpkgs-25.05pre796313.b3582c75c7f2/nixexprs.tar.xz"
"url": "https://releases.nixos.org/nixpkgs/nixpkgs-25.05pre794180.ed30f8aba416/nixexprs.tar.xz"
},
"original": {
"type": "tarball",
@@ -184,11 +184,11 @@
]
},
"locked": {
"lastModified": 1746989248,
"narHash": "sha256-uoQ21EWsAhyskNo8QxrTVZGjG/dV4x5NM1oSgrmNDJY=",
"lastModified": 1746216483,
"narHash": "sha256-4h3s1L/kKqt3gMDcVfN8/4v2jqHrgLIe4qok4ApH5x4=",
"owner": "numtide",
"repo": "treefmt-nix",
"rev": "708ec80ca82e2bbafa93402ccb66a35ff87900c5",
"rev": "29ec5026372e0dec56f890e50dbe4f45930320fd",
"type": "github"
},
"original": {

View File

@@ -37,7 +37,7 @@ let
done
if ! test -e ~/clan-core; then
# git clone https://git.clan.lol/clan/clan-core.git ~/clan-core
cp -rv ${self.checks.x86_64-linux.clan-core-for-checks} clan-core
cp -rv ${self} clan-core
fi
cd clan-core
clan machines morph demo-template --i-will-be-fired-for-using-this

View File

@@ -45,9 +45,7 @@ let
inherit inventory directory;
flakeInputs = config.self.inputs;
prefix = config._prefix ++ [ "inventoryClass" ];
# TODO: remove inventory.modules, this is here for backwards compatibility
localModuleSet =
lib.filterAttrs (n: _: !inventory._legacyModules ? ${n}) inventory.modules // config.modules;
localModuleSet = config.self.clan.modules;
}
);
@@ -181,7 +179,6 @@ in
# Merge the meta attributes from the buildClan function
{
inventory.modules = clan-core.clanModules;
inventory._legacyModules = clan-core.clanModules;
}
# config.inventory.meta <- config.meta
{ inventory.meta = config.meta; }

View File

@@ -33,7 +33,6 @@ let
distributedServices = clanLib.inventory.mapInstances {
inherit (config) inventory;
inherit localModuleSet;
inherit flakeInputs;
prefix = prefix ++ [ "distributedServices" ];
};

View File

@@ -96,12 +96,6 @@ in
./assertions.nix
];
options = {
_legacyModules = lib.mkOption {
internal = true;
visible = false;
default = { };
};
options = lib.mkOption {
internal = true;
visible = false;
@@ -144,28 +138,6 @@ in
};
```
'';
apply =
moduleSet:
let
allowedNames = lib.attrNames config._legacyModules;
in
if builtins.all (moduleName: builtins.elem moduleName allowedNames) (lib.attrNames moduleSet) then
moduleSet
else
lib.warn ''
`inventory.modules` will be deprecated soon.
Please migrate the following modules into `clan.service` modules
and register them in `clan.modules`
${lib.concatStringsSep "\n" (
map (m: "'${m}'") (lib.attrNames (lib.filterAttrs (n: _v: !builtins.elem n allowedNames) moduleSet))
)}
See: https://docs.clan.lol/manual/distributed-services/
And: https://docs.clan.lol/authoring/clanServices/
'' moduleSet;
};
assertions = lib.mkOption {

View File

@@ -1,12 +1,9 @@
{
resolvedRoles,
instanceName,
moduleName,
allRoles,
}:
{
lib,
config,
resolvedRoles,
instanceName,
moduleName,
...
}:
let
@@ -14,7 +11,7 @@ let
in
{
imports = [
(lib.modules.importApply ./interface.nix { inherit allRoles; })
./interface.nix
# Role assertions
{
config.assertions = lib.foldlAttrs (
@@ -27,7 +24,7 @@ in
"${moduleName}.${instanceName}.roles.${roleName}.min" = {
assertion = memberCount >= roleConstraints.min;
message = ''
The '${moduleName}' module requires at least ${builtins.toString roleConstraints.min} members of the '${roleName}' role
The ${moduleName} module requires at least ${builtins.toString roleConstraints.min} members of the '${roleName}' role
but found '${builtins.toString memberCount}' members within instance '${instanceName}':
${lib.concatLines members}

View File

@@ -1,8 +1,7 @@
{
allRoles,
}:
{
lib,
allRoles,
moduleName,
...
}:
let
@@ -10,6 +9,12 @@ let
rolesAttrs = builtins.groupBy lib.id allRoles;
in
{
options.serviceName = mkOption {
type = types.str;
default = moduleName;
readOnly = true;
visible = false;
};
options.roles = lib.mapAttrs (
_name: _:
mkOption {

View File

@@ -54,7 +54,7 @@ let
)
}
To import a local module from 'clan.modules' remove the 'input' attribute from the module definition
To import a local module from 'inventory.modules' remove the 'input' attribute from the module definition
Remove the following line from the module definition:
...
@@ -81,7 +81,6 @@ in
flakeInputs,
# The clan inventory
inventory,
localModuleSet,
prefix ? [ ],
}:
let
@@ -93,7 +92,7 @@ in
let
resolvedModule = resolveModule {
moduleSpec = instance.module;
inherit localModuleSet;
localModuleSet = inventory.modules;
inherit flakeInputs;
};

View File

@@ -1,78 +0,0 @@
{ lib, config, ... }:
let
inherit (lib) mkOption;
inherit (lib) types;
in
{
options = {
name = mkOption {
description = ''
The name of the module
Mainly used to create an error context while evaluating.
This helps backtracking which module was included; And where an error came from originally.
'';
type = types.str;
};
description = mkOption {
type = types.str;
description = ''
A Short description of the module.
'';
defaultText = "Short description";
default = config.name;
};
categories = mkOption {
default = [ "Uncategorized" ];
description = ''
Categories are used for Grouping and searching.
While initial oriented on [freedesktop](https://specifications.freedesktop.org/menu-spec/latest/category-registry.html) the following categories are allowed
'';
type = types.listOf (
types.enum [
"AudioVideo"
"Audio"
"Video"
"Development"
"Education"
"Game"
"Graphics"
"Social"
"Network"
"Office"
"Science"
"System"
"Settings"
"Utility"
"Uncategorized"
]
);
};
features = mkOption {
description = ''
Enable built-in features for the module
See the documentation for each feature:
- API
'';
type = types.submoduleWith {
modules = [
{
options.API = mkOption {
type = types.bool;
# This is read only, because we don't support turning it off yet
readOnly = true;
default = true;
description = ''
Enables automatic API schema conversion for the interface of this module.
'';
};
}
];
};
default = { };
};
};
}

View File

@@ -232,7 +232,43 @@ in
description = "Meta information about this module itself";
type = submoduleWith {
modules = [
./manifest/default.nix
{
options = {
name = mkOption {
description = ''
The name of the module
Mainly used to create an error context while evaluating.
This helps backtracking which module was included; And where an error came from originally.
'';
type = types.str;
};
features = mkOption {
description = ''
Enable built-in features for the module
See the documentation for each feature:
- API
'';
type = types.submoduleWith {
modules = [
{
options.API = mkOption {
type = types.bool;
# This is read only, because we don't support turning it off yet
readOnly = true;
default = true;
description = ''
Enables automatic API schema conversion for the interface of this module.
'';
};
}
];
};
default = { };
};
};
}
];
};
};

View File

@@ -41,13 +41,9 @@ let
callInventoryAdapter =
inventoryModule:
let
inventory = evalInventory inventoryModule;
in
clanLib.inventory.mapInstances {
flakeInputs = flakeInputsFixture;
inherit inventory;
localModuleSet = inventory.modules;
inventory = evalInventory inventoryModule;
};
in
{

View File

@@ -34,60 +34,37 @@ let
allModules,
}:
lib.evalModules {
specialArgs = {
inherit moduleName resolvedRoles instanceName;
allRoles = getRoles "inventory.modules" allModules moduleName;
};
modules = [
(getFrontmatter allModules.${moduleName} moduleName)
./interface.nix
{
constraints.imports = [
(lib.modules.importApply ../constraints {
inherit moduleName resolvedRoles instanceName;
allRoles = getRoles "inventory.modules" allModules moduleName;
})
];
}
];
};
# For Documentation purposes only
frontmatterOptions =
(lib.evalModules {
specialArgs = {
moduleName = "{moduleName}";
allRoles = [ "{roleName}" ];
};
modules = [
./interface.nix
{
constraints.imports = [
(lib.modules.importApply ../constraints {
moduleName = "{moduleName}";
allRoles = [ "{roleName}" ];
})
];
}
];
}).options;
migratedModules = [ "admin" ];
makeModuleNotFoundError =
serviceName:
if builtins.elem serviceName migratedModules then
''
(Legacy) ClanModule not found: '${serviceName}'.
Please update your configuration to use this module via 'inventory.instances'
See: https://docs.clan.lol/manual/distributed-services/
''
else
''
(Legacy) ClanModule not found: '${serviceName}'.
Make sure the module is added to inventory.modules.${serviceName}
'';
# This is a legacy function
# Old modules needed to define their roles by directory
# This means if this function gets anything other than a string/path it will throw
getRoles =
_scope: allModules: serviceName:
scope: allModules: serviceName:
let
module = allModules.${serviceName} or (throw (makeModuleNotFoundError serviceName));
module =
allModules.${serviceName}
or (throw "(Legacy) ClanModule not found: '${serviceName}'. Make sure the module is added to ${scope}");
moduleType = (lib.typeOf module);
checked =
if

View File

@@ -1,5 +1,6 @@
{
lib,
specialArgs,
...
}:
let
@@ -75,8 +76,9 @@ in
```
'';
type = types.submoduleWith {
inherit specialArgs;
modules = [
../constraints
];
};
};

View File

@@ -2,11 +2,9 @@ import argparse
import ctypes
import os
import re
import shutil
import subprocess
import time
import types
import uuid
from collections.abc import Callable
from contextlib import _GeneratorContextManager
from dataclasses import dataclass
@@ -15,8 +13,6 @@ from pathlib import Path
from tempfile import TemporaryDirectory
from typing import Any
from colorama import Fore, Style
from .logger import AbstractLogger, CompositeLogger, TerminalLogger
# Load the C library
@@ -248,7 +244,7 @@ class Machine:
"""
# Always run command with shell opts
command = f"set -eo pipefail; source /etc/profile; set -xu; {command}"
command = f"set -eo pipefail; source /etc/profile; set -u; {command}"
proc = subprocess.run(
self.nsenter_command(command),
@@ -472,42 +468,8 @@ class Driver:
print(f"Starting {machine.name}")
machine.start()
# Print copy-pastable nsenter command to debug container tests
for machine in self.machines:
nspawn_uuid = uuid.uuid4()
# We lauch a sleep here, so we can pgrep the process cmdline for
# the uuid
sleep = shutil.which("sleep")
assert sleep is not None, "sleep command not found"
machine.execute(
f"systemd-run /bin/sh -c '{sleep} 999999999 && echo {nspawn_uuid}'",
)
print(f"nsenter for {machine.name}:")
print(
" ".join(
[
Style.BRIGHT,
Fore.CYAN,
"sudo",
"nsenter",
"--user",
"--target",
f"$(\\pgrep -f '^/bin/sh.*{nspawn_uuid}')",
"--mount",
"--uts",
"--ipc",
"--net",
"--pid",
"--cgroup",
"/bin/sh",
"-c",
"bash",
Style.RESET_ALL,
]
)
)
print(" ".join(machine.nsenter_command("bash")))
def test_symbols(self) -> dict[str, Any]:
general_symbols = {

View File

@@ -1,21 +0,0 @@
{ lib, ... }:
{
_class = "clan.service";
manifest.name = "test";
roles.peer.interface =
{ ... }:
{
options.debug = lib.mkOption { default = 1; };
};
roles.peer.perInstance =
{ settings, ... }:
{
nixosModule = {
options.debug = lib.mkOption {
default = settings;
};
};
};
}

View File

@@ -58,16 +58,7 @@ in
)
)
''
The config.clan.core.vars.generators.${generator.name}.files.${file.name} is not secret:
${lib.optionalString (file.owner != "root") ''
The owner is set to ${file.owner}, but should be root.
''}
${lib.optionalString (file.group != (if _class == "darwin" then "wheel" else "root")) ''
The group is set to ${file.group}, but should be ${if _class == "darwin" then "wheel" else "root"}.
''}
${lib.optionalString (file.mode != "0400") ''
The mode is set to ${file.mode}, but should be 0400.
''}
The config.clan.core.vars.generators.${generator.name}.files.${file.name} is not secret, but has non-default owner/group/mode set.
This doesn't work because the file will be added to the nix store
''
) [ ] (lib.attrValues generator.files)

View File

@@ -18,7 +18,7 @@ let
config.clan.core.settings.directory
+ "/vars/per-machine/${machineName}/${secret.generator}/${secret.name}/secret";
vars = collectFiles config.clan.core.vars.generators;
vars = collectFiles config.clan.core.vars;
in
{
config.clan.core.vars.settings = lib.mkIf (config.clan.core.vars.settings.secretStore == "sops") {

View File

@@ -13,7 +13,7 @@ in
{
collectFiles =
generators:
vars:
let
relevantFiles =
generator:
@@ -30,7 +30,7 @@ in
inherit (generator) share;
inherit (file) owner group mode;
}) (relevantFiles generator)
) generators
) vars.generators
);
in
allFiles;

View File

@@ -6,9 +6,8 @@ from urllib.parse import urlparse
from clan_lib.api import API
from clan_cli.cmd import run
from clan_cli.cmd import run_no_stdout
from clan_cli.errors import ClanCmdError, ClanError
from clan_cli.flake import Flake
from clan_cli.inventory import Meta
from clan_cli.nix import nix_eval
@@ -16,26 +15,26 @@ log = logging.getLogger(__name__)
@API.register
def show_clan_meta(flake: Flake) -> Meta:
if flake.is_local and not flake.path.exists():
msg = f"Path {flake} does not exist"
def show_clan_meta(uri: str) -> Meta:
if uri.startswith("/") and not Path(uri).exists():
msg = f"Path {uri} does not exist"
raise ClanError(msg, description="clan directory does not exist")
cmd = nix_eval(
[
f"{flake}#clanInternals.inventory.meta",
f"{uri}#clanInternals.inventory.meta",
"--json",
]
)
res = "{}"
try:
proc = run(cmd)
proc = run_no_stdout(cmd)
res = proc.stdout.strip()
except ClanCmdError as e:
msg = "Evaluation failed on meta attribute"
raise ClanError(
msg,
location=f"show_clan {flake}",
location=f"show_clan {uri}",
description=str(e.cmd),
) from e
@@ -54,16 +53,16 @@ def show_clan_meta(flake: Flake) -> Meta:
msg = "Invalid absolute path"
raise ClanError(
msg,
location=f"show_clan {flake}",
location=f"show_clan {uri}",
description="Icon path must be a URL or a relative path",
)
icon_path = str((flake.path / meta_icon).resolve())
icon_path = str((Path(uri) / meta_icon).resolve())
else:
msg = "Invalid schema"
raise ClanError(
msg,
location=f"show_clan {flake}",
location=f"show_clan {uri}",
description="Icon path must be a URL or a relative path",
)

View File

@@ -403,3 +403,23 @@ def run(
raise ClanCmdError(cmd_out)
return cmd_out
def run_no_stdout(
cmd: list[str],
opts: RunOpts | None = None,
) -> CmdOut:
"""
Like run, but automatically suppresses all output, if not in DEBUG log level.
If in DEBUG log level the stdout of commands will be shown.
"""
if opts is None:
opts = RunOpts()
if cmdlog.isEnabledFor(logging.DEBUG):
opts.log = opts.log if opts.log.value > Log.STDERR.value else Log.STDERR
return run(
cmd,
opts,
)

View File

@@ -10,7 +10,6 @@ from .errors import ClanError
if TYPE_CHECKING:
from clan_cli.flake import Flake
from clan_cli.machines.machines import Machine
log = logging.getLogger(__name__)
@@ -145,8 +144,8 @@ def machines_dir(flake: "Flake") -> Path:
return Path(store_path) / "machines"
def specific_machine_dir(machine: "Machine") -> Path:
return machines_dir(machine.flake) / machine.name
def specific_machine_dir(flake: "Flake", machine: str) -> Path:
return machines_dir(flake) / machine
def module_root() -> Path:

View File

@@ -48,7 +48,6 @@ def bubblewrap_cmd(generator: str, facts_dir: Path, secrets_dir: Path) -> list[s
"--unshare-all",
"--tmpfs", "/",
"--ro-bind", "/nix/store", "/nix/store",
"--ro-bind", "/bin/sh", "/bin/sh",
"--dev", "/dev",
# not allowed to bind procfs in some sandboxes
"--bind", str(facts_dir), str(facts_dir),

View File

@@ -5,27 +5,28 @@ from tempfile import TemporaryDirectory
from clan_cli.completions import add_dynamic_completer, complete_machines
from clan_cli.machines.machines import Machine
from clan_cli.ssh.host import Host
from clan_cli.ssh.upload import upload
log = logging.getLogger(__name__)
def upload_secrets(machine: Machine) -> None:
with machine.target_host() as host:
if not machine.secret_facts_store.needs_upload(host):
machine.info("Secrets already uploaded")
return
def upload_secrets(machine: Machine, host: Host) -> None:
if not machine.secret_facts_store.needs_upload(host):
machine.info("Secrets already uploaded")
return
with TemporaryDirectory(prefix="facts-upload-") as _tempdir:
local_secret_dir = Path(_tempdir).resolve()
machine.secret_facts_store.upload(local_secret_dir)
remote_secret_dir = Path(machine.secrets_upload_directory)
upload(host, local_secret_dir, remote_secret_dir)
with TemporaryDirectory(prefix="facts-upload-") as _tempdir:
local_secret_dir = Path(_tempdir).resolve()
machine.secret_facts_store.upload(local_secret_dir)
remote_secret_dir = Path(machine.secrets_upload_directory)
upload(host, local_secret_dir, remote_secret_dir)
def upload_command(args: argparse.Namespace) -> None:
machine = Machine(name=args.machine, flake=args.flake)
upload_secrets(machine)
with machine.target_host() as host:
upload_secrets(machine, host)
def register_upload_parser(parser: argparse.ArgumentParser) -> None:

View File

@@ -575,12 +575,12 @@ class Flake:
identifier: str
inputs_from: str | None = None
hash: str | None = None
flake_cache_path: Path | None = None
store_path: str | None = None
_flake_cache_path: Path | None = field(init=False, default=None)
_cache: FlakeCache | None = field(init=False, default=None)
_path: Path | None = field(init=False, default=None)
_is_local: bool | None = field(init=False, default=None)
cache: FlakeCache | None = None
_cache: FlakeCache | None = None
_path: Path | None = None
_is_local: bool | None = None
@classmethod
def from_json(cls: type["Flake"], data: dict[str, Any]) -> "Flake":

View File

@@ -21,7 +21,7 @@ from typing import Any
from clan_lib.api import API, dataclass_to_dict, from_dict
from clan_cli.cmd import run
from clan_cli.cmd import run_no_stdout
from clan_cli.errors import ClanCmdError, ClanError
from clan_cli.flake import Flake
from clan_cli.git import commit_file
@@ -80,7 +80,7 @@ def load_inventory_eval(flake_dir: Flake) -> Inventory:
]
)
proc = run(cmd)
proc = run_no_stdout(cmd)
try:
res = proc.stdout.strip()
@@ -380,7 +380,7 @@ def get_inventory_current_priority(flake: Flake) -> dict:
]
)
proc = run(cmd)
proc = run_no_stdout(cmd)
try:
res = proc.stdout.strip()

View File

@@ -110,7 +110,7 @@ def create_machine(opts: CreateOptions, commit: bool = True) -> None:
new_machine["deploy"] = {"targetHost": target_host}
patch_inventory_with(
opts.clan_dir, f"machines.{machine_name}", dataclass_to_dict(new_machine)
Flake(str(clan_dir)), f"machines.{machine_name}", dataclass_to_dict(new_machine)
)
# Commit at the end in that order to avoid committing halve-baked machines

View File

@@ -5,10 +5,9 @@ from pathlib import Path
from clan_lib.api import API
from clan_cli import inventory
from clan_cli import Flake, inventory
from clan_cli.completions import add_dynamic_completer, complete_machines
from clan_cli.dirs import specific_machine_dir
from clan_cli.machines.machines import Machine
from clan_cli.secrets.folders import sops_secrets_folder
from clan_cli.secrets.machines import has_machine as secrets_has_machine
from clan_cli.secrets.machines import remove_machine as secrets_machine_remove
@@ -16,46 +15,49 @@ from clan_cli.secrets.secrets import (
list_secrets,
)
from .machines import Machine
log = logging.getLogger(__name__)
@API.register
def delete_machine(machine: Machine) -> None:
def delete_machine(flake: Flake, name: str) -> None:
try:
inventory.delete(machine.flake, {f"machines.{machine.name}"})
inventory.delete(flake, {f"machines.{name}"})
except KeyError as exc:
# louis@(2025-03-09): test infrastructure does not seem to set the
# inventory properly, but more importantly only one machine in my
# personal clan ended up in the inventory for some reason, so I think
# it makes sense to eat the exception here.
log.warning(
f"{machine.name} was missing or already deleted from the machines inventory: {exc}"
f"{name} was missing or already deleted from the machines inventory: {exc}"
)
changed_paths: list[Path] = []
folder = specific_machine_dir(machine)
folder = specific_machine_dir(flake, name)
if folder.exists():
changed_paths.append(folder)
shutil.rmtree(folder)
# louis@(2025-02-04): clean-up legacy (pre-vars) secrets:
sops_folder = sops_secrets_folder(machine.flake.path)
filter_fn = lambda secret_name: secret_name.startswith(f"{machine.name}-")
for secret_name in list_secrets(machine.flake.path, filter_fn):
sops_folder = sops_secrets_folder(flake.path)
filter_fn = lambda secret_name: secret_name.startswith(f"{name}-")
for secret_name in list_secrets(flake.path, filter_fn):
secret_path = sops_folder / secret_name
changed_paths.append(secret_path)
shutil.rmtree(secret_path)
machine = Machine(name, flake)
changed_paths.extend(machine.public_vars_store.delete_store())
changed_paths.extend(machine.secret_vars_store.delete_store())
# Remove the machine's key, and update secrets & vars that referenced it:
if secrets_has_machine(machine.flake.path, machine.name):
secrets_machine_remove(machine.flake.path, machine.name)
if secrets_has_machine(flake.path, name):
secrets_machine_remove(flake.path, name)
def delete_command(args: argparse.Namespace) -> None:
delete_machine(Machine(flake=args.flake, name=args.name))
delete_machine(args.flake, args.name)
def register_delete_parser(parser: argparse.ArgumentParser) -> None:

View File

@@ -7,10 +7,11 @@ from pathlib import Path
from clan_lib.api import API
from clan_cli.cmd import RunOpts, run
from clan_cli.cmd import RunOpts, run_no_stdout
from clan_cli.completions import add_dynamic_completer, complete_machines
from clan_cli.dirs import specific_machine_dir
from clan_cli.errors import ClanCmdError, ClanError
from clan_cli.flake import Flake
from clan_cli.git import commit_file
from clan_cli.machines.machines import Machine
from clan_cli.nix import nix_config, nix_eval
@@ -25,35 +26,39 @@ class HardwareConfig(Enum):
NIXOS_GENERATE_CONFIG = "nixos-generate-config"
NONE = "none"
def config_path(self, machine: Machine) -> Path:
machine_dir = specific_machine_dir(machine)
def config_path(self, flake: Flake, machine_name: str) -> Path:
machine_dir = specific_machine_dir(flake, machine_name)
if self == HardwareConfig.NIXOS_FACTER:
return machine_dir / "facter.json"
return machine_dir / "hardware-configuration.nix"
@classmethod
def detect_type(cls: type["HardwareConfig"], machine: Machine) -> "HardwareConfig":
hardware_config = HardwareConfig.NIXOS_GENERATE_CONFIG.config_path(machine)
def detect_type(
cls: type["HardwareConfig"], flake: Flake, machine_name: str
) -> "HardwareConfig":
hardware_config = HardwareConfig.NIXOS_GENERATE_CONFIG.config_path(
flake, machine_name
)
if hardware_config.exists() and "throw" not in hardware_config.read_text():
return HardwareConfig.NIXOS_GENERATE_CONFIG
if HardwareConfig.NIXOS_FACTER.config_path(machine).exists():
if HardwareConfig.NIXOS_FACTER.config_path(flake, machine_name).exists():
return HardwareConfig.NIXOS_FACTER
return HardwareConfig.NONE
@API.register
def show_machine_hardware_config(machine: Machine) -> HardwareConfig:
def show_machine_hardware_config(flake: Flake, machine_name: str) -> HardwareConfig:
"""
Show hardware information for a machine returns None if none exist.
"""
return HardwareConfig.detect_type(machine)
return HardwareConfig.detect_type(flake, machine_name)
@API.register
def show_machine_hardware_platform(machine: Machine) -> str | None:
def show_machine_hardware_platform(flake: Flake, machine_name: str) -> str | None:
"""
Show hardware information for a machine returns None if none exist.
"""
@@ -61,13 +66,13 @@ def show_machine_hardware_platform(machine: Machine) -> str | None:
system = config["system"]
cmd = nix_eval(
[
f"{machine.flake}#clanInternals.machines.{system}.{machine.name}",
f"{flake}#clanInternals.machines.{system}.{machine_name}",
"--apply",
"machine: { inherit (machine.pkgs) system; }",
"--json",
]
)
proc = run(cmd, RunOpts(prefix=machine.name))
proc = run_no_stdout(cmd, RunOpts(prefix=machine_name))
res = proc.stdout.strip()
host_platform = json.loads(res)
@@ -76,8 +81,11 @@ def show_machine_hardware_platform(machine: Machine) -> str | None:
@dataclass
class HardwareGenerateOptions:
machine: Machine
flake: Flake
machine: str
backend: HardwareConfig
target_host: str | None = None
keyfile: str | None = None
password: str | None = None
@@ -88,9 +96,14 @@ def generate_machine_hardware_info(opts: HardwareGenerateOptions) -> HardwareCon
and place the resulting *.nix file in the machine's directory.
"""
machine = opts.machine
machine = Machine(
opts.machine,
flake=opts.flake,
private_key=Path(opts.keyfile) if opts.keyfile else None,
override_target_host=opts.target_host,
)
hw_file = opts.backend.config_path(opts.machine)
hw_file = opts.backend.config_path(opts.flake, opts.machine)
hw_file.parent.mkdir(parents=True, exist_ok=True)
if opts.backend == HardwareConfig.NIXOS_FACTER:
@@ -135,11 +148,11 @@ def generate_machine_hardware_info(opts: HardwareGenerateOptions) -> HardwareCon
commit_file(
hw_file,
opts.machine.flake.path,
opts.flake.path,
f"machines/{opts.machine}/{hw_file.name}: update hardware configuration",
)
try:
show_machine_hardware_platform(opts.machine)
show_machine_hardware_platform(opts.flake, opts.machine)
if backup_file:
backup_file.unlink(missing_ok=True)
except ClanCmdError as e:
@@ -160,13 +173,10 @@ def generate_machine_hardware_info(opts: HardwareGenerateOptions) -> HardwareCon
def update_hardware_config_command(args: argparse.Namespace) -> None:
machine = Machine(
flake=args.flake,
name=args.machine,
override_target_host=args.target_host,
)
opts = HardwareGenerateOptions(
machine=machine,
flake=args.flake,
machine=args.machine,
target_host=args.target_host,
password=args.password,
backend=HardwareConfig(args.backend),
)

View File

@@ -111,7 +111,11 @@ def install_machine(opts: InstallOptions) -> None:
[
"--generate-hardware-config",
str(opts.update_hardware_config.value),
str(opts.update_hardware_config.config_path(machine)),
str(
opts.update_hardware_config.config_path(
machine.flake, machine.name
)
),
]
)

View File

@@ -67,9 +67,9 @@ def get_machine_details(machine: Machine) -> MachineDetails:
msg = f"Machine {machine.name} not found in inventory"
raise ClanError(msg)
hw_config = HardwareConfig.detect_type(machine)
hw_config = HardwareConfig.detect_type(machine.flake, machine.name)
machine_dir = specific_machine_dir(machine)
machine_dir = specific_machine_dir(machine.flake, machine.name)
disk_schema: MachineDiskMatter | None = None
disk_path = machine_dir / "disko.nix"
if disk_path.exists():

View File

@@ -9,7 +9,7 @@ from functools import cached_property
from pathlib import Path
from typing import TYPE_CHECKING, Any
from clan_cli.cmd import Log, RunOpts, run
from clan_cli.cmd import Log, RunOpts, run_no_stdout
from clan_cli.errors import ClanCmdError, ClanError
from clan_cli.facts import public_modules as facts_public_modules
from clan_cli.facts import secret_modules as facts_secret_modules
@@ -188,7 +188,7 @@ class Machine:
# however there is a soon to be merged PR that requires deployment
# as root to match NixOS: https://github.com/nix-darwin/nix-darwin/pull/1341
return json.loads(
run(
run_no_stdout(
nix_eval(
[
f"{self.flake}#darwinConfigurations.{self.name}.options.system",

View File

@@ -10,7 +10,7 @@ from contextlib import ExitStack
from clan_lib.api import API
from clan_cli.async_run import AsyncContext, AsyncOpts, AsyncRuntime, is_async_cancelled
from clan_cli.cmd import Log, MsgColor, RunOpts, run
from clan_cli.cmd import MsgColor, RunOpts, run
from clan_cli.colors import AnsiColor
from clan_cli.completions import (
add_dynamic_completer,
@@ -141,7 +141,7 @@ def deploy_machine(machine: Machine) -> None:
generate_facts([machine], service=None, regenerate=False)
generate_vars([machine], generator_name=None, regenerate=False)
upload_secrets(machine)
upload_secrets(machine, target_host)
upload_secret_vars(machine, target_host)
path = upload_sources(machine, host)
@@ -182,16 +182,16 @@ def deploy_machine(machine: Machine) -> None:
remote_env = host.nix_ssh_env(None, local_ssh=False)
ret = host.run(
switch_cmd,
RunOpts(
check=False,
log=Log.BOTH,
msg_color=MsgColor(stderr=AnsiColor.DEFAULT),
needs_user_terminal=True,
),
RunOpts(check=False, msg_color=MsgColor(stderr=AnsiColor.DEFAULT)),
extra_env=remote_env,
become_root=become_root,
)
# Last output line (config store path) is printed to stdout instead of stderr
lines = ret.stdout.splitlines()
if lines:
print(lines[-1])
if is_async_cancelled():
return
@@ -206,7 +206,6 @@ def deploy_machine(machine: Machine) -> None:
ret = host.run(
test_cmd if is_mobile else switch_cmd,
RunOpts(
log=Log.BOTH,
msg_color=MsgColor(stderr=AnsiColor.DEFAULT),
needs_user_terminal=True,
),

View File

@@ -1,13 +1,12 @@
import json
import logging
import os
import shutil
import tempfile
from functools import cache
from pathlib import Path
from typing import Any
from clan_cli.cmd import run
from clan_cli.cmd import run, run_no_stdout
from clan_cli.dirs import nixpkgs_flake, nixpkgs_source
from clan_cli.errors import ClanError
from clan_cli.locked_open import locked_open
@@ -56,7 +55,7 @@ def nix_add_to_gcroots(nix_path: Path, dest: Path) -> None:
@cache
def nix_config() -> dict[str, Any]:
cmd = nix_command(["config", "show", "--json"])
proc = run(cmd)
proc = run_no_stdout(cmd)
data = json.loads(proc.stdout)
config = {}
for key, value in data.items():
@@ -132,16 +131,7 @@ class Packages:
cls.static_packages = set(
os.environ.get("CLAN_PROVIDED_PACKAGES", "").split(":")
)
if program in cls.static_packages:
if shutil.which(program) is None:
log.warning(
"Program %s is not in the path even though it should be shipped with clan",
program,
)
return False
return True
return False
return program in cls.static_packages
# Features:

View File

@@ -6,7 +6,6 @@
"age-plugin-sss",
"age-plugin-tpm",
"age-plugin-yubikey",
"age-plugin-1p",
"avahi",
"bash",
"bubblewrap",

View File

@@ -63,8 +63,7 @@ def upload(
for mdir in dirs:
dir_path = Path(root) / mdir
tarinfo = tar.gettarinfo(
dir_path,
arcname=str(dir_path.relative_to(str(local_src))),
dir_path, arcname=str(dir_path.relative_to(str(local_src)))
)
tarinfo.mode = dir_mode
tarinfo.uname = file_user

View File

@@ -3,7 +3,7 @@ import json
import logging
from pathlib import Path
from clan_cli.cmd import RunOpts, run
from clan_cli.cmd import RunOpts, run_no_stdout
from clan_cli.completions import (
add_dynamic_completer,
complete_machines,
@@ -32,7 +32,7 @@ def list_state_folders(machine: Machine, service: None | str = None) -> None:
res = "{}"
try:
proc = run(cmd, RunOpts(prefix=machine.name))
proc = run_no_stdout(cmd, opts=RunOpts(prefix=machine.name))
res = proc.stdout.strip()
except ClanCmdError as e:
msg = "Clan might not have meta attributes"

View File

@@ -2,7 +2,7 @@ import json
from pathlib import Path
from typing import Any
from clan_cli.cmd import run
from clan_cli.cmd import run_no_stdout
from clan_cli.errors import ClanError
from clan_cli.nix import nix_eval
@@ -18,7 +18,7 @@ def list_tagged_machines(flake_url: str | Path) -> dict[str, Any]:
"--json",
]
)
proc = run(cmd)
proc = run_no_stdout(cmd)
try:
res = proc.stdout.strip()

View File

@@ -10,15 +10,8 @@ from pathlib import Path
from typing import Any, NamedTuple
import pytest
from clan_cli.dirs import (
TemplateType,
clan_templates,
nixpkgs_source,
specific_machine_dir,
)
from clan_cli.flake import Flake
from clan_cli.dirs import TemplateType, clan_templates, nixpkgs_source
from clan_cli.locked_open import locked_open
from clan_cli.machines.machines import Machine
from clan_cli.nix import nix_test_store
from clan_cli.tests import age_keys
from clan_cli.tests.fixture_error import FixtureError
@@ -77,10 +70,11 @@ class FlakeForTest(NamedTuple):
def set_machine_settings(
machine: Machine,
flake: Path,
machine_name: str,
machine_settings: dict,
) -> None:
config_path = specific_machine_dir(machine) / "configuration.json"
config_path = flake / "machines" / machine_name / "configuration.json"
config_path.write_text(json.dumps(machine_settings, indent=2))
@@ -208,8 +202,7 @@ class ClanFlake:
}}
"""
)
machine = Machine(name=machine_name, flake=Flake(str(self.path)))
set_machine_settings(machine, machine_config)
set_machine_settings(self.path, machine_name, machine_config)
sp.run(["git", "add", "."], cwd=self.path, check=True)
sp.run(
["git", "commit", "-a", "-m", "Update by flake generator"],

View File

@@ -11,7 +11,7 @@ from clan_cli.inventory import (
set_inventory,
)
from clan_cli.machines.create import CreateOptions, create_machine
from clan_cli.nix import nix_eval, run
from clan_cli.nix import nix_eval, run_no_stdout
from clan_cli.tests.fixtures_flakes import FlakeForTest
from clan_lib.api.modules import list_modules
@@ -27,8 +27,10 @@ def test_list_modules(test_flake_with_core: FlakeForTest) -> None:
base_path = test_flake_with_core.path
modules_info = list_modules(str(base_path))
assert "localModules" in modules_info
assert "modulesPerSource" in modules_info
assert len(modules_info.items()) > 1
# Random test for those two modules
assert "borgbackup" in modules_info
assert "syncthing" in modules_info
@pytest.mark.impure
@@ -120,7 +122,7 @@ def test_add_module_to_inventory(
"--json",
]
)
proc = run(cmd)
proc = run_no_stdout(cmd)
res = json.loads(proc.stdout.strip())
assert res["machine1"]["authorizedKeys"] == [ssh_key.decode()]

View File

@@ -12,12 +12,7 @@ from clan_cli.tests.age_keys import SopsSetup
from clan_cli.tests.fixtures_flakes import ClanFlake
from clan_cli.tests.helpers import cli
from clan_cli.vars.check import check_vars
from clan_cli.vars.generate import (
Generator,
generate_vars_for_machine,
generate_vars_for_machine_interactive,
get_generators_closure,
)
from clan_cli.vars.generate import Generator, generate_vars_for_machine_interactive
from clan_cli.vars.get import get_var
from clan_cli.vars.graph import all_missing_closure, requested_closure
from clan_cli.vars.list import stringify_all_vars
@@ -645,6 +640,9 @@ def test_api_set_prompts(
monkeypatch: pytest.MonkeyPatch,
flake: ClanFlake,
) -> None:
from clan_cli.vars._types import GeneratorUpdate
from clan_cli.vars.list import get_generators, set_prompts
config = flake.machines["my_machine"]
config["nixpkgs"]["hostPlatform"] = "x86_64-linux"
my_generator = config["clan"]["core"]["vars"]["generators"]["my_generator"]
@@ -654,39 +652,33 @@ def test_api_set_prompts(
flake.refresh()
monkeypatch.chdir(flake.path)
params = {"machine_name": "my_machine", "base_dir": str(flake.path)}
generate_vars_for_machine(
machine_name="my_machine",
base_dir=flake.path,
generators=["my_generator"],
all_prompt_values={
"my_generator": {
"prompt1": "input1",
}
},
set_prompts(
**params,
updates=[
GeneratorUpdate(
generator="my_generator",
prompt_values={"prompt1": "input1"},
)
],
)
machine = Machine(name="my_machine", flake=Flake(str(flake.path)))
store = in_repo.FactStore(machine)
assert store.exists(Generator("my_generator"), "prompt1")
assert store.get(Generator("my_generator"), "prompt1").decode() == "input1"
generate_vars_for_machine(
machine_name="my_machine",
base_dir=flake.path,
generators=["my_generator"],
all_prompt_values={
"my_generator": {
"prompt1": "input2",
}
},
set_prompts(
**params,
updates=[
GeneratorUpdate(
generator="my_generator",
prompt_values={"prompt1": "input2"},
)
],
)
assert store.get(Generator("my_generator"), "prompt1").decode() == "input2"
generators = get_generators_closure(
machine_name="my_machine",
base_dir=flake.path,
regenerate=True,
include_previous_values=True,
)
generators = get_generators(**params)
assert len(generators) == 1
assert generators[0].name == "my_generator"
assert generators[0].prompts[0].name == "prompt1"

View File

@@ -93,7 +93,7 @@ Examples:
Will list non-secret vars for the specified machine.
For more detailed information, visit: {help_hyperlink("vars", "https://docs.clan.lol/manual/vars-backend/")}
For more detailed information, visit: {help_hyperlink("secrets", "https://docs.clan.lol/getting-started/secrets")}
"""
),
formatter_class=argparse.RawTextHelpFormatter,
@@ -112,7 +112,7 @@ Examples:
$ clan vars get my-server zerotier/vpn-ip
Will get the var for the specified machine.
For more detailed information, visit: {help_hyperlink("vars", "https://docs.clan.lol/manual/vars-backend/")}
For more detailed information, visit: {help_hyperlink("secrets", "https://docs.clan.lol/getting-started/secrets")}
"""
),
formatter_class=argparse.RawTextHelpFormatter,
@@ -131,7 +131,7 @@ Examples:
$ clan vars set my-server zerotier/vpn-ip
Will set the var for the specified machine.
For more detailed information, visit: {help_hyperlink("vars", "https://docs.clan.lol/manual/vars-backend/")}
For more detailed information, visit: {help_hyperlink("secrets", "https://docs.clan.lol/getting-started/secrets")}
"""
),
formatter_class=argparse.RawTextHelpFormatter,
@@ -171,7 +171,7 @@ Examples:
This is especially useful for resetting certain passwords while leaving the rest
of the vars for a machine in place.
For more detailed information, visit: {help_hyperlink("vars", "https://docs.clan.lol/manual/vars-backend/")}
For more detailed information, visit: {help_hyperlink("secrets", "https://docs.clan.lol/getting-started/secrets")}
"""
),
formatter_class=argparse.RawTextHelpFormatter,

View File

@@ -103,7 +103,6 @@ def bubblewrap_cmd(generator: str, tmpdir: Path) -> list[str]:
"--unshare-all",
"--tmpfs", "/",
"--ro-bind", "/nix/store", "/nix/store",
"--ro-bind", "/bin/sh", "/bin/sh",
*(["--ro-bind", str(test_store), str(test_store)] if test_store else []),
"--dev", "/dev",
# not allowed to bind procfs in some sandboxes
@@ -295,28 +294,10 @@ def _ask_prompts(
return prompt_values
def _get_previous_value(
machine: "Machine",
generator: Generator,
prompt: Prompt,
) -> str | None:
if not prompt.persist:
return None
pub_store = machine.public_vars_store
if pub_store.exists(generator, prompt.name):
return pub_store.get(generator, prompt.name).decode()
sec_store = machine.secret_vars_store
if sec_store.exists(generator, prompt.name):
return sec_store.get(generator, prompt.name).decode()
return None
def get_closure(
machine: "Machine",
generator_name: str | None,
regenerate: bool,
include_previous_values: bool = False,
) -> list[Generator]:
from .graph import all_missing_closure, full_closure
@@ -329,24 +310,14 @@ def get_closure(
for generator in vars_generators:
generator.machine(machine)
result_closure = []
if generator_name is None: # all generators selected
if regenerate:
result_closure = full_closure(generators)
else:
result_closure = all_missing_closure(generators)
return full_closure(generators)
return all_missing_closure(generators)
# specific generator selected
elif regenerate:
result_closure = requested_closure([generator_name], generators)
else:
result_closure = minimal_closure([generator_name], generators)
if include_previous_values:
for generator in result_closure:
for prompt in generator.prompts:
prompt.previous_value = _get_previous_value(machine, generator, prompt)
return result_closure
if regenerate:
return requested_closure([generator_name], generators)
return minimal_closure([generator_name], generators)
@API.register
@@ -354,7 +325,6 @@ def get_generators_closure(
machine_name: str,
base_dir: Path,
regenerate: bool = False,
include_previous_values: bool = False,
) -> list[Generator]:
from clan_cli.machines.machines import Machine
@@ -362,14 +332,13 @@ def get_generators_closure(
machine=Machine(name=machine_name, flake=Flake(str(base_dir))),
generator_name=None,
regenerate=regenerate,
include_previous_values=include_previous_values,
)
def _generate_vars_for_machine(
machine: "Machine",
generators: list[Generator],
all_prompt_values: dict[str, dict[str, str]],
all_prompt_values: dict[str, dict],
no_sandbox: bool = False,
) -> bool:
for generator in generators:
@@ -381,7 +350,7 @@ def _generate_vars_for_machine(
generator=generator,
secret_vars_store=machine.secret_vars_store,
public_vars_store=machine.public_vars_store,
prompt_values=all_prompt_values.get(generator.name, {}),
prompt_values=all_prompt_values[generator.name],
no_sandbox=no_sandbox,
)
return True

View File

@@ -4,10 +4,9 @@ from dataclasses import dataclass, field
from pathlib import Path
from typing import Any, Literal
from clan_cli.cmd import RunOpts, run
from clan_cli.cmd import RunOpts
from clan_cli.errors import ClanError
from clan_cli.flake import Flake
from clan_cli.nix import nix_shell
from clan_cli.nix import nix_shell, run_no_stdout
from . import API
@@ -53,8 +52,8 @@ class Directory:
@API.register
def get_directory(flake: Flake) -> Directory:
curr_dir = flake.path
def get_directory(current_path: str) -> Directory:
curr_dir = Path(current_path)
directory = Directory(path=str(curr_dir))
if not curr_dir.is_dir():
@@ -136,7 +135,7 @@ def show_block_devices() -> Blockdevices:
"PATH,NAME,RM,SIZE,RO,MOUNTPOINTS,TYPE,ID-LINK",
],
)
proc = run(cmd, RunOpts(needs_user_terminal=True))
proc = run_no_stdout(cmd, RunOpts(needs_user_terminal=True))
res = proc.stdout.strip()
blk_info: dict[str, Any] = json.loads(res)

View File

@@ -7,9 +7,9 @@ from uuid import uuid4
from clan_cli.dirs import TemplateType, clan_templates
from clan_cli.errors import ClanError
from clan_cli.flake import Flake
from clan_cli.git import commit_file
from clan_cli.machines.hardware import HardwareConfig, show_machine_hardware_config
from clan_cli.machines.machines import Machine
from clan_lib.api import API
from clan_lib.api.modules import Frontmatter, extract_frontmatter
@@ -74,7 +74,9 @@ templates: dict[str, dict[str, Callable[[dict[str, Any]], Placeholder]]] = {
@API.register
def get_disk_schemas(machine: Machine) -> dict[str, DiskSchema]:
def get_disk_schemas(
flake: Flake, machine_name: str | None = None
) -> dict[str, DiskSchema]:
"""
Get the available disk schemas
"""
@@ -82,12 +84,13 @@ def get_disk_schemas(machine: Machine) -> dict[str, DiskSchema]:
disk_schemas = {}
hw_report = {}
hw_report_path = HardwareConfig.NIXOS_FACTER.config_path(machine)
if not hw_report_path.exists():
msg = "Hardware configuration missing"
raise ClanError(msg)
with hw_report_path.open("r") as hw_report_file:
hw_report = json.load(hw_report_file)
if machine_name is not None:
hw_report_path = HardwareConfig.NIXOS_FACTER.config_path(flake, machine_name)
if not hw_report_path.exists():
msg = "Hardware configuration missing"
raise ClanError(msg)
with hw_report_path.open("r") as hw_report_file:
hw_report = json.load(hw_report_file)
for disk_template in disk_templates.iterdir():
if disk_template.is_dir():
@@ -127,7 +130,8 @@ class MachineDiskMatter(TypedDict):
@API.register
def set_machine_disk_schema(
machine: Machine,
flake: Flake,
machine_name: str,
schema_name: str,
# Placeholders are used to fill in the disk schema
# Use get disk schemas to get the placeholders and their options
@@ -138,8 +142,8 @@ def set_machine_disk_schema(
Set the disk placeholders of the template
"""
# Assert the hw-config must exist before setting the disk
hw_config = show_machine_hardware_config(machine)
hw_config_path = hw_config.config_path(machine)
hw_config = show_machine_hardware_config(flake, machine_name)
hw_config_path = hw_config.config_path(flake, machine_name)
if not hw_config_path.exists():
msg = "Hardware configuration must exist before applying disk schema"
@@ -156,7 +160,7 @@ def set_machine_disk_schema(
raise ClanError(msg)
# Check that the placeholders are valid
disk_schema = get_disk_schemas(machine)[schema_name]
disk_schema = get_disk_schemas(flake, machine_name)[schema_name]
# check that all required placeholders are present
for placeholder_name, schema_placeholder in disk_schema.placeholders.items():
if schema_placeholder.required and placeholder_name not in placeholders:
@@ -217,6 +221,6 @@ def set_machine_disk_schema(
commit_file(
disko_file_path,
machine.flake.path,
commit_message=f"Set disk schema of machine: {machine.name} to {schema_name}",
flake.path,
commit_message=f"Set disk schema of machine: {machine_name} to {schema_name}",
)

View File

@@ -2,7 +2,7 @@ import argparse
import re
from dataclasses import dataclass
from clan_cli.cmd import run
from clan_cli.cmd import run_no_stdout
from clan_cli.nix import nix_shell
from . import API
@@ -100,7 +100,7 @@ def show_mdns() -> DNSInfo:
"--terminate",
],
)
proc = run(cmd)
proc = run_no_stdout(cmd)
data = parse_avahi_output(proc.stdout)
return data

View File

@@ -1,11 +1,13 @@
import json
import re
import tomllib
from dataclasses import dataclass, field
from pathlib import Path
from typing import Any, TypedDict
from clan_cli.errors import ClanError
from clan_cli.flake import Flake
from clan_cli.cmd import run_no_stdout
from clan_cli.errors import ClanCmdError, ClanError
from clan_cli.nix import nix_eval
from . import API
@@ -141,50 +143,53 @@ def get_roles(module_path: Path) -> None | list[str]:
]
class ModuleManifest(TypedDict):
name: str
features: dict[str, bool]
@dataclass
class ModuleInfo:
manifest: ModuleManifest
roles: dict[str, None]
description: str
readme: str
categories: list[str]
roles: list[str] | None
features: list[str] = field(default_factory=list)
constraints: dict[str, Any] = field(default_factory=dict)
class ModuleLists(TypedDict):
modulesPerSource: dict[str, dict[str, ModuleInfo]]
localModules: dict[str, ModuleInfo]
@API.register
def list_modules(base_path: str) -> ModuleLists:
"""
Show information about a module
"""
flake = Flake(base_path)
modules = flake.select(
"clanInternals.inventoryClass.{?modulesPerSource,?localModules}"
def get_modules(base_path: str) -> dict[str, str]:
cmd = nix_eval(
[
f"{base_path}#clanInternals.inventory.modules",
"--json",
]
)
print("Modules found:", modules)
try:
proc = run_no_stdout(cmd)
res = proc.stdout.strip()
except ClanCmdError as e:
msg = "clanInternals might not have inventory.modules attributes"
raise ClanError(
msg,
location=f"list_modules {base_path}",
description="Evaluation failed on clanInternals.inventory.modules attribute",
) from e
modules: dict[str, str] = json.loads(res)
return modules
@dataclass
class LegacyModuleInfo:
description: str
categories: list[str]
roles: None | list[str]
readme: str
features: list[str]
constraints: dict[str, Any]
@API.register
def list_modules(base_path: str) -> dict[str, ModuleInfo]:
"""
Show information about a module
"""
modules = get_modules(base_path)
return {
module_name: get_module_info(module_name, Path(module_path))
for module_name, module_path in modules.items()
}
def get_module_info(
module_name: str,
module_path: Path,
) -> LegacyModuleInfo:
) -> ModuleInfo:
"""
Retrieves information about a module
"""
@@ -209,7 +214,7 @@ def get_module_info(
readme, f"{module_path}/README.md"
)
return LegacyModuleInfo(
return ModuleInfo(
description=frontmatter.description,
categories=frontmatter.categories,
roles=get_roles(module_path),

View File

@@ -35,7 +35,6 @@ log = logging.getLogger(__name__)
@dataclass
class InventoryWrapper:
services: dict[str, Any]
instances: dict[str, Any]
@dataclass
@@ -58,7 +57,7 @@ def create_base_inventory(ssh_keys_pairs: list[SSHKeyPair]) -> InventoryWrapper:
ssh_keys.append(InvSSHKeyEntry(f"user_{num}", ssh_key.public.read_text()))
"""Create the base inventory structure."""
legacy_services: dict[str, Any] = {
inventory: dict[str, Any] = {
"sshd": {
"someid": {
"roles": {
@@ -78,24 +77,23 @@ def create_base_inventory(ssh_keys_pairs: list[SSHKeyPair]) -> InventoryWrapper:
}
}
},
}
instances = {
"admin-1": {
"module": {"name": "admin"},
"roles": {
"default": {
"tags": {"all": {}},
"settings": {
"allowedKeys": {
key.username: key.ssh_pubkey_txt for key in ssh_keys
"admin": {
"someid": {
"roles": {
"default": {
"tags": ["all"],
"config": {
"allowedKeys": {
key.username: key.ssh_pubkey_txt for key in ssh_keys
},
},
},
},
},
}
}
}
},
}
return InventoryWrapper(services=legacy_services, instances=instances)
return InventoryWrapper(services=inventory)
# TODO: We need a way to calculate the narHash of the current clan-core
@@ -242,7 +240,7 @@ def test_clan_create_api(
facter_json = test_lib_root / "assets" / "facter.json"
assert facter_json.exists(), f"Source facter file not found: {facter_json}"
dest_dir = specific_machine_dir(machine)
dest_dir = specific_machine_dir(Flake(str(clan_dir_flake.path)), machine.name)
# specific_machine_dir should create the directory, but ensure it exists just in case
dest_dir.mkdir(parents=True, exist_ok=True)
@@ -255,7 +253,10 @@ def test_clan_create_api(
)
# ===== Create Disko Config ======
facter_path = specific_machine_dir(machine) / "facter.json"
facter_path = (
specific_machine_dir(Flake(str(clan_dir_flake.path)), machine.name)
/ "facter.json"
)
with facter_path.open("r") as f:
facter_report = json.load(f)
@@ -264,10 +265,9 @@ def test_clan_create_api(
assert disk_devs is not None
placeholders = {"mainDisk": disk_devs[0]}
set_machine_disk_schema(machine, "single-disk", placeholders)
set_machine_disk_schema(clan_dir_flake, machine.name, "single-disk", placeholders)
clan_dir_flake.invalidate_cache()
# @Qubasa what does this assert check, why does it raise?
# with pytest.raises(ClanError) as exc_info:
# machine.build_nix("config.system.build.toplevel")
# assert "nixos-system-test-clan" in str(exc_info.value)
with pytest.raises(ClanError) as exc_info:
machine.build_nix("config.system.build.toplevel")
assert "nixos-system-test-clan" in str(exc_info.value)

View File

@@ -36,7 +36,6 @@
classgen = pkgs.callPackage ./classgen { };
zerotierone = pkgs.callPackage ./zerotierone { };
webview-lib = pkgs.callPackage ./webview-lib { };
update-clan-core-for-checks = pkgs.callPackage ./update-clan-core-for-checks { };
};
};
}

View File

@@ -1,35 +0,0 @@
{
writeShellApplication,
git,
jq,
nix-prefetch-git,
}:
writeShellApplication {
name = "update-clan-core-for-checks";
runtimeInputs = [
git
jq
nix-prefetch-git
];
text = ''
reporoot=$(git rev-parse --show-toplevel)
if [ -z "$reporoot" ]; then
echo "Not in a git repository. Please run this script from the root of the repository."
exit 1
fi
cd "$reporoot"
# get latest commit of clan-core
json=$(nix-prefetch-git "$(pwd)")
sha256=$(jq -r '.sha256' <<< "$json")
rev=$(jq -r '.rev' <<< "$json")
cat > ./checks/clan-core-for-checks.nix <<EOF
{ fetchgit }:
fetchgit {
url = "https://git.clan.lol/clan/clan-core.git";
rev = "$rev";
sha256 = "$sha256";
}
EOF
'';
}

File diff suppressed because it is too large Load Diff

View File

@@ -22,9 +22,7 @@ export { clanList, setClanList };
(async function () {
const curr = activeURI();
if (curr) {
const result = await callApi("show_clan_meta", {
flake: { identifier: curr },
});
const result = await callApi("show_clan_meta", { uri: curr });
console.log("refetched meta for ", curr);
if (result.status === "error") {
result.errors.forEach((error) => {

View File

@@ -142,87 +142,91 @@ export function SelectInput(props: SelectInputpProps) {
</InputLabel>
}
field={
<InputBase
error={!!props.error}
disabled={props.disabled}
required={props.required}
class="!justify-start"
divRef={setReference}
inputElem={
<button
// TODO: Keyboard acessibililty
// Currently the popover only opens with onClick
// Options are not selectable with keyboard
tabIndex={-1}
disabled={props.disabled}
onClick={() => {
const popover = document.getElementById(_id);
if (popover) {
popover.togglePopover(); // Show or hide the popover
}
}}
type="button"
class="flex w-full items-center gap-2"
formnovalidate
// TODO: Use native popover once Webkit supports it within <form>
// popovertarget={_id}
// popovertargetaction="toggle"
>
<Show
when={props.adornment && props.adornment.position === "start"}
<>
<InputBase
error={!!props.error}
disabled={props.disabled}
required={props.required}
class="!justify-start"
divRef={setReference}
inputElem={
<button
// TODO: Keyboard acessibililty
// Currently the popover only opens with onClick
// Options are not selectable with keyboard
tabIndex={-1}
disabled={props.disabled}
onClick={() => {
const popover = document.getElementById(_id);
if (popover) {
popover.togglePopover(); // Show or hide the popover
}
}}
type="button"
class="flex w-full items-center gap-2"
formnovalidate
// TODO: Use native popover once Webkit supports it within <form>
// popovertarget={_id}
// popovertargetaction="toggle"
>
{props.adornment?.content}
</Show>
{props.inlineLabel}
<div class="flex cursor-default flex-row gap-2">
<Show
when={
getValues() &&
getValues.length !== 1 &&
getValues()[0] !== ""
props.adornment && props.adornment.position === "start"
}
fallback={props.placeholder}
>
<For each={getValues()} fallback={"Select"}>
{(item) => (
<div class="rounded-xl bg-slate-800 px-4 py-1 text-sm text-white">
{item}
<Show when={props.multiple}>
<button
class=""
type="button"
onClick={(_e) => {
// @ts-expect-error: fieldName is not known ahead of time
props.selectProps.onInput({
currentTarget: {
options: getValues()
.filter((o) => o !== item)
.map((value) => ({
value,
selected: true,
disabled: false,
})),
},
});
}}
>
X
</button>
</Show>
</div>
)}
</For>
{props.adornment?.content}
</Show>
</div>
<Show
when={props.adornment && props.adornment.position === "end"}
>
{props.adornment?.content}
</Show>
<Icon size={12} icon="CaretDown" class="ml-auto mr-2" />
</button>
}
/>
{props.inlineLabel}
<div class="flex cursor-default flex-row gap-2">
<Show
when={
getValues() &&
getValues.length !== 1 &&
getValues()[0] !== ""
}
fallback={props.placeholder}
>
<For each={getValues()} fallback={"Select"}>
{(item) => (
<div class="rounded-xl bg-slate-800 px-4 py-1 text-sm text-white">
{item}
<Show when={props.multiple}>
<button
class=""
type="button"
onClick={(_e) => {
// @ts-expect-error: fieldName is not known ahead of time
props.selectProps.onInput({
currentTarget: {
options: getValues()
.filter((o) => o !== item)
.map((value) => ({
value,
selected: true,
disabled: false,
})),
},
});
}}
>
X
</button>
</Show>
</div>
)}
</For>
</Show>
</div>
<Show
when={props.adornment && props.adornment.position === "end"}
>
{props.adornment?.content}
</Show>
<Icon icon="CaretDown" class="ml-auto mr-2"></Icon>
</button>
}
/>
</>
}
/>

View File

@@ -18,7 +18,7 @@ export const FieldLayout = (props: LayoutProps) => {
class={cx("grid grid-cols-10 items-center", intern.class)}
{...divProps}
>
<div class="col-span-5 flex items-center">{props.label}</div>
<label class="col-span-5">{props.label}</label>
<div class="col-span-5">{props.field}</div>
{props.error && <span class="col-span-full">{props.error}</span>}
</div>

View File

@@ -96,7 +96,6 @@ export const DynForm = (props: FormProps) => {
return (
<>
{/* @ts-expect-error: This happened after solidjs upgrade. TOOD: fixme */}
<ModuleForm {...props.formProps} onSubmit={handleSubmit}>
{props.components?.before}
<SchemaFields

View File

@@ -98,6 +98,7 @@ export async function set_single_service<T extends keyof Services>(
inventory.services = inventory.services || {};
inventory.services[service_name] = inventory.services[service_name] || {};
// @ts-expect-error: This doesn't check
inventory.services[service_name][instance_key] = service_config;
console.log("saving inventory", inventory);
return callApi("set_inventory", {

View File

@@ -0,0 +1,224 @@
import { createSignal, For, Setter, Show } from "solid-js";
import { callApi, SuccessQuery } from "../api";
import { Menu } from "./Menu";
import { activeURI } from "../App";
import toast from "solid-toast";
import { A, useNavigate } from "@solidjs/router";
import { RndThumbnail } from "./noiseThumbnail";
import Icon from "./icon";
import { Filter } from "../routes/machines";
import { Typography } from "./Typography";
import { Button } from "./button";
type MachineDetails = SuccessQuery<"list_machines">["data"][string];
interface MachineListItemProps {
name: string;
info?: MachineDetails;
nixOnly?: boolean;
setFilter: Setter<Filter>;
}
export const MachineListItem = (props: MachineListItemProps) => {
const { name, info, nixOnly } = props;
// Bootstrapping
const [installing, setInstalling] = createSignal<boolean>(false);
// Later only updates
const [updating, setUpdating] = createSignal<boolean>(false);
const navigate = useNavigate();
const handleInstall = async () => {
if (!info?.deploy?.targetHost || installing()) {
return;
}
const active_clan = activeURI();
if (!active_clan) {
toast.error("No active clan selected");
return;
}
if (!info?.deploy?.targetHost) {
toast.error(
"Machine does not have a target host. Specify where the machine should be deployed.",
);
return;
}
setInstalling(true);
await toast.promise(
callApi("install_machine", {
opts: {
machine: {
name: name,
flake: {
identifier: active_clan,
},
override_target_host: info?.deploy.targetHost,
},
no_reboot: true,
debug: true,
nix_options: [],
password: null,
},
}),
{
loading: "Installing...",
success: "Installed",
error: "Failed to install",
},
);
setInstalling(false);
};
const handleUpdate = async () => {
if (!info?.deploy?.targetHost || installing()) {
return;
}
const active_clan = activeURI();
if (!active_clan) {
toast.error("No active clan selected");
return;
}
if (!info?.deploy.targetHost) {
toast.error(
"Machine does not have a target host. Specify where the machine should be deployed.",
);
return;
}
setUpdating(true);
await toast.promise(
callApi("update_machines", {
base_path: active_clan,
machines: [
{
name: name,
deploy: {
targetHost: info?.deploy.targetHost,
},
},
],
}),
{
loading: "Updating...",
success: "Updated",
error: "Failed to update",
},
);
setUpdating(false);
};
return (
<div class="m-2 w-64 rounded-lg border p-3 border-def-2">
<figure class="h-fit rounded-xl border bg-def-2 border-def-5">
<RndThumbnail name={name} width={220} height={120} />
</figure>
<div class="flex-row justify-between gap-4 px-2 pt-2">
<div class="flex flex-col">
<A href={`/machines/${name}`}>
<Typography hierarchy="title" size="m" weight="bold">
{name}
</Typography>
</A>
<div class="flex justify-between text-slate-600">
<div class="flex flex-nowrap">
<span class="h-4">
<Icon icon="Flash" class="h-4" font-size="inherit" />
</span>
<Typography hierarchy="body" size="s" weight="medium">
<Show when={info}>
{(d) => d()?.description || "no description"}
</Show>
</Typography>
</div>
<div class="self-end">
<Menu
popoverid={`menu-${props.name}`}
label={<Icon icon={"More"} />}
>
<ul class="z-[1] w-64 bg-white p-2 shadow ">
<li>
<Button
variant="ghost"
class="w-full"
onClick={() => {
navigate("/machines/" + name);
}}
>
Details
</Button>
</li>
<li
classList={{
disabled: !info?.deploy?.targetHost || installing(),
}}
>
<Button
variant="ghost"
class="w-full"
onClick={handleInstall}
>
Install
</Button>
</li>
<li
classList={{
disabled: !info?.deploy?.targetHost || updating(),
}}
>
<Button
variant="ghost"
class="w-full"
onClick={handleUpdate}
>
Update
</Button>
</li>
</ul>
</Menu>
</div>
</div>
{/* <div class="text-slate-600">
<Show when={info}>
{(d) => (
<>
<Show when={d().tags}>
{(tags) => (
<span class="flex gap-1">
<For each={tags()}>
{(tag) => (
<button
type="button"
onClick={() =>
props.setFilter((prev) => {
if (prev.tags.includes(tag)) {
return prev;
}
return {
...prev,
tags: [...prev.tags, tag],
};
})
}
>
<span class="rounded-full px-3 py-1 bg-inv-4 fg-inv-1">
{tag}
</span>
</button>
)}
</For>
</span>
)}
</Show>
{d()?.deploy?.targetHost}
</>
)}
</Show>
</div> */}
</div>
</div>
</div>
);
};

View File

@@ -1,8 +1,7 @@
import { createSignal, Show } from "solid-js";
import { createSignal } from "solid-js";
import { Typography } from "@/src/components/Typography";
import { SidebarFlyout } from "./SidebarFlyout";
import "./css/sidebar.css";
import Icon from "../icon";
interface SidebarProps {
clanName: string;
@@ -54,16 +53,8 @@ export const SidebarHeader = (props: SidebarProps) => {
return (
<header class="sidebar__header">
<div onClick={handleClick} class="sidebar__header__inner">
{/* <ClanProfile clanName={props.clanName} showFlyout={showFlyout} /> */}
<div class="w-full pl-1 text-white">
<ClanTitle clanName={props.clanName} />
</div>
<Show
when={showFlyout}
fallback={<Icon size={12} class="text-white" icon="CaretDown" />}
>
<Icon size={12} class="text-white" icon="CaretDown" />
</Show>
<ClanProfile clanName={props.clanName} showFlyout={showFlyout} />
<ClanTitle clanName={props.clanName} />
</div>
{showFlyout() && <SidebarFlyout />}
</header>

View File

@@ -52,9 +52,7 @@ export const Sidebar = (props: RouteSectionProps) => {
queryFn: async () => {
const curr = activeURI();
if (curr) {
const result = await callApi("show_clan_meta", {
flake: { identifier: curr },
});
const result = await callApi("show_clan_meta", { uri: curr });
console.log("refetched meta for ", curr);
if (result.status === "error") throw new Error("Failed to fetch data");

View File

@@ -21,7 +21,7 @@ export default function Accordion(props: AccordionProps) {
fallback={
<Button
endIcon={<Icon size={12} icon={"CaretDown"} />}
variant="ghost"
variant="light"
size="s"
>
{props.title}
@@ -30,7 +30,7 @@ export default function Accordion(props: AccordionProps) {
>
<Button
endIcon={<Icon size={12} icon={"CaretUp"} />}
variant="ghost"
variant="dark"
size="s"
>
{props.title}

View File

@@ -1,11 +0,0 @@
.button--ghost-hover:hover {
@apply hover:bg-secondary-100 hover:text-secondary-900;
}
.button--ghost-focus:focus {
@apply focus:bg-secondary-200 focus:text-secondary-900;
}
.button--ghost-active:active {
@apply active:bg-secondary-200 active:text-secondary-900 active:shadow-inner-primary-active;
}

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