Compare commits

..

2 Commits

Author SHA1 Message Date
Jörg Thalheim
160f7d2cf5 Revert "Merge pull request 'Fix deploying with sudo + password' (#3470) from target-host into main"
This reverts commit 8a849eb90f, reversing
changes made to 3b5c22ebcf.
2025-05-04 13:37:09 +02:00
Jörg Thalheim
4c9aaa09d5 fix ssh control master check 2025-05-04 13:36:55 +02:00
151 changed files with 4082 additions and 8589 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"

1
.gitignore vendored
View File

@@ -14,7 +14,6 @@ example_clan
nixos.qcow2
**/*.glade~
/docs/out
/pkgs/clan-cli/clan_cli/select
**/.local.env
# MacOS stuff

View File

@@ -1,2 +0,0 @@
nixosModules/clanCore/vars/.* @lopter
pkgs/clan-cli/clan_cli/(secrets|vars)/.* @lopter

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

@@ -26,7 +26,6 @@ clanLib.test.makeTestClan {
roles.admin.machines = [ "admin1" ];
};
};
instances."test" = {
module.name = "new-service";
roles.peer.machines.peer1 = { };
@@ -34,33 +33,25 @@ 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:
# ./pkgs/scripts/update-vars.py
clan.core.vars.generators.new-service = {
files.hello = {
secret = false;
deploy = true;
};
script = ''
# This is a dummy script that does nothing
echo "This is a dummy script" > $out/hello
'';
};
};
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
'';
};
};
};
@@ -78,15 +69,7 @@ clanLib.test.makeTestClan {
print(peer1.succeed("systemctl status dummy-service"))
# peer1 should have the 'hello' file
peer1.succeed("cat ${nodes.peer1.clan.core.vars.generators.new-service.files.not-a-secret.path}")
ls_out = peer1.succeed("ls -la ${nodes.peer1.clan.core.vars.generators.new-service.files.a-secret.path}")
# Check that the file is owned by 'nobody'
assert "nobody" in ls_out, f"File is not owned by 'nobody': {ls_out}"
# Check that the file is in the 'users' group
assert "users" in ls_out, f"File is not in the 'users' group: {ls_out}"
# Check that the file is in the '0644' mode
assert "-rw-r--r--" in ls_out, f"File is not in the '0644' mode: {ls_out}"
peer1.succeed("cat ${nodes.peer1.clan.core.vars.generators.new-service.files.hello.path}")
'';
}
);

View File

@@ -1,6 +1,6 @@
[
{
"publickey": "age12yt078p9ewxy2sh0a36nxdpgglv8wqqftmj4dkj9rgy5fuyn4p0q5nje9m",
"publickey": "age1hd2exjq88h7538y6mvjvexx3u5gp6a03yfn5nj32h2667yyksyaqcuk5qs",
"type": "age"
}
]

View File

@@ -1,6 +1,6 @@
[
{
"publickey": "age12w2ld4vxfyf3hdq2d8la4cu0tye4pq97egvv3me4wary7xkdnq2snh0zx2",
"publickey": "age19urkt89q45a2wk6a4yaramzufjtnw6nq2snls0v7hmf7tqf73axsfx50tk",
"type": "age"
}
]

View File

@@ -1,15 +1,15 @@
{
"data": "ENC[AES256_GCM,data:GPpsUhSzWPtTP8EUNKsobFXjYqDldhkkIH6hBk11RsDLAGWdhVrwcISGbhsWpYhvAdPKA84DB6Zqyh9lL2bLM9//ybC1kzY20BQ=,iv:NrxMLdedT2FCkUAD00SwsAHchIsxWvqe7BQekWuJcxw=,tag:pMDXcMyHnLF2t3Qhb1KolA==,type:str]",
"data": "ENC[AES256_GCM,data:hhuFgZcPqht0h3tKxGtheS4GlrVDo4TxH0a9lxgPYj2i12QUmE04rB07A+hu4Z8WNWLYvdM5069mEOZYm3lSeTzBHQPxYZRuVj0=,iv:sA1srRFQqsMlJTAjFcb09tI/Jg2WjOVJL5NZkPwiLoU=,tag:6xXo9FZpmAJw6hCBsWzf8Q==,type:str]",
"sops": {
"age": [
{
"recipient": "age1qm0p4vf9jvcnn43s6l4prk8zn6cx0ep9gzvevxecv729xz540v8qa742eg",
"enc": "-----BEGIN AGE ENCRYPTED FILE-----\nYWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSBzb2tWb1ExKzdmUTRzaGVj\nK3cyYTBHZTJwVjM1SzUvbHFiMnVhY05iKzFZCnJTSE1VSVdpcUFLSEJuaE1CZzJD\nWjZxYzN2cUltdThNMVRKU3FIb20vUXMKLS0tIFlHQXRIdnMybDZFUVEzWlQrc1dw\nbUxhZURXblhHd0pka0JIK1FTZEVqdUEKI/rfxQRBc+xGRelhswkJQ9GcZs6lzfgy\nuCxS5JI9npdPLQ/131F3b21+sP5YWqks41uZG+vslM1zQ+BlENNhDw==\n-----END AGE ENCRYPTED FILE-----\n"
"enc": "-----BEGIN AGE ENCRYPTED FILE-----\nYWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSBGaGVHeTgrN3dJQ2VITFBM\neWVzbDhjb0pwNUhBUjdUc0p5OTVta1dvSno4ClJxeUc4Z0hiaFRkVlJ1YTA4Lyta\neWdwV005WGYvMUNRVG1qOVdicTk0NUkKLS0tIFQvaDNFS1JMSFlHRXlhc3lsZm03\nYVhDaHNsam5wN1VqdzA3WTZwM1JwV2sKZk/SiZJgjllADdfHLSWuQcU4+LttDpt/\nqqDUATEuqYaALljC/y3COT+grTM2bwGjj6fsfsfiO/EL9iwzD3+7oA==\n-----END AGE ENCRYPTED FILE-----\n"
}
],
"lastmodified": "2025-05-04T12:44:13Z",
"mac": "ENC[AES256_GCM,data:fWxLHXBWolHVxv6Q7utcy6OVLV13ziswrIYyNKiwy1vsU8i7xvvuGO1HlnE+q43D2WuHR53liKq1UHuf1JMrWzTwZ0PYe+CVugtoEtbR2qu3rK/jAkOyMyhmmHzmf6Rp4ZMCzKgZeC/X2bDKY/z0firHAvjWydEyogutHpvtznM=,iv:OQI3FfkLneqbdztAXVQB3UkHwDPK+0hWu5hZ9m8Oczg=,tag:em6GfS2QHsXs391QKPxfmA==,type:str]",
"lastmodified": "2025-04-09T15:10:16Z",
"mac": "ENC[AES256_GCM,data:xuXj4833G6nhvcRo2ekDxz8G5phltmU8h1GgGofH9WndzrqLKeRSqm/n03IHRW0f4F68XxnyAkfvokVh6vW3LRQAFkqIlXz5U4+zFNcaVaPobS5gHTgxsCoTUoalWPvHWtXd50hUVXeAt8rPfTfeveVGja8bOERk8mvwUPxb6h4=,iv:yP1usA9m8tKl6Z/UK9PaVMJlZlF5qpY4EiM4+ByVlik=,tag:8DgoIhLstp3MRki90VfEvw==,type:str]",
"unencrypted_suffix": "_unencrypted",
"version": "3.10.2"
"version": "3.10.1"
}
}

View File

@@ -1,15 +1,15 @@
{
"data": "ENC[AES256_GCM,data:W3cOkUYL5/YulW2pEISyTlMaA/t7/WBE7BoCdFlqrqgaCL7tG4IV2HgjiPWzIVMs0zvDSaghdEvAIoB4wOf470d1nSWs0/E8SDk=,iv:wXXaZIw3sPY8L/wxsu7+C5v+d3RQRuwxZRP4YLkS8K4=,tag:HeK4okj7O7XDA9JDz2KULw==,type:str]",
"data": "ENC[AES256_GCM,data:rwPhbayGf6mE1E9NCN+LuL7VfWWOfhoJW6H2tNSoyebtyTpM3GO2jWca1+N7hI0juhNkUk+rIsYQYbCa/5DZQiV0/2Jgu4US1XY=,iv:B5mcaQsDjb6BacxGB4Kk88/qLCpVOjQNRvGN+fgUiEo=,tag:Uz0A8kAF5NzFetbv9yHIjQ==,type:str]",
"sops": {
"age": [
{
"recipient": "age1qm0p4vf9jvcnn43s6l4prk8zn6cx0ep9gzvevxecv729xz540v8qa742eg",
"enc": "-----BEGIN AGE ENCRYPTED FILE-----\nYWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSAxRC83b3dtSVpXcGovNnVs\nTzFka2J2MEFhYkF1ajVrdjMrNUtPWGRObjM4Cm5zSUR5OGw0T0FaL3BaWmR6L29W\nU2syMFIyMUhFRUZpWFpCT28vWko2ZU0KLS0tIFpHK3BjU1V1L0FrMGtwTGFuU3Mz\nRkV5VjI2Vndod202bUR3RWQwNXpmVzQKNk8/y7M62wTIIKqY4r3ZRk5aUCRUfine\n1LUSHMKa2bRe+hR7nS7AF4BGXp03h2UPY0FP5+U5q8XuIj1jfMX8kg==\n-----END AGE ENCRYPTED FILE-----\n"
"enc": "-----BEGIN AGE ENCRYPTED FILE-----\nYWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSBWY0hKQ1dnV0tMYytDMCtj\nTDV4Zk5NeVN0bCtqaWRQV3d4M0VlcGVZMkhZCm02dHZyOGVlYzJ5Z3FlUWNXMVQ0\nb2ZrTXZQRzRNdzFDeWZCVGhlTS9rMm8KLS0tIEJkY1QwOENRYWw3cjIwd3I0bzdz\nOEtQNm1saE5wNWt2UUVnYlN4NWtGdFkKmWHU5ttZoQ3NZu/zkX5VxfC2sMpSOyod\neb7LRhFqPfo5N1XphJcCqr5QUoZOfnH0xFhZ2lxWUS3ItiRpU4VDwg==\n-----END AGE ENCRYPTED FILE-----\n"
}
],
"lastmodified": "2025-05-04T12:44:16Z",
"mac": "ENC[AES256_GCM,data:yTkQeFvKrN1+5FP+yInsaRWSAG+ZGG0uWF3+gVRvzJTFxab8kT2XkAMc+4D7SKgcjsmwBBb77GNoAKaKByhZ92UaCfZ2X66i7ZmYUwLM1NVVmm+xiwwjsh7PJXlZO/70anTzd1evtlZse0jEmRnV5Y0F0M6YqXmuwU+qGUJU2F8=,iv:sy6ozhXonWVruaQfa7pdEoV5GkNZR/UbbINKAPbgWeg=,tag:VMruQ1KExmlMR7TsGNgMlg==,type:str]",
"lastmodified": "2025-04-09T15:10:41Z",
"mac": "ENC[AES256_GCM,data:pab0G2GPjgs59sbiZ8XIV5SdRtq5NPU0yq18FcqiMV8noAL94fyVAY7fb+9HILQWQsEjcykgk9mA2MQ0KpK/XG8+tDQKcBH+F+2aQnw5GJevXmfi7KLTU0P224SNo7EnKlfFruB/+NZ0WBtkbbg1OzekrbplchpSI6BxWz/jASE=,iv:TCj9FCxgfMF2+PJejr67zgGnF+CFS+YeJiejnHbf7j0=,tag:s7r9SqxeqpAkncohYvIQ2Q==,type:str]",
"unencrypted_suffix": "_unencrypted",
"version": "3.10.2"
"version": "3.10.1"
}
}

View File

@@ -1,19 +1,19 @@
{
"data": "ENC[AES256_GCM,data:T8edCvw=,iv:7/G5xt5fv38I9uFzk7WMIr9xQdz/6lFxqOC+18HBg8Q=,tag:F39Cxbgmzml+lZLsZ59Kmg==,type:str]",
"data": "ENC[AES256_GCM,data:bxM9aYMK,iv:SMNYtk9FSyZ1PIfEzayTKKdCnZWdhcyUEiTwFUNb988=,tag:qJYW4+VQyhF1tGPQPTKlOQ==,type:str]",
"sops": {
"age": [
{
"recipient": "age12yt078p9ewxy2sh0a36nxdpgglv8wqqftmj4dkj9rgy5fuyn4p0q5nje9m",
"enc": "-----BEGIN AGE ENCRYPTED FILE-----\nYWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSBPNUhiYkZWK3dPMHNiRTVM\nRHNvaHFsOFp1c0UxQitwVG0zY01MNDZRV1E4CjEybENoTVIzN29vQ3FtUTRSYmFU\nNXIzQllVSllXRGN2M1B6WXJLdHZSajgKLS0tIDllZ0ZmZUcxMHhDQUpUOEdWbmkv\neUQweHArYTdFSmNteVpuQ3BKdnh0Y0UKs8Hm3D+rXRRfpUVSZM3zYjs6b9z8g10D\nGTkvreUMim4CS22pjdQ3eNA9TGeDXfWXE7XzwXLCb+wVcf7KwbDmvg==\n-----END AGE ENCRYPTED FILE-----\n"
"recipient": "age1hd2exjq88h7538y6mvjvexx3u5gp6a03yfn5nj32h2667yyksyaqcuk5qs",
"enc": "-----BEGIN AGE ENCRYPTED FILE-----\nYWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSAvZDZYYXdpcXVqRFRnQ2Jx\nTFhFWEJTR290cHZhTXZadFFvcHM4MHVIN3lFCmJhOEZrL3g4TFBZVllxdDFZakJn\nR3NxdXo0eE8vTDh3QlhWOFpVZ0lNUHcKLS0tIEE4dkpCalNzaXJ0Qks3VHJSUzZF\nb2N3NGdjNHJnSUN6bW8welZ1VDdJakEKGKZ7nn1p11IyJB6DMxu2HJMvZ+0+5WpE\nPLWh2NlGJO3XrrL4Fw7xetwbqE+QUZPNl/JbEbu4KLIUGLjqk9JDhQ==\n-----END AGE ENCRYPTED FILE-----\n"
},
{
"recipient": "age1qm0p4vf9jvcnn43s6l4prk8zn6cx0ep9gzvevxecv729xz540v8qa742eg",
"enc": "-----BEGIN AGE ENCRYPTED FILE-----\nYWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSBKSDhpT3cvck9PenZYVEZH\ndFQreVRBdG93L1dBUGlvYjFWcDlHWUJsZUVBCm9DMTJ4UytiYzlEVHNWdUcwS1ds\nT0dhbzAzNDdmbDBCU0dvL2xNeHpXcGsKLS0tIFArbmpsbzU3WnpJdUt1MGN0L1d0\nV1JkTDJYWUxsbmhTQVNOeVRaSUhTODQKk9Vph2eldS5nwuvVX0SCsxEm4B+sO76Z\ndIjJ3OQxzoZmXMaOOuKHC5U0Y75Qn7eXC43w5KHsl2CMIUYsBGJOZw==\n-----END AGE ENCRYPTED FILE-----\n"
"enc": "-----BEGIN AGE ENCRYPTED FILE-----\nYWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSBHckJCQVFyb21aT1R0d2Rr\nMWxNMHVqcGxabHBmS0RibW9sN0gyZDI1b1dFCnRWUk5LSWdxV3c4RWVZdUtEN1Fv\nRk4xVmwwT2xrdWVERkJXUVVlVXJjTVUKLS0tIC9ERG9KMGxTNEsrbzFHUGRiVUlm\nRi9qakxoc1FOVVV1TkUrckwxRUVnajQKE8ms/np2NMswden3xkjdC8cXccASLOoN\nu+EaEk69UvBvnOg9VBjyPAraIKgNrTc4WWwz+DOBj1pCwVbu9XxUlA==\n-----END AGE ENCRYPTED FILE-----\n"
}
],
"lastmodified": "2025-05-04T12:44:14Z",
"mac": "ENC[AES256_GCM,data:6fKrS1eLLUWlHkQpxLFXBRk6f2wa5ADLMViVvYXXGU24ayl9UuNSKrCRHp9cbzhqhti3HdwyNt6TM+2X6qhiiAQanKEB2PF7JRYX74NfNKil9BEDjt5AqqtpSgVv5l7Ku/uSHaPkd2sDmzHsy5Q4bSGxJQokStk1kidrwle+mbc=,iv:I/Aad82L/TCxStM8d8IZICUrwdjRbGx2fuGWqexr21o=,tag:BfgRbGUxhPZzK2fLik1kxA==,type:str]",
"lastmodified": "2025-04-09T15:10:30Z",
"mac": "ENC[AES256_GCM,data:cIwWctUbAFI8TRMxYWy5xqlKDVLMqBIxVv4LInnLqi3AauL0rJ3Z7AxK/wb2dCQM07E1N7YaORNqgUpFC1xo0hObAA8mrPaToPotKDkjua0zuyTUNS1COoraYjZpI/LKwmik/qtk399LMhiC7aHs+IliT9Dd41B8LSMBXwdMldY=,iv:sZ+//BrYH5Ay2JJAGs7K+WfO2ASK82syDlilQjGmgFs=,tag:nY+Af9eQRLwkiHZe85dQ9A==,type:str]",
"unencrypted_suffix": "_unencrypted",
"version": "3.10.2"
"version": "3.10.1"
}
}

View File

@@ -1,19 +1,19 @@
{
"data": "ENC[AES256_GCM,data:vp0yW0Gt,iv:FO2cy+UpEl5aRay/LUGu//c82QiVxuKuGSaVh0rGJvc=,tag:vf2RAOPpcRW0HwxHoGy17A==,type:str]",
"data": "ENC[AES256_GCM,data:ImlGIKxE,iv:UUWxjLNRKJCD2WHNpw8lfvCc8rnXPCqc2pni1ODckjE=,tag:HFCqiv31E9bShIIaAEjF0A==,type:str]",
"sops": {
"age": [
{
"recipient": "age12w2ld4vxfyf3hdq2d8la4cu0tye4pq97egvv3me4wary7xkdnq2snh0zx2",
"enc": "-----BEGIN AGE ENCRYPTED FILE-----\nYWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSBjaFVNMEd2YUxpSm5XVVRi\nY2ZUc3NTOStJUFNMWWVPQTgxZ2tCK1QrMW1ZCjYwMlA4dkIzSlc0TGtvZjcyK3Bi\nM3pob2JOOFUyeVJ6M2JpaTRCZlc1R0kKLS0tIDJMb1dFcVRWckhwYWNCQng0RlFO\nTkw3OGt4dkFIZVY5aVEzZE5mMzJSM0EKUv8bUqg48L2FfYVUVlpXvyZvPye699of\nG6PcjLh1ZMbNCfnsCzr+P8Vdk/F4J/ifxL66lRGfu2xOLxwciwQ+5Q==\n-----END AGE ENCRYPTED FILE-----\n"
"recipient": "age19urkt89q45a2wk6a4yaramzufjtnw6nq2snls0v7hmf7tqf73axsfx50tk",
"enc": "-----BEGIN AGE ENCRYPTED FILE-----\nYWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSBpTEROZjh6NjBhSlJSc1Av\nSHhjdkhwVUd3VzBZemhQb3dhMlJXalBmZlFjCkZPYkhZZGVOVTNjUWdFU0s4cWFn\nL2NXbkRCdUlMdElnK2lGbG5iV0w1cHMKLS0tIFREcmxDdHlUNVBFVGRVZSt0c0E5\nbnpHaW1Vb3R3ZFFnZVMxY3djSjJmOU0KIwqCSQf5S9oA59BXu7yC/V6yqvCh88pa\nYgmNyBjulytPh1aAfOuNWIGdIxBpcEf+gFjz3EiJY9Kft3fTmhp2bw==\n-----END AGE ENCRYPTED FILE-----\n"
},
{
"recipient": "age1qm0p4vf9jvcnn43s6l4prk8zn6cx0ep9gzvevxecv729xz540v8qa742eg",
"enc": "-----BEGIN AGE ENCRYPTED FILE-----\nYWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSBnZ2dDbVhoQngxM3lTSmZF\nUTAwS1lCTGhEMU1GVXpFUzlIUFdqZy9LajF3Ck9mdVpBRjlyVUNhZXZIUFZjUzF1\nNlhFN28vNmwzcUVkNmlzUnpkWjJuZE0KLS0tIHpXVHVlNk9vU1ZPTGRrYStWbmRO\nbDM4U2o1SlEwYWtqOXBqd3BFUTAvMHcKkI8UVd0v+x+ELZ5CoGq9DzlA6DnVNU2r\nrV9wLfbFd7RHxS0/TYZh5tmU42nO3iMYA9FqERQXCtZgXS9KvfqHwQ==\n-----END AGE ENCRYPTED FILE-----\n"
"enc": "-----BEGIN AGE ENCRYPTED FILE-----\nYWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSArN3R4TThibjdYbE9TMDE1\naUhuNDlscExjaktIR2VmTk1OMWtVM0NpTUJZClJUNEcwVDlibExWQk84TTNEWFhp\nMjYyZStHc1N0ZTh1S3VTVk45WGxlWWMKLS0tIHFab25LY1R1d1l6NE5XbHJvQ3lj\nNGsxUldFVHQ5RVJERDlGbi9NY29hNWsKENBTcAS/R/dTGRYdaWv5Mc/YG4bkah5w\nb421ZMQF+r4CYnzUqnwivTG8TMRMqJLavfkutE6ZUfJbbLufrTk5Lw==\n-----END AGE ENCRYPTED FILE-----\n"
}
],
"lastmodified": "2025-05-04T12:44:18Z",
"mac": "ENC[AES256_GCM,data:1ZZ+ZI1JsHmxTov1bRijfol3kTkyheg2o3ivLsMHRhCmScsUry97hQJchF78+y2Izt7avaQEHYn6pVbYt/0rLrSYD7Ru7ITVxXoYHOiN5Qb98masUzpibZjrdyg5nO+LW5/Hmmwsc3yn/+o3IH1AUYpsxlJRdnHHCmoSOFaiFFM=,iv:OQlgmpOTw4ljujNzqwQ5/0Mz8pQpCSUtqRvj3FJAxDs=,tag:foZvdeW7gK9ZVKkWqnlxGA==,type:str]",
"lastmodified": "2025-04-09T15:11:04Z",
"mac": "ENC[AES256_GCM,data:JdJzocQZWVprOmZ4Ni04k1tpD1TpFcK5neKy3+0/c3+uPBwjwaMayISKRaa/ILUXlalg60oTqxB4fUFoYVm8KGQVhDwPhO/T1hyYVQqidonrcYfJfCYg00mVSREV/AWqXb7RTnaEBfrdnRJvaAQF9g2qDXGVgzp3eACdlItclv4=,iv:nOw1jQjIWHWwU3SiKpuQgMKXyu8MZYI+zI9UYYd9fCI=,tag:ewUkemIPm/5PkmuUD0EcAQ==,type:str]",
"unencrypted_suffix": "_unencrypted",
"version": "3.10.2"
"version": "3.10.1"
}
}

View File

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

View File

@@ -1,19 +0,0 @@
{
"data": "ENC[AES256_GCM,data:prFl0EJy8bM=,iv:zITWxf+6Ebk0iB5vhhd7SBQa1HFrIJXm8xpSM+D9I0M=,tag:NZCRMCs1SzNKLBu/KUDKMQ==,type:str]",
"sops": {
"age": [
{
"recipient": "age12w2ld4vxfyf3hdq2d8la4cu0tye4pq97egvv3me4wary7xkdnq2snh0zx2",
"enc": "-----BEGIN AGE ENCRYPTED FILE-----\nYWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSB0S0RZRWxaZVZvTUhjdWVL\naU9WZmtEcm1qa2JsRmdvdmZmNENMaWFEVUFRCmdoVnRXSGlpRlFjNmVVbDJ5VnFT\nMnVJUlVnM3lxNmZCRTdoRVJ4NW1oYWcKLS0tIFFNbXBFUk1RWnlUTW1SeG1vYzlM\nVVpEclFVOE9PWWQxVkZ0eEgwWndoRWcKDAOHe+FIxqGsc6LhxMy164qjwG6t2Ei2\nP0FSs+bcKMDpudxeuxCjnDm/VoLxOWeuqkB+9K2vSm2W/c/fHTSbrA==\n-----END AGE ENCRYPTED FILE-----\n"
},
{
"recipient": "age1qm0p4vf9jvcnn43s6l4prk8zn6cx0ep9gzvevxecv729xz540v8qa742eg",
"enc": "-----BEGIN AGE ENCRYPTED FILE-----\nYWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSB2VU5jOEpwYUtDVEVFcVpU\nQkExTVZ3ejZHcGo5TG8zdUQwNktoV09WdUZvCmQ0dE1TOWRFbTlxdVd4WWRxd3VF\nQUNTTkNNT3NKYjQ5dEJDY0xVZ3pZVUUKLS0tIDFjajRZNFJZUTdNeS8yN05FMFZU\ncEtjRjhRbGE0MnRLdk10NkFLMkxqencKGzJ66dHluIghH04RV/FccfEQP07yqnfb\n25Hi0XIVJfXBwje4UEyszrWTxPPwVXdQDQmoNKf76Qy2jYqJ56uksw==\n-----END AGE ENCRYPTED FILE-----\n"
}
],
"lastmodified": "2025-05-04T12:44:20Z",
"mac": "ENC[AES256_GCM,data:FIkilsni5kOdNlVwDuLsQ/zExypHRWdqIBQDNWMLTwe8OrsNPkX+KYutUvt9GaSoGv4iDULaMRoizO/OZUNfc2d8XYSdj0cxOG1Joov4GPUcC/UGyNuQneAejZBKolvlnidKZArofnuK9g+lOTANEUtEXUTnx8L+VahqPZayQas=,iv:NAo6sT3L8OOB3wv1pjr3RY2FwXgVmZ4N0F4BEX4YPUY=,tag:zHwmXygyvkdpASZCodQT9Q==,type:str]",
"unencrypted_suffix": "_unencrypted",
"version": "3.10.2"
}
}

View File

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

View File

@@ -0,0 +1 @@
This is a dummy script

View File

@@ -14,14 +14,13 @@ 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 =
{
pkgs,
lib,
self',
system,
...
}:
{
@@ -84,10 +83,7 @@ in
schema =
(self.clanLib.inventory.evalClanService {
modules = [ m ];
prefix = [
"checks"
system
];
key = "checks";
}).config.result.api.schema;
in
schema
@@ -101,12 +97,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

@@ -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

@@ -4,15 +4,7 @@
_class = "clan.service";
manifest.name = "clan-core/hello-word";
roles.peer = {
interface =
{ lib, ... }:
{
options.foo = lib.mkOption {
type = lib.types.str;
};
};
};
roles.peer = { };
perMachine =
{ machine, ... }:

View File

@@ -10,6 +10,9 @@ let
};
in
{
clan.inventory.modules = {
hello-world = module;
};
clan.modules = {
hello-world = module;
};
@@ -47,7 +50,6 @@ in
hello-service = import ./tests/vm/default.nix {
inherit module;
inherit self inputs pkgs;
# clanLib is exposed from inputs.clan-core
clanLib = self.clanLib;
};
};

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

@@ -19,7 +19,7 @@ We might not be sure whether all of those will exist but the architecture should
## Decision
This leads to the conclusion that we should do `library` centric development.
With the current `clan` python code being a library that can be imported to create various tools ontop of it.
With the current `clan` python code beeing a library that can be imported to create various tools ontop of it.
All **CLI** or **UI** related parts should be moved out of the main library.
*Note: The next person who wants implement any new frontend should do this first. Currently it looks like the TUI is the next one.*

View File

@@ -1,47 +0,0 @@
# ADR Numbering process
## Status
Proposed after some conversation between @lassulus, @Mic92, & @lopter.
## Context
It can be useful to refer to ADRs by their numbers, rather than their full title. To that end, short and sequential numbers are useful.
The issue is that an ADR number is effectively assigned when the ADR is merged, before being merged its number is provisional. Because multiple ADRs can be written at the same time, you end-up with multiple provisional ADRs with the same number, for example this is the third ADR-3:
1. ADR-3-clan-compat: see [#3212];
2. ADR-3-fetching-nix-from-python: see [#3452];
3. ADR-3-numbering-process: this ADR.
This situation makes it impossible to refer to an ADR by its number, and why I (@lopter) went with the arbitrary number 7 in [#3196].
We could solve this problem by using the PR number as the ADR number (@lassulus). The issue is that PR numbers are getting big in clan-core which does not make them easy to remember, or use in conversation and code (@lopter).
Another approach would be to move the ADRs in a different repository, this would reset the counter back to 1, and make it straightforward to keep ADR and PR numbers in sync (@lopter). The issue then is that ADR are not in context with their changes which makes them more difficult to review (@Mic92).
## Decision
A third approach would be to:
1. Commit ADRs before they are approved, so that the next ADR number gets assigned;
1. Open a PR for the proposed ADR;
1. Update the ADR file committed in step 1, so that its markdown contents point to the PR that tracks it.
## Consequences
### ADR have unique and memorable numbers trough their entire life cycle
This makes it easier to refer to them in conversation or in code.
### You need to have commit access to get an ADR number assigned
This makes it more difficult for someone external to the project to contribute an ADR.
### Creating a new ADR requires multiple commits
Maybe a script or CI flow could help with that if it becomes painful.
[#3212]: https://git.clan.lol/clan/clan-core/pulls/3212/
[#3452]: https://git.clan.lol/clan/clan-core/pulls/3452/
[#3196]: https://git.clan.lol/clan/clan-core/pulls/3196/

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:

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"
```

38
flake.lock generated
View File

@@ -16,11 +16,11 @@
]
},
"locked": {
"lastModified": 1747008053,
"narHash": "sha256-rob/qftmEuk+/JVGCIrOpv+LWjdmayFtebEKqRZXVAI=",
"rev": "2666bb11f4287cfbdf3b7c5f55231c6b5772a436",
"lastModified": 1746334246,
"narHash": "sha256-YU4wtH9Y5yRjqbMwczOdDakOjSiTkOUP/JAYd1f3jBc=",
"rev": "607ce65fbfe20bb38170b76826a11006f526c05d",
"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/607ce65fbfe20bb38170b76826a11006f526c05d.tar.gz"
},
"original": {
"type": "tarball",
@@ -34,11 +34,11 @@
]
},
"locked": {
"lastModified": 1746729224,
"narHash": "sha256-9R4sOLAK1w3Bq54H3XOJogdc7a6C2bLLmatOQ+5pf5w=",
"lastModified": 1745812220,
"narHash": "sha256-hotBG0EJ9VmAHJYF0yhWuTVZpENHvwcJ2SxvIPrXm+g=",
"owner": "nix-community",
"repo": "disko",
"rev": "85555d27ded84604ad6657ecca255a03fd878607",
"rev": "d0c543d740fad42fe2c035b43c9d41127e073c78",
"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-EbVl0wIdDYZWrxpQoxPlXfliaR4KHA9xP5dVjG1CZxI=",
"rev": "ed30f8aba41605e3ab46421e3dcb4510ec560ff8",
"narHash": "sha256-pxwYhAgOyComW58BCfboADZWr4b5oS8hP9E9fQ489HM=",
"rev": "f21e4546e3ede7ae34d12a84602a22246b31f7e0",
"type": "tarball",
"url": "https://releases.nixos.org/nixpkgs/nixpkgs-25.05pre794180.ed30f8aba416/nixexprs.tar.xz"
"url": "https://releases.nixos.org/nixpkgs/nixpkgs-25.05pre793694.f21e4546e3ed/nixexprs.tar.xz"
},
"original": {
"type": "tarball",
@@ -149,11 +149,11 @@
]
},
"locked": {
"lastModified": 1746485181,
"narHash": "sha256-PxrrSFLaC7YuItShxmYbMgSuFFuwxBB+qsl9BZUnRvg=",
"lastModified": 1745310711,
"narHash": "sha256-ePyTpKEJTgX0gvgNQWd7tQYQ3glIkbqcW778RpHlqgA=",
"owner": "Mic92",
"repo": "sops-nix",
"rev": "e93ee1d900ad264d65e9701a5c6f895683433386",
"rev": "5e3e92b16d6fdf9923425a8d4df7496b2434f39c",
"type": "github"
},
"original": {
@@ -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

@@ -10,11 +10,6 @@ let
in
{
options = {
_prefix = lib.mkOption {
type = types.listOf types.str;
internal = true;
default = [ ];
};
self = lib.mkOption {
type = types.raw;
default = self;
@@ -173,7 +168,6 @@ in
inventoryFile = lib.mkOption { type = lib.types.raw; };
# The machine 'imports' generated by the inventory per machine
inventoryClass = lib.mkOption { type = lib.types.raw; };
evalServiceSchema = lib.mkOption { };
# clan-core's modules
clanModules = lib.mkOption { type = lib.types.raw; };
source = lib.mkOption { type = lib.types.raw; };

View File

@@ -44,10 +44,6 @@ let
buildInventory {
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;
}
);
@@ -181,7 +177,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; }
@@ -209,9 +204,6 @@ in
inherit inventoryClass;
# Endpoint that can be called to get a service schema
evalServiceSchema = clan-core.clanLib.evalServiceSchema config.self;
# TODO: unify this interface
# We should have only clan.modules. (consistent with clan.templates)
inherit (clan-core) clanModules clanLib;

View File

@@ -15,27 +15,10 @@ lib.fix (clanLib: {
*/
callLib = file: args: import file ({ inherit lib clanLib; } // args);
# ------------------------------------
buildClan = clanLib.buildClanModule.buildClanWith {
clan-core = self;
inherit nixpkgs nix-darwin;
};
evalServiceSchema =
self:
{
moduleSpec,
flakeInputs ? self.inputs,
localModuleSet ? self.clan.modules,
}:
let
resolvedModule = clanLib.inventory.resolveModule {
inherit moduleSpec flakeInputs localModuleSet;
};
in
(clanLib.inventory.evalClanService {
modules = [ resolvedModule ];
prefix = [ ];
}).config.result.api.schema;
# ------------------------------------
# ClanLib functions
evalClan = clanLib.callLib ./inventory/eval-clan-modules { };

View File

@@ -12,35 +12,25 @@ let
inventory,
directory,
flakeInputs,
prefix ? [ ],
localModuleSet ? { },
}:
(lib.evalModules {
# TODO: remove clanLib from specialArgs
specialArgs = {
inherit clanLib;
};
modules = [
./builder
(lib.modules.importApply ./service-list-from-inputs.nix {
inherit flakeInputs clanLib localModuleSet;
})
{ inherit directory inventory; }
(
# config.distributedServices.allMachines.${name} or [ ];
{ config, ... }:
{
distributedServices = clanLib.inventory.mapInstances {
inherit (config) inventory;
inherit localModuleSet;
inherit flakeInputs;
prefix = prefix ++ [ "distributedServices" ];
};
machines = lib.mapAttrs (_machineName: v: {
machineImports = v;
}) config.distributedServices.allMachines;
}
)
(lib.modules.importApply ./inventory-introspection.nix { inherit clanLib; })

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,43 +0,0 @@
{
flakeInputs,
clanLib,
localModuleSet,
}:
{ lib, config, ... }:
let
inspectModule =
inputName: moduleName: module:
let
eval = clanLib.inventory.evalClanService {
modules = [ module ];
prefix = [
inputName
"clan"
"modules"
moduleName
];
};
in
{
manifest = eval.config.manifest;
roles = lib.mapAttrs (_n: _v: { }) eval.config.roles;
};
in
{
options.modulesPerSource = lib.mkOption {
# { sourceName :: { moduleName :: {} }}
default =
let
inputsWithModules = lib.filterAttrs (_inputName: v: v ? clan.modules) flakeInputs;
in
lib.mapAttrs (
inputName: v: lib.mapAttrs (inspectModule inputName) v.clan.modules
) inputsWithModules;
};
options.localModules = lib.mkOption {
default = lib.mapAttrs (inspectModule "self") localModuleSet;
};
}

View File

@@ -3,7 +3,7 @@ let
services = clanLib.callLib ./distributed-service/inventory-adapter.nix { };
in
{
inherit (services) evalClanService mapInstances resolveModule;
inherit (services) evalClanService mapInstances;
inherit (import ./build-inventory { inherit lib clanLib; }) buildInventory;
interface = ./build-inventory/interface.nix;
# Returns the list of machine names

View File

@@ -1,7 +1,7 @@
# This module enables itself if
# manifest.features.API = true
# It converts the roles.interface to a json-schema
{ clanLib, prefix }:
{ clanLib, attrName }:
let
converter = clanLib.jsonschema {
includeDefaults = true;
@@ -45,7 +45,7 @@ in
To see the evaluation problem run
nix eval .#${lib.concatStringsSep "." prefix}.config.result.api.schema.${roleName}
nix eval .#clanInternals.inventoryClass.distributedServices.importedModulesEvaluated.${attrName}.config.result.api.schema.${roleName}
'';
assertion = (builtins.tryEval (lib.deepSeq config.result.api.schema.${roleName} true)).success;
};

View File

@@ -16,73 +16,27 @@
}:
let
evalClanService =
{ modules, prefix }:
{ modules, key }:
(lib.evalModules {
class = "clan.service";
modules = [
./service-module.nix
# feature modules
(lib.modules.importApply ./api-feature.nix {
inherit clanLib prefix;
inherit clanLib;
attrName = key;
})
] ++ modules;
});
resolveModule =
{
moduleSpec,
flakeInputs,
localModuleSet,
}:
let
# TODO:
resolvedModuleSet =
# If the module.name is self then take the modules defined in the flake
# Otherwise its an external input which provides the modules via 'clan.modules' attribute
if moduleSpec.input == null then
localModuleSet
else
let
input =
flakeInputs.${moduleSpec.input} or (throw ''
Flake doesn't provide input with name '${moduleSpec.input}'
Choose one of the following inputs:
- ${
builtins.concatStringsSep "\n- " (
lib.attrNames (lib.filterAttrs (_name: input: input ? clan) flakeInputs)
)
}
To import a local module from 'clan.modules' remove the 'input' attribute from the module definition
Remove the following line from the module definition:
...
- module.input = "${moduleSpec.input}"
'');
clanAttrs =
input.clan
or (throw "It seems the flake input ${moduleSpec.input} doesn't export any clan resources");
in
clanAttrs.modules;
resolvedModule =
resolvedModuleSet.${moduleSpec.name}
or (throw "flake doesn't provide clan-module with name ${moduleSpec.name}");
in
resolvedModule;
in
{
inherit evalClanService resolveModule;
inherit evalClanService;
mapInstances =
{
# This is used to resolve the module imports from 'flake.inputs'
flakeInputs,
# The clan inventory
inventory,
localModuleSet,
prefix ? [ ],
}:
let
# machineHasTag = machineName: tagName: lib.elem tagName inventory.machines.${machineName}.tags;
@@ -91,11 +45,42 @@ in
importedModuleWithInstances = lib.mapAttrs (
instanceName: instance:
let
resolvedModule = resolveModule {
moduleSpec = instance.module;
inherit localModuleSet;
inherit flakeInputs;
};
# TODO:
resolvedModuleSet =
# If the module.name is self then take the modules defined in the flake
# Otherwise its an external input which provides the modules via 'clan.modules' attribute
if instance.module.input == null then
inventory.modules
else
let
input =
flakeInputs.${instance.module.input} or (throw ''
Flake doesn't provide input with name '${instance.module.input}'
Choose one of the following inputs:
- ${
builtins.concatStringsSep "\n- " (
lib.attrNames (lib.filterAttrs (_name: input: input ? clan) flakeInputs)
)
}
To import a local module from 'inventory.modules' remove the 'input' attribute from the module definition
Remove the following line from the module definition:
...
- module.input = "${instance.module.input}"
'');
clanAttrs =
input.clan
or (throw "It seems the flake input ${instance.module.input} doesn't export any clan resources");
in
clanAttrs.modules;
resolvedModule =
resolvedModuleSet.${instance.module.name}
or (throw "flake doesn't provide clan-module with name ${instance.module.name}");
# Every instance includes machines via roles
# :: { client :: ... }
@@ -153,7 +138,7 @@ in
importedModulesEvaluated = lib.mapAttrs (
module_ident: instances:
evalClanService {
prefix = prefix ++ [ module_ident ];
key = module_ident;
modules =
[
# Import the resolved module.

View File

@@ -255,9 +255,7 @@ in
{
options.API = mkOption {
type = types.bool;
# This is read only, because we don't support turning it off yet
readOnly = true;
default = true;
default = false;
description = ''
Enables automatic API schema conversion for the interface of this module.
'';

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

@@ -92,7 +92,7 @@ in
lib.lazyDerivation {
# lazyDerivation improves performance when only passthru items and/or meta are used.
derivation = hostPkgs.stdenv.mkDerivation {
name = "container-test-run-${config.name}";
name = "vm-test-run-${config.name}";
requiredSystemFeatures = [ "uid-range" ];

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
@@ -191,22 +187,6 @@ class Machine:
if line_pattern.match(line)
)
def nsenter_command(self, command: str) -> list[str]:
return [
"nsenter",
"--target",
str(self.container_pid),
"--mount",
"--uts",
"--ipc",
"--net",
"--pid",
"--cgroup",
"/bin/sh",
"-c",
command,
]
def execute(
self,
command: str,
@@ -248,10 +228,23 @@ 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),
[
"nsenter",
"--target",
str(self.container_pid),
"--mount",
"--uts",
"--ipc",
"--net",
"--pid",
"--cgroup",
"/bin/sh",
"-c",
command,
],
timeout=timeout,
check=False,
stdout=subprocess.PIPE,
@@ -472,43 +465,6 @@ 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,
]
)
)
def test_symbols(self) -> dict[str, Any]:
general_symbols = {
"start_all": self.start_all,

View File

@@ -22,9 +22,6 @@ in
pkgs,
self,
useContainers ? true,
# Displayed for better error messages, otherwise the placeholder
system ? "<system>",
attrName ? "<check_name>",
...
}:
let
@@ -38,7 +35,7 @@ in
{
imports = [
nixosTest
] ++ lib.optionals useContainers [ ./container-test-driver/driver-module.nix ];
] ++ lib.optionals (useContainers) [ ./container-test-driver/driver-module.nix ];
options = {
clanSettings = mkOption {
default = { };
@@ -63,15 +60,6 @@ in
};
modules = [
clanLib.buildClanModule.flakePartsModule
{
_prefix = [
"checks"
system
attrName
"config"
"clan"
];
}
];
};
};

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

@@ -39,44 +39,9 @@ in
type = submodule { imports = [ ./interface.nix ]; };
};
config = {
# check all that all non-secret files have no owner/group/mode set
warnings = lib.foldl' (
warnings: generator:
warnings
++ lib.foldl' (
warnings: file:
warnings
++
lib.optional
(
!file.secret
&& (
file.owner != "root"
|| file.group != (if _class == "darwin" then "wheel" else "root")
|| file.mode != "0400"
)
)
''
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.
''}
This doesn't work because the file will be added to the nix store
''
) [ ] (lib.attrValues generator.files)
) [ ] (lib.attrValues config.clan.core.vars.generators);
system.clan.deployment.data = {
vars = config.clan.core.vars._serialized;
inherit (config.clan.core.networking) targetHost buildHost;
inherit (config.clan.core.deployment) requireExplicitUpdate;
};
config.system.clan.deployment.data = {
vars = config.clan.core.vars._serialized;
inherit (config.clan.core.networking) targetHost buildHost;
inherit (config.clan.core.deployment) requireExplicitUpdate;
};
}

View File

@@ -39,7 +39,7 @@ in
internal = true;
description = ''
JSON serialization of the generators.
This is read from the python client to generate the specified resources.
This is read from the python client to generate the specified ressources.
'';
default = {
# TODO: We don't support per-machine choice of backends
@@ -258,7 +258,7 @@ in
defaultText = ''
builtins.path {
name = "$${generator.config._module.args.name}_$${file.config._module.args.name}";
path = file.config.flakePath;
path = file.config.inRepoPath;
}
'';
default = builtins.path {

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

@@ -29,8 +29,6 @@ mkShell {
export GIT_ROOT=$(git rev-parse --show-toplevel)
export PKG_ROOT=$GIT_ROOT/pkgs/clan-app
export CLAN_CORE_PATH="$GIT_ROOT"
# Add current package to PYTHONPATH
export PYTHONPATH="$PKG_ROOT''${PYTHONPATH:+:$PYTHONPATH:}"

View File

@@ -6,13 +6,13 @@ from pathlib import Path
from types import ModuleType
# These imports are unused, but necessary for @API.register to run once.
from clan_lib.api import directory, disk, iwd, mdns_discovery, modules
from clan_lib.api import admin, directory, disk, iwd, mdns_discovery, modules
from .arg_actions import AppendOptionAction
from .clan import show, update
# API endpoints that are not used in the cli.
__all__ = ["directory", "disk", "iwd", "mdns_discovery", "modules", "update"]
__all__ = ["admin", "directory", "disk", "iwd", "mdns_discovery", "modules", "update"]
from . import (
backups,

View File

@@ -19,23 +19,21 @@ def create_backup(machine: Machine, provider: str | None = None) -> None:
if not backup_scripts["providers"]:
msg = "No providers specified"
raise ClanError(msg)
with machine.target_host() as host:
for provider in backup_scripts["providers"]:
proc = host.run(
[backup_scripts["providers"][provider]["create"]],
)
if proc.returncode != 0:
msg = "failed to start backup"
raise ClanError(msg)
print("successfully started backup")
for provider in backup_scripts["providers"]:
proc = machine.target_host.run(
[backup_scripts["providers"][provider]["create"]],
)
if proc.returncode != 0:
msg = "failed to start backup"
raise ClanError(msg)
print("successfully started backup")
else:
if provider not in backup_scripts["providers"]:
msg = f"provider {provider} not found"
raise ClanError(msg)
with machine.target_host() as host:
proc = host.run(
[backup_scripts["providers"][provider]["create"]],
)
proc = machine.target_host.run(
[backup_scripts["providers"][provider]["create"]],
)
if proc.returncode != 0:
msg = "failed to start backup"
raise ClanError(msg)

View File

@@ -10,7 +10,6 @@ from clan_cli.completions import (
)
from clan_cli.errors import ClanError
from clan_cli.machines.machines import Machine
from clan_cli.ssh.host import Host
@dataclass
@@ -19,11 +18,11 @@ class Backup:
job_name: str | None = None
def list_provider(machine: Machine, host: Host, provider: str) -> list[Backup]:
def list_provider(machine: Machine, provider: str) -> list[Backup]:
results = []
backup_metadata = machine.eval_nix("config.clan.core.backups")
list_command = backup_metadata["providers"][provider]["list"]
proc = host.run(
proc = machine.target_host.run(
[list_command],
RunOpts(log=Log.NONE, check=False),
)
@@ -49,13 +48,12 @@ def list_provider(machine: Machine, host: Host, provider: str) -> list[Backup]:
def list_backups(machine: Machine, provider: str | None = None) -> list[Backup]:
backup_metadata = machine.eval_nix("config.clan.core.backups")
results = []
with machine.target_host() as host:
if provider is None:
for _provider in backup_metadata["providers"]:
results += list_provider(machine, host, _provider)
if provider is None:
for _provider in backup_metadata["providers"]:
results += list_provider(machine, _provider)
else:
results += list_provider(machine, host, provider)
else:
results += list_provider(machine, provider)
return results

View File

@@ -8,12 +8,9 @@ from clan_cli.completions import (
)
from clan_cli.errors import ClanError
from clan_cli.machines.machines import Machine
from clan_cli.ssh.host import Host
def restore_service(
machine: Machine, host: Host, name: str, provider: str, service: str
) -> None:
def restore_service(machine: Machine, name: str, provider: str, service: str) -> None:
backup_metadata = machine.eval_nix("config.clan.core.backups")
backup_folders = machine.eval_nix("config.clan.core.state")
@@ -28,7 +25,7 @@ def restore_service(
env["FOLDERS"] = ":".join(set(folders))
if pre_restore := backup_folders[service]["preRestoreCommand"]:
proc = host.run(
proc = machine.target_host.run(
[pre_restore],
RunOpts(log=Log.STDERR),
extra_env=env,
@@ -37,7 +34,7 @@ def restore_service(
msg = f"failed to run preRestoreCommand: {pre_restore}, error was: {proc.stdout}"
raise ClanError(msg)
proc = host.run(
proc = machine.target_host.run(
[backup_metadata["providers"][provider]["restore"]],
RunOpts(log=Log.STDERR),
extra_env=env,
@@ -47,7 +44,7 @@ def restore_service(
raise ClanError(msg)
if post_restore := backup_folders[service]["postRestoreCommand"]:
proc = host.run(
proc = machine.target_host.run(
[post_restore],
RunOpts(log=Log.STDERR),
extra_env=env,
@@ -64,19 +61,18 @@ def restore_backup(
service: str | None = None,
) -> None:
errors = []
with machine.target_host() as host:
if service is None:
backup_folders = machine.eval_nix("config.clan.core.state")
for _service in backup_folders:
try:
restore_service(machine, host, name, provider, _service)
except ClanError as e:
errors.append(f"{_service}: {e}")
else:
if service is None:
backup_folders = machine.eval_nix("config.clan.core.state")
for _service in backup_folders:
try:
restore_service(machine, host, name, provider, service)
restore_service(machine, name, provider, _service)
except ClanError as e:
errors.append(f"{service}: {e}")
errors.append(f"{_service}: {e}")
else:
try:
restore_service(machine, name, provider, service)
except ClanError as e:
errors.append(f"{service}: {e}")
if errors:
raise ClanError(
"Restore failed for the following services:\n" + "\n".join(errors)

View File

@@ -1,8 +1,4 @@
import os
import shutil
from pathlib import Path
from clan_cli.cmd import Log, RunOpts, run
from clan_cli.cmd import run
from clan_cli.nix import nix_shell
_works: bool | None = None
@@ -16,11 +12,6 @@ def bubblewrap_works() -> bool:
def _bubblewrap_works() -> bool:
real_bash_path = Path("bash")
if os.environ.get("IN_NIX_SANDBOX"):
bash_executable_path = Path(str(shutil.which("bash")))
real_bash_path = bash_executable_path.resolve()
# fmt: off
cmd = nix_shell(
[
@@ -39,10 +30,13 @@ def _bubblewrap_works() -> bool:
"--gid", "1000",
"--",
# do nothing, just test if bash executes
str(real_bash_path), "-c", ":"
"bash", "-c", ":"
],
)
# fmt: on
res = run(cmd, RunOpts(log=Log.BOTH, check=False))
return res.returncode == 0
try:
run(cmd)
except Exception:
return False
else:
return True

View File

@@ -107,7 +107,7 @@ def create_clan(opts: CreateOptions) -> CreateClanResponse:
response.flake_update = flake_update
if opts.initial:
init_inventory(Flake(str(opts.dest)), init=opts.initial)
init_inventory(str(opts.dest), init=opts.initial)
return response

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

@@ -2,21 +2,20 @@ from dataclasses import dataclass
from clan_lib.api import API
from clan_cli.flake import Flake
from clan_cli.inventory import Inventory, Meta, load_inventory_json, set_inventory
@dataclass
class UpdateOptions:
flake: Flake
directory: str
meta: Meta
@API.register
def update_clan_meta(options: UpdateOptions) -> Inventory:
inventory = load_inventory_json(options.flake)
inventory = load_inventory_json(options.directory)
inventory["meta"] = options.meta
set_inventory(inventory, options.flake, "Update clan metadata")
set_inventory(inventory, options.directory, "Update clan metadata")
return inventory

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

@@ -1,3 +1,9 @@
import pytest
from clan_cli.custom_logger import setup_logging
# collect_ignore = ["./nixpkgs"]
pytest_plugins = [
"clan_cli.tests.temporary_dir",
"clan_cli.tests.root",
@@ -13,3 +19,13 @@ pytest_plugins = [
"clan_cli.tests.stdout",
"clan_cli.tests.nix_config",
]
# Executed on pytest session start
def pytest_sessionstart(session: pytest.Session) -> None:
# This function will be called once at the beginning of the test session
print("Starting pytest session")
# You can access the session config, items, testsfailed, etc.
print(f"Session config: {session.config}")
setup_logging(level="INFO")

View File

@@ -4,14 +4,9 @@ import sys
import urllib
from enum import Enum
from pathlib import Path
from typing import TYPE_CHECKING
from .errors import ClanError
if TYPE_CHECKING:
from clan_cli.flake import Flake
from clan_cli.machines.machines import Machine
log = logging.getLogger(__name__)
@@ -136,17 +131,12 @@ def vm_state_dir(flake_url: str, vm_name: str) -> Path:
return user_data_dir() / "clan" / "vmstate" / clan_key / vm_name
def machines_dir(flake: "Flake") -> Path:
if flake.is_local:
return flake.path / "machines"
store_path = flake.store_path
assert store_path is not None, "Invalid flake object. Doesn't have a store path"
return Path(store_path) / "machines"
def machines_dir(flake_dir: Path) -> Path:
return flake_dir / "machines"
def specific_machine_dir(machine: "Machine") -> Path:
return machines_dir(machine.flake) / machine.name
def specific_machine_dir(flake_dir: Path, machine: str) -> Path:
return machines_dir(flake_dir) / 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

@@ -4,7 +4,6 @@ from abc import ABC, abstractmethod
from pathlib import Path
import clan_cli.machines.machines as machines
from clan_cli.ssh.host import Host
class SecretStoreBase(ABC):
@@ -26,7 +25,7 @@ class SecretStoreBase(ABC):
def exists(self, service: str, name: str) -> bool:
pass
def needs_upload(self, host: Host) -> bool:
def needs_upload(self) -> bool:
return True
@abstractmethod

View File

@@ -6,7 +6,6 @@ from typing import override
from clan_cli.cmd import Log, RunOpts
from clan_cli.machines.machines import Machine
from clan_cli.nix import nix_shell
from clan_cli.ssh.host import Host
from . import SecretStoreBase
@@ -94,9 +93,9 @@ class SecretStore(SecretStoreBase):
return b"\n".join(hashes)
@override
def needs_upload(self, host: Host) -> bool:
def needs_upload(self) -> bool:
local_hash = self.generate_hash()
remote_hash = host.run(
remote_hash = self.machine.target_host.run(
# TODO get the path to the secrets from the machine
["cat", f"{self.machine.secrets_upload_directory}/.pass_info"],
RunOpts(log=Log.STDERR, check=False),

View File

@@ -6,7 +6,6 @@ from clan_cli.secrets.folders import sops_secrets_folder
from clan_cli.secrets.machines import add_machine, has_machine
from clan_cli.secrets.secrets import decrypt_secret, encrypt_secret, has_secret
from clan_cli.secrets.sops import generate_private_key
from clan_cli.ssh.host import Host
from . import SecretStoreBase
@@ -61,7 +60,7 @@ class SecretStore(SecretStoreBase):
)
@override
def needs_upload(self, host: Host) -> bool:
def needs_upload(self) -> bool:
return False
# We rely now on the vars backend to upload the age key

View File

@@ -1,6 +1,5 @@
import shutil
from pathlib import Path
from typing import override
from clan_cli.dirs import vm_state_dir
from clan_cli.machines.machines import Machine
@@ -29,7 +28,6 @@ class SecretStore(SecretStoreBase):
def exists(self, service: str, name: str) -> bool:
return (self.dir / service / name).exists()
@override
def upload(self, output_dir: Path) -> None:
if output_dir.exists():
shutil.rmtree(output_dir)

View File

@@ -11,16 +11,16 @@ 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
if not machine.secret_facts_store.needs_upload():
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(machine.target_host, local_secret_dir, remote_secret_dir)
def upload_command(args: argparse.Namespace) -> None:

View File

@@ -14,7 +14,6 @@ from clan_cli.nix import (
nix_build,
nix_command,
nix_config,
nix_eval,
nix_metadata,
nix_test_store,
)
@@ -575,12 +574,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":
@@ -620,9 +619,11 @@ class Flake:
except Exception as e:
log.warning(f"Failed load eval cache: {e}. Continue without cache")
def prefetch(self) -> None:
def invalidate_cache(self) -> None:
"""
Loads the flake into the store and populates self.store_path and self.hash such that the flake can evaluate locally and offline
Invalidate the cache and reload it.
This method is used to refresh the cache by reloading it from the flake.
"""
cmd = [
"flake",
@@ -641,15 +642,6 @@ class Flake:
flake_metadata = json.loads(flake_prefetch.stdout)
self.store_path = flake_metadata["storePath"]
self.hash = flake_metadata["hash"]
self.flake_metadata = flake_metadata
def invalidate_cache(self) -> None:
"""
Invalidate the cache and reload it.
This method is used to refresh the cache by reloading it from the flake.
"""
self.prefetch()
self._cache = FlakeCache()
assert self.hash is not None
@@ -659,17 +651,17 @@ class Flake:
)
self.load_cache()
if "original" not in self.flake_metadata:
self.flake_metadata = nix_metadata(self.identifier)
if "original" not in flake_metadata:
flake_metadata = nix_metadata(self.identifier)
if self.flake_metadata["original"].get("url", "").startswith("file:"):
if flake_metadata["original"].get("url", "").startswith("file:"):
self._is_local = True
path = self.flake_metadata["original"]["url"].removeprefix("file://")
path = flake_metadata["original"]["url"].removeprefix("file://")
path = path.removeprefix("file:")
self._path = Path(path)
elif self.flake_metadata["original"].get("path"):
elif flake_metadata["original"].get("path"):
self._is_local = True
self._path = Path(self.flake_metadata["original"]["path"])
self._path = Path(flake_metadata["original"]["path"])
else:
self._is_local = False
assert self.store_path is not None
@@ -763,56 +755,6 @@ class Flake:
if self.flake_cache_path:
self._cache.save_to_file(self.flake_cache_path)
def uncached_nix_eval_with_args(
self,
attr_path: str,
f_args: dict[str, str],
nix_options: list[str] | None = None,
) -> str:
"""
Calls a nix function with the provided arguments 'f_args'
The argument must be an attribute set.
Args:
attr_path (str): The attribute path to the nix function
f_args (dict[str, nix_expr]): A python dictionary mapping from the name of the argument to a raw nix expression.
Example
flake.uncached_nix_eval_with_args(
"clanInternals.evalServiceSchema",
{ "moduleSpec": "{ name = \"hello-world\"; input = null; }" }
)
> '{ ...JSONSchema... }'
"""
# Always prefetch, so we don't get any stale information
self.prefetch()
if nix_options is None:
nix_options = []
arg_expr = "{"
for arg_name, arg_value in f_args.items():
arg_expr += f" {arg_name} = {arg_value}; "
arg_expr += "}"
nix_code = f"""
let
flake = builtins.getFlake "path:{self.store_path}?narHash={self.hash}";
in
flake.{attr_path} {arg_expr}
"""
if tmp_store := nix_test_store():
nix_options += ["--store", str(tmp_store)]
nix_options.append("--impure")
output = run(
nix_eval(["--expr", nix_code, *nix_options]), RunOpts(log=Log.NONE)
).stdout.strip()
return output
def precache(
self,
selectors: list[str],

View File

@@ -14,7 +14,7 @@ from clan_cli.facts.generate import generate_facts
from clan_cli.machines.machines import Machine
from clan_cli.nix import nix_shell
from clan_cli.vars.generate import generate_vars
from clan_cli.vars.upload import populate_secret_vars
from clan_cli.vars.upload import upload_secret_vars
from .automount import pause_automounting
from .list import list_possible_keymaps, list_possible_languages
@@ -35,7 +35,6 @@ class Disk:
device: str
# TODO: unify this with machine install
@API.register
def flash_machine(
machine: Machine,
@@ -108,7 +107,7 @@ def flash_machine(
local_dir.mkdir(parents=True)
machine.secret_facts_store.upload(local_dir)
populate_secret_vars(machine, local_dir)
upload_secret_vars(machine, local_dir)
disko_install = []
if os.geteuid() != 0:

View File

@@ -21,9 +21,8 @@ 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
from clan_cli.nix import nix_eval
@@ -50,11 +49,11 @@ __all__ = [
]
def get_inventory_path(flake: Flake) -> Path:
def get_inventory_path(flake_dir: str | Path) -> Path:
"""
Get the path to the inventory file in the flake directory
"""
inventory_file = (flake.path / "inventory.json").resolve()
inventory_file = (Path(flake_dir) / "inventory.json").resolve()
return inventory_file
@@ -62,7 +61,8 @@ def get_inventory_path(flake: Flake) -> Path:
default_inventory: Inventory = {"meta": {"name": "New Clan"}}
def load_inventory_eval(flake_dir: Flake) -> Inventory:
@API.register
def load_inventory_eval(flake_dir: str | Path) -> Inventory:
"""
Loads the evaluated inventory.
After all merge operations with eventual nix code in buildClan.
@@ -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()
@@ -355,7 +355,7 @@ def determine_writeability(
return results
def get_inventory_current_priority(flake: Flake) -> dict:
def get_inventory_current_priority(flake_dir: str | Path) -> dict:
"""
Returns the current priority of the inventory values
@@ -375,12 +375,12 @@ def get_inventory_current_priority(flake: Flake) -> dict:
"""
cmd = nix_eval(
[
f"{flake}#clanInternals.inventoryClass.introspection",
f"{flake_dir}#clanInternals.inventoryClass.introspection",
"--json",
]
)
proc = run(cmd)
proc = run_no_stdout(cmd)
try:
res = proc.stdout.strip()
@@ -393,7 +393,7 @@ def get_inventory_current_priority(flake: Flake) -> dict:
@API.register
def load_inventory_json(flake: Flake) -> Inventory:
def load_inventory_json(flake_dir: str | Path) -> Inventory:
"""
Load the inventory FILE from the flake directory
If no file is found, returns an empty dictionary
@@ -403,7 +403,7 @@ def load_inventory_json(flake: Flake) -> Inventory:
Use load_inventory_eval instead
"""
inventory_file = get_inventory_path(flake)
inventory_file = get_inventory_path(flake_dir)
if not inventory_file.exists():
return {}
@@ -473,14 +473,14 @@ def patch(d: dict[str, Any], path: str, content: Any) -> None:
@API.register
def patch_inventory_with(flake: Flake, section: str, content: dict[str, Any]) -> None:
def patch_inventory_with(base_dir: Path, section: str, content: dict[str, Any]) -> None:
"""
Pass only the section to update and the content to update with.
Make sure you pass only attributes that you would like to persist.
ATTENTION: Don't pass nix eval values unintentionally.
"""
inventory_file = get_inventory_path(flake)
inventory_file = get_inventory_path(base_dir)
curr_inventory = {}
if inventory_file.exists():
@@ -492,9 +492,7 @@ def patch_inventory_with(flake: Flake, section: str, content: dict[str, Any]) ->
with inventory_file.open("w") as f:
json.dump(curr_inventory, f, indent=2)
commit_file(
inventory_file, flake.path, commit_message=f"inventory.{section}: Update"
)
commit_file(inventory_file, base_dir, commit_message=f"inventory.{section}: Update")
@dataclass
@@ -506,16 +504,16 @@ class WriteInfo:
@API.register
def get_inventory_with_writeable_keys(
flake: Flake,
flake_dir: str | Path,
) -> WriteInfo:
"""
Load the inventory and determine the writeable keys
Performs 2 nix evaluations to get the current priority and the inventory
"""
current_priority = get_inventory_current_priority(flake)
current_priority = get_inventory_current_priority(flake_dir)
data_eval: Inventory = load_inventory_eval(flake)
data_disk: Inventory = load_inventory_json(flake)
data_eval: Inventory = load_inventory_eval(flake_dir)
data_disk: Inventory = load_inventory_json(flake_dir)
writeables = determine_writeability(
current_priority, dict(data_eval), dict(data_disk)
@@ -524,17 +522,16 @@ def get_inventory_with_writeable_keys(
return WriteInfo(writeables, data_eval, data_disk)
# TODO: remove this function in favor of a proper read/write API
@API.register
def set_inventory(
inventory: Inventory, flake: Flake, message: str, commit: bool = True
inventory: Inventory, flake_dir: str | Path, message: str, commit: bool = True
) -> None:
"""
Write the inventory to the flake directory
and commit it to git with the given message
"""
write_info = get_inventory_with_writeable_keys(flake)
write_info = get_inventory_with_writeable_keys(flake_dir)
# Remove internals from the inventory
inventory.pop("tags", None) # type: ignore
@@ -555,43 +552,43 @@ def set_inventory(
for delete_path in delete_set:
delete_by_path(persisted, delete_path)
inventory_file = get_inventory_path(flake)
inventory_file = get_inventory_path(flake_dir)
with inventory_file.open("w") as f:
json.dump(persisted, f, indent=2)
if commit:
commit_file(inventory_file, flake.path, commit_message=message)
commit_file(inventory_file, Path(flake_dir), commit_message=message)
# TODO: wrap this in a proper persistence API
def delete(flake: Flake, delete_set: set[str]) -> None:
@API.register
def delete(directory: str | Path, delete_set: set[str]) -> None:
"""
Delete keys from the inventory
"""
write_info = get_inventory_with_writeable_keys(flake)
write_info = get_inventory_with_writeable_keys(directory)
data_disk = dict(write_info.data_disk)
for delete_path in delete_set:
delete_by_path(data_disk, delete_path)
inventory_file = get_inventory_path(flake)
inventory_file = get_inventory_path(directory)
with inventory_file.open("w") as f:
json.dump(data_disk, f, indent=2)
commit_file(
inventory_file,
flake.path,
Path(directory),
commit_message=f"Delete inventory keys {delete_set}",
)
def init_inventory(flake: Flake, init: Inventory | None = None) -> None:
def init_inventory(directory: str, init: Inventory | None = None) -> None:
inventory = None
# Try reading the current flake
if init is None:
with contextlib.suppress(ClanCmdError):
inventory = load_inventory_eval(flake)
inventory = load_inventory_eval(directory)
if init is not None:
inventory = init
@@ -599,9 +596,9 @@ def init_inventory(flake: Flake, init: Inventory | None = None) -> None:
# Write inventory.json file
if inventory is not None:
# Persist creates a commit message for each change
set_inventory(inventory, flake, "Init inventory")
set_inventory(inventory, directory, "Init inventory")
@API.register
def get_inventory(flake: Flake) -> Inventory:
return load_inventory_eval(flake)
def get_inventory(base_path: str | Path) -> Inventory:
return load_inventory_eval(base_path)

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)
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(str(flake.path), {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.path, 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,61 @@ 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, clan_dir: Path, machine_name: str) -> Path:
machine_dir = specific_machine_dir(clan_dir, 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"], clan_dir: Path, machine_name: str
) -> "HardwareConfig":
hardware_config = HardwareConfig.NIXOS_GENERATE_CONFIG.config_path(
clan_dir, 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(clan_dir, 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(clan_dir: Path, machine_name: str) -> HardwareConfig:
"""
Show hardware information for a machine returns None if none exist.
"""
return HardwareConfig.detect_type(machine)
return HardwareConfig.detect_type(clan_dir, machine_name)
@API.register
def show_machine_hardware_platform(machine: Machine) -> str | None:
def show_machine_deployment_target(clan_dir: Path, machine_name: str) -> str | None:
"""
Show deployment target for a machine returns None if none exist.
"""
config = nix_config()
system = config["system"]
cmd = nix_eval(
[
f"{clan_dir}#clanInternals.machines.{system}.{machine_name}",
"--apply",
"machine: { inherit (machine.config.clan.core.networking) targetHost; }",
"--json",
]
)
proc = run_no_stdout(cmd, RunOpts(prefix=machine_name))
res = proc.stdout.strip()
target_host = json.loads(res)
return target_host.get("targetHost", None)
@API.register
def show_machine_hardware_platform(clan_dir: Path, machine_name: str) -> str | None:
"""
Show hardware information for a machine returns None if none exist.
"""
@@ -61,13 +88,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"{clan_dir}#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 +103,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 +118,15 @@ 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)
hw_file = opts.backend.config_path(opts.machine)
if opts.keyfile is not None:
machine.private_key = Path(opts.keyfile)
if opts.target_host is not None:
machine.override_target_host = opts.target_host
hw_file = opts.backend.config_path(opts.flake.path, opts.machine)
hw_file.parent.mkdir(parents=True, exist_ok=True)
if opts.backend == HardwareConfig.NIXOS_FACTER:
@@ -103,26 +139,26 @@ def generate_machine_hardware_info(opts: HardwareGenerateOptions) -> HardwareCon
"--show-hardware-config",
]
with machine.target_host() as host:
host.ssh_options["StrictHostKeyChecking"] = "accept-new"
host.ssh_options["UserKnownHostsFile"] = "/dev/null"
if opts.password:
host.password = opts.password
host = machine.target_host
host.ssh_options["StrictHostKeyChecking"] = "accept-new"
host.ssh_options["UserKnownHostsFile"] = "/dev/null"
if opts.password:
host.password = opts.password
out = host.run(config_command, become_root=True, opts=RunOpts(check=False))
if out.returncode != 0:
if "nixos-facter" in out.stderr and "not found" in out.stderr:
machine.error(str(out.stderr))
msg = (
"Please use our custom nixos install images from https://github.com/nix-community/nixos-images/releases/tag/nixos-unstable. "
"nixos-factor only works on nixos / clan systems currently."
)
raise ClanError(msg)
machine.error(str(out))
msg = f"Failed to inspect {opts.machine}. Address: {host.target}"
out = host.run(config_command, become_root=True, opts=RunOpts(check=False))
if out.returncode != 0:
if "nixos-facter" in out.stderr and "not found" in out.stderr:
machine.error(str(out.stderr))
msg = (
"Please use our custom nixos install images from https://github.com/nix-community/nixos-images/releases/tag/nixos-unstable. "
"nixos-factor only works on nixos / clan systems currently."
)
raise ClanError(msg)
machine.error(str(out))
msg = f"Failed to inspect {opts.machine}. Address: {host.target}"
raise ClanError(msg)
backup_file = None
if hw_file.exists():
backup_file = hw_file.with_suffix(".bak")
@@ -135,11 +171,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.path, opts.machine)
if backup_file:
backup_file.unlink(missing_ok=True)
except ClanCmdError as e:
@@ -160,13 +196,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

@@ -36,6 +36,7 @@ class BuildOn(Enum):
@dataclass
class InstallOptions:
machine: Machine
target_host: str
kexec: str | None = None
debug: bool = False
no_reboot: bool = False
@@ -51,16 +52,17 @@ class InstallOptions:
@API.register
def install_machine(opts: InstallOptions) -> None:
machine = opts.machine
machine.override_target_host = opts.target_host
machine.debug(f"installing {machine.name}")
machine.info(f"installing {machine.name}")
h = machine.target_host
machine.info(f"target host: {h.target}")
generate_facts([machine])
generate_vars([machine])
with (
TemporaryDirectory(prefix="nixos-install-") as _base_directory,
machine.target_host() as host,
):
with TemporaryDirectory(prefix="nixos-install-") as _base_directory:
base_directory = Path(_base_directory).resolve()
activation_secrets = base_directory / "activation_secrets"
upload_dir = activation_secrets / machine.secrets_upload_directory.lstrip("/")
@@ -111,7 +113,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.path, machine.name
)
),
]
)
@@ -128,14 +134,14 @@ def install_machine(opts: InstallOptions) -> None:
if opts.build_on:
cmd += ["--build-on", opts.build_on.value]
if host.port:
cmd += ["--ssh-port", str(host.port)]
if h.port:
cmd += ["--ssh-port", str(h.port)]
if opts.kexec:
cmd += ["--kexec", opts.kexec]
if opts.debug:
cmd.append("--debug")
cmd.append(host.target)
cmd.append(h.target)
if opts.use_tor:
# nix copy does not support tor socks proxy
# cmd.append("--ssh-option")
@@ -158,32 +164,7 @@ def install_machine(opts: InstallOptions) -> None:
def install_command(args: argparse.Namespace) -> None:
host_key_check = HostKeyCheck.from_str(args.host_key_check)
try:
# Only if the caller did not specify a target_host via args.target_host
# Find a suitable target_host that is reachable
target_host = args.target_host
deploy_info: DeployInfo | None = ssh_command_parse(args)
if deploy_info and not args.target_host:
host = find_reachable_host(deploy_info, host_key_check)
if host is None:
use_tor = True
target_host = f"root@{deploy_info.tor}"
else:
target_host = host.target
if args.password:
password = args.password
elif deploy_info and deploy_info.pwd:
password = deploy_info.pwd
else:
password = None
machine = Machine(
name=args.machine,
flake=args.flake,
nix_options=args.option,
override_target_host=target_host,
)
machine = Machine(name=args.machine, flake=args.flake, nix_options=args.option)
use_tor = False
if machine._class_ == "darwin":
@@ -194,16 +175,41 @@ def install_command(args: argparse.Namespace) -> None:
msg = "Could not find clan flake toplevel directory"
raise ClanError(msg)
deploy_info: DeployInfo | None = ssh_command_parse(args)
if args.target_host:
target_host = args.target_host
elif deploy_info:
host = find_reachable_host(deploy_info, host_key_check)
if host is None:
use_tor = True
target_host = f"root@{deploy_info.tor}"
else:
target_host = host.target
password = deploy_info.pwd
else:
target_host = machine.target_host.target
if args.password:
password = args.password
elif deploy_info and deploy_info.pwd:
password = deploy_info.pwd
else:
password = None
if not target_host:
msg = "No target host provided, please provide a target host."
raise ClanError(msg)
if not args.yes:
ask = input(
f"Install {args.machine} to {machine.target_host_address}? [y/N] "
)
ask = input(f"Install {args.machine} to {target_host}? [y/N] ")
if ask != "y":
return None
return install_machine(
InstallOptions(
machine=machine,
target_host=target_host,
kexec=args.kexec,
phases=args.phases,
debug=args.debug,

View File

@@ -2,7 +2,6 @@ import argparse
import json
import logging
import re
import time
from dataclasses import dataclass
from pathlib import Path
from typing import Literal
@@ -12,38 +11,38 @@ from clan_lib.api.disk import MachineDiskMatter
from clan_lib.api.modules import parse_frontmatter
from clan_lib.api.serde import dataclass_to_dict
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_tags
from clan_cli.dirs import specific_machine_dir
from clan_cli.errors import ClanError
from clan_cli.flake import Flake
from clan_cli.errors import ClanCmdError, ClanError
from clan_cli.inventory import (
Machine,
load_inventory_eval,
patch_inventory_with,
)
from clan_cli.inventory.classes import Machine as InventoryMachine
from clan_cli.machines.hardware import HardwareConfig
from clan_cli.machines.machines import Machine
from clan_cli.nix import nix_eval
from clan_cli.nix import nix_eval, nix_shell
from clan_cli.tags import list_nixos_machines_by_tags
log = logging.getLogger(__name__)
@API.register
def set_machine(flake: Flake, machine_name: str, machine: InventoryMachine) -> None:
patch_inventory_with(flake, f"machines.{machine_name}", dataclass_to_dict(machine))
def set_machine(flake_url: Path, machine_name: str, machine: Machine) -> None:
patch_inventory_with(
flake_url, f"machines.{machine_name}", dataclass_to_dict(machine)
)
@API.register
def list_machines(flake: Flake) -> dict[str, InventoryMachine]:
inventory = load_inventory_eval(flake)
def list_inventory_machines(flake_url: str | Path) -> dict[str, Machine]:
inventory = load_inventory_eval(flake_url)
return inventory.get("machines", {})
@dataclass
class MachineDetails:
machine: InventoryMachine
machine: Machine
hw_config: HardwareConfig | None = None
disk_schema: MachineDiskMatter | None = None
@@ -60,16 +59,16 @@ def extract_header(c: str) -> str:
@API.register
def get_machine_details(machine: Machine) -> MachineDetails:
inventory = load_inventory_eval(machine.flake)
machine_inv = inventory.get("machines", {}).get(machine.name)
if machine_inv is None:
msg = f"Machine {machine.name} not found in inventory"
def get_inventory_machine_details(flake_url: Path, machine_name: str) -> MachineDetails:
inventory = load_inventory_eval(flake_url)
machine = inventory.get("machines", {}).get(machine_name)
if machine is None:
msg = f"Machine {machine_name} not found in inventory"
raise ClanError(msg)
hw_config = HardwareConfig.detect_type(machine)
hw_config = HardwareConfig.detect_type(flake_url, machine_name)
machine_dir = specific_machine_dir(machine)
machine_dir = specific_machine_dir(flake_url, machine_name)
disk_schema: MachineDiskMatter | None = None
disk_path = machine_dir / "disko.nix"
if disk_path.exists():
@@ -80,9 +79,7 @@ def get_machine_details(machine: Machine) -> MachineDetails:
if data:
disk_schema = data # type: ignore
return MachineDetails(
machine=machine_inv, hw_config=hw_config, disk_schema=disk_schema
)
return MachineDetails(machine=machine, hw_config=hw_config, disk_schema=disk_schema)
def list_nixos_machines(flake_url: str | Path) -> list[str]:
@@ -95,7 +92,7 @@ def list_nixos_machines(flake_url: str | Path) -> list[str]:
]
)
proc = run(cmd)
proc = run_no_stdout(cmd)
try:
res = proc.stdout.strip()
@@ -109,36 +106,53 @@ def list_nixos_machines(flake_url: str | Path) -> list[str]:
@dataclass
class ConnectionOptions:
keyfile: str | None = None
timeout: int = 2
retries: int = 10
from clan_cli.machines.machines import Machine
@API.register
def check_machine_online(
machine: Machine, opts: ConnectionOptions | None = None
flake_url: str | Path, machine_name: str, opts: ConnectionOptions | None
) -> Literal["Online", "Offline"]:
hostname = machine.target_host_address
if not hostname:
msg = f"Machine {machine.name} does not specify a targetHost"
machine = load_inventory_eval(flake_url).get("machines", {}).get(machine_name)
if not machine:
msg = f"Machine {machine_name} not found in inventory"
raise ClanError(msg)
timeout = opts.timeout if opts and opts.timeout else 2
hostname = machine.get("deploy", {}).get("targetHost")
for _ in range(opts.retries if opts and opts.retries else 10):
with machine.target_host() as target:
res = target.run(
["true"],
RunOpts(timeout=timeout, check=False, needs_user_terminal=True),
)
if not hostname:
msg = f"Machine {machine_name} does not specify a targetHost"
raise ClanError(msg)
if res.returncode == 0:
return "Online"
time.sleep(timeout)
timeout = opts.timeout if opts and opts.timeout else 20
return "Offline"
cmd = nix_shell(
["util-linux", *(["openssh"] if hostname else [])],
[
"ssh",
*(["-i", f"{opts.keyfile}"] if opts and opts.keyfile else []),
# Disable strict host key checking
"-o",
"StrictHostKeyChecking=accept-new",
# Disable known hosts file
"-o",
"UserKnownHostsFile=/dev/null",
"-o",
f"ConnectTimeout={timeout}",
f"{hostname}",
"true",
"&> /dev/null",
],
)
try:
proc = run_no_stdout(cmd, RunOpts(needs_user_terminal=True))
if proc.returncode != 0:
return "Offline"
except ClanCmdError:
return "Offline"
else:
return "Online"
def list_command(args: argparse.Namespace) -> None:

View File

@@ -2,14 +2,12 @@ import importlib
import json
import logging
import re
from collections.abc import Iterator
from contextlib import contextmanager
from dataclasses import dataclass, field
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
@@ -26,7 +24,7 @@ if TYPE_CHECKING:
from clan_cli.vars.generate import Generator
@dataclass(frozen=True)
@dataclass
class Machine:
name: str
flake: Flake
@@ -147,37 +145,34 @@ class Machine:
def flake_dir(self) -> Path:
return self.flake.path
@contextmanager
def target_host(self) -> Iterator[Host]:
with parse_deployment_address(
@property
def target_host(self) -> Host:
return parse_deployment_address(
self.name,
self.target_host_address,
self.host_key_check,
private_key=self.private_key,
meta={"machine": self},
) as target_host:
yield target_host
)
@contextmanager
def build_host(self) -> Iterator[Host | None]:
@property
def build_host(self) -> Host:
"""
The host where the machine is built and deployed from.
Can be the same as the target host.
"""
build_host = self.override_build_host or self.deployment.get("buildHost")
if build_host is None:
yield None
return
return self.target_host
# enable ssh agent forwarding to allow the build host to access the target host
with parse_deployment_address(
return parse_deployment_address(
self.name,
build_host,
self.host_key_check,
forward_agent=True,
private_key=self.private_key,
meta={"machine": self},
) as build_host:
yield build_host
meta={"machine": self, "target_host": self.target_host},
)
@cached_property
def deploy_as_root(self) -> bool:
@@ -188,7 +183,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

@@ -5,12 +5,11 @@ import os
import re
import shlex
import sys
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,
@@ -21,13 +20,14 @@ from clan_cli.facts.generate import generate_facts
from clan_cli.facts.upload import upload_secrets
from clan_cli.flake import Flake
from clan_cli.inventory import Machine as InventoryMachine
from clan_cli.machines.list import list_machines
from clan_cli.machines.machines import Machine
from clan_cli.nix import nix_command, nix_config, nix_metadata
from clan_cli.ssh.host import Host, HostKeyCheck
from clan_cli.vars.generate import generate_vars
from clan_cli.vars.upload import upload_secret_vars
from .inventory import get_all_machines, get_selected_machines
log = logging.getLogger(__name__)
@@ -43,7 +43,8 @@ def is_local_input(node: dict[str, dict[str, str]]) -> bool:
)
def upload_sources(machine: Machine, host: Host) -> str:
def upload_sources(machine: Machine) -> str:
host = machine.build_host
env = host.nix_ssh_env(os.environ.copy())
flake_url = (
@@ -110,41 +111,37 @@ def update_machines(base_path: str, machines: list[InventoryMachine]) -> None:
flake = Flake(base_path)
for machine in machines:
name = machine.get("name")
# prefer target host set via inventory, but fallback to the one set in the machine
target_host = machine.get("deploy", {}).get("targetHost")
if not name:
msg = "Machine name is not set"
raise ClanError(msg)
m = Machine(
name,
flake=flake,
override_target_host=target_host,
)
# prefer target host set via inventory, but fallback to the one set in the machine
if target_host := machine.get("deploy", {}).get("targetHost"):
m.override_target_host = target_host
group_machines.append(m)
deploy_machines(group_machines)
def deploy_machine(machine: Machine) -> None:
with ExitStack() as stack:
target_host = stack.enter_context(machine.target_host())
build_host = stack.enter_context(machine.build_host())
if machine._class_ == "darwin":
if not machine.deploy_as_root and target_host.user == "root":
msg = f"'targetHost' should be set to a non-root user for deploying to nix-darwin on machine '{machine.name}'"
raise ClanError(msg)
host = build_host or target_host
def deploy_machines(machines: list[Machine]) -> None:
"""
Deploy to all hosts in parallel
"""
def deploy(machine: Machine) -> None:
host = machine.build_host
generate_facts([machine], service=None, regenerate=False)
generate_vars([machine], generator_name=None, regenerate=False)
upload_secrets(machine)
upload_secret_vars(machine, target_host)
upload_secret_vars(machine)
path = upload_sources(machine, host)
path = upload_sources(
machine=machine,
)
nix_options = [
"--show-trace",
@@ -169,7 +166,8 @@ def deploy_machine(machine: Machine) -> None:
"",
]
if build_host:
target_host: Host | None = host.meta.get("target_host")
if target_host:
become_root = False
nix_options += ["--target-host", target_host.target]
@@ -179,19 +177,19 @@ def deploy_machine(machine: Machine) -> None:
switch_cmd = [f"{machine._class_}-rebuild", "switch", *nix_options]
test_cmd = [f"{machine._class_}-rebuild", "test", *nix_options]
remote_env = host.nix_ssh_env(None, local_ssh=False)
env = host.nix_ssh_env(None)
ret = host.run(
switch_cmd,
RunOpts(
check=False,
log=Log.BOTH,
msg_color=MsgColor(stderr=AnsiColor.DEFAULT),
needs_user_terminal=True,
),
extra_env=remote_env,
RunOpts(check=False, msg_color=MsgColor(stderr=AnsiColor.DEFAULT)),
extra_env=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,27 +204,26 @@ 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,
),
extra_env=remote_env,
extra_env=env,
become_root=become_root,
)
def deploy_machines(machines: list[Machine]) -> None:
"""
Deploy to all hosts in parallel
"""
with AsyncRuntime() as runtime:
for machine in machines:
if machine._class_ == "darwin":
if not machine.deploy_as_root and machine.target_host.user == "root":
msg = f"'targetHost' should be set to a non-root user for deploying to nix-darwin on machine '{machine.name}'"
raise ClanError(msg)
machine.info(f"Updating {machine.name}")
runtime.async_run(
AsyncOpts(
tid=machine.name, async_ctx=AsyncContext(prefix=machine.name)
),
deploy_machine,
deploy,
machine,
)
runtime.join_all()
@@ -238,73 +235,61 @@ def update_command(args: argparse.Namespace) -> None:
if args.flake is None:
msg = "Could not find clan flake toplevel directory"
raise ClanError(msg)
machines: list[Machine] = []
# if no machines are passed, we will update all machines
selected_machines = (
args.machines if args.machines else list_machines(args.flake).keys()
)
if args.target_host is not None and len(args.machines) > 1:
msg = "Target Host can only be set for one machines"
raise ClanError(msg)
for machine_name in selected_machines:
machines = []
if len(args.machines) == 1 and args.target_host is not None:
machine = Machine(
name=machine_name,
flake=args.flake,
nix_options=args.option,
override_target_host=args.target_host,
override_build_host=args.build_host,
host_key_check=HostKeyCheck.from_str(args.host_key_check),
name=args.machines[0], flake=args.flake, nix_options=args.option
)
machine.override_target_host = args.target_host
machine.override_build_host = args.build_host
machine.host_key_check = HostKeyCheck.from_str(args.host_key_check)
machines.append(machine)
def filter_machine(m: Machine) -> bool:
if m.deployment.get("requireExplicitUpdate", False):
return False
elif args.target_host is not None:
print("target host can only be specified for a single machine")
exit(1)
else:
if len(args.machines) == 0:
ignored_machines = []
for machine in get_all_machines(args.flake, args.option):
if machine.deployment.get("requireExplicitUpdate", False):
continue
try:
machine.build_host # noqa: B018
except ClanError: # check if we have a build host set
ignored_machines.append(machine)
continue
machine.host_key_check = HostKeyCheck.from_str(args.host_key_check)
machine.override_build_host = args.build_host
machines.append(machine)
try:
# check if the machine has a target host set
m.target_host # noqa: B018
except ClanError:
return False
if not machines and ignored_machines != []:
print(
"WARNING: No machines to update."
"The following defined machines were ignored because they"
"do not have the `clan.core.networking.targetHost` nixos option set:",
file=sys.stderr,
)
for machine in ignored_machines:
print(machine, file=sys.stderr)
return True
else:
machines = get_selected_machines(args.flake, args.option, args.machines)
for machine in machines:
machine.override_build_host = args.build_host
machine.host_key_check = HostKeyCheck.from_str(args.host_key_check)
machines_to_update = machines
implicit_all: bool = len(args.machines) == 0
if implicit_all:
machines_to_update = list(filter(filter_machine, machines))
# machines that are in the list but not included in the update list
ignored_machines = {m.name for m in machines if m not in machines_to_update}
if not machines_to_update and ignored_machines:
print(
"WARNING: No machines to update.\n"
"The following defined machines were ignored because they\n"
"- Require explicit update (see 'requireExplicitUpdate')\n",
"- Might not have the `clan.core.networking.targetHost` nixos option set:\n",
file=sys.stderr,
)
for m in ignored_machines:
print(m, file=sys.stderr)
if machines_to_update:
# Prepopulate the cache
config = nix_config()
system = config["system"]
machine_names = [machine.name for machine in machines_to_update]
args.flake.precache(
[
f"clanInternals.machines.{system}.{{{','.join(machine_names)}}}.config.clan.core.vars.generators.*.validationHash",
f"clanInternals.machines.{system}.{{{','.join(machine_names)}}}.config.system.clan.deployment.file",
]
)
# Run the deplyoyment
deploy_machines(machines_to_update)
config = nix_config()
system = config["system"]
machine_names = [machine.name for machine in machines]
args.flake.precache(
[
f"clanInternals.machines.{system}.{{{','.join(machine_names)}}}.config.clan.core.vars.generators.*.validationHash",
f"clanInternals.machines.{system}.{{{','.join(machine_names)}}}.config.system.clan.deployment.file",
]
)
deploy_machines(machines)
except KeyboardInterrupt:
log.warning("Interrupted by user")
sys.exit(1)

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

@@ -5,11 +5,11 @@ import os
import shlex
import socket
import subprocess
import types
import errno
import stat
from dataclasses import dataclass, field
from pathlib import Path
from shlex import quote
from tempfile import TemporaryDirectory
from typing import Any
from clan_cli.cmd import CmdOut, RunOpts, run
@@ -40,29 +40,35 @@ class Host:
ssh_options: dict[str, str] = field(default_factory=dict)
tor_socks: bool = False
_temp_dir: TemporaryDirectory | None = None
def __enter__(self) -> "Host":
self._temp_dir = TemporaryDirectory(prefix="clan-ssh-")
return self
def __exit__(
self,
exc_type: type[BaseException] | None,
exc_value: BaseException | None,
traceback: types.TracebackType | None,
) -> None:
def setup_control_master(self) -> None:
home = Path.home()
if not home.exists():
return
control_path = home / ".ssh"
try:
if self._temp_dir:
self._temp_dir.cleanup()
except OSError:
pass
if not stat.S_ISDIR(control_path.stat().st_mode):
return
except OSError as e:
if e.errno == errno.ENOENT:
try:
control_path.mkdir(exist_ok=True)
except OSError:
return
else:
return
self.ssh_options["ControlMaster"] = "auto"
# Can we make this a temporary directory?
self.ssh_options["ControlPath"] = str(control_path / "clan-%h-%p-%r")
# We use a short ttl because we want to mainly re-use the connection during the cli run
self.ssh_options["ControlPersist"] = "1m"
def __post_init__(self) -> None:
if not self.command_prefix:
self.command_prefix = self.host
if not self.user:
self.user = "root"
self.setup_control_master()
def __str__(self) -> str:
return self.target
@@ -182,17 +188,15 @@ class Host:
# Run the ssh command
return run(ssh_cmd, opts)
def nix_ssh_env(
self, env: dict[str, str] | None, local_ssh: bool = True
) -> dict[str, str]:
def nix_ssh_env(self, env: dict[str, str] | None) -> dict[str, str]:
if env is None:
env = {}
env["NIX_SSHOPTS"] = " ".join(self.ssh_cmd_opts(local_ssh=local_ssh))
env["NIX_SSHOPTS"] = " ".join(self.ssh_cmd_opts)
return env
@property
def ssh_cmd_opts(
self,
local_ssh: bool = True,
) -> list[str]:
ssh_opts = ["-A"] if self.forward_agent else []
if self.port:
@@ -206,16 +210,6 @@ class Host:
if self.private_key:
ssh_opts.extend(["-i", str(self.private_key)])
if local_ssh and self._temp_dir:
ssh_opts.extend(["-o", "ControlPersist=30m"])
ssh_opts.extend(
[
"-o",
f"ControlPath={Path(self._temp_dir.name) / 'clan-%h-%p-%r'}",
]
)
ssh_opts.extend(["-o", "ControlMaster=auto"])
return ssh_opts
def ssh_cmd(
@@ -233,7 +227,7 @@ class Host:
self.password,
]
ssh_opts = self.ssh_cmd_opts()
ssh_opts = self.ssh_cmd_opts
if verbose_ssh or self.verbose_ssh:
ssh_opts.extend(["-v"])
if tty:

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

@@ -27,14 +27,6 @@ def test_root() -> Path:
return TEST_ROOT
@pytest.fixture(scope="session")
def test_lib_root() -> Path:
"""
Root directory of the clan-lib tests
"""
return PROJECT_ROOT.parent / "clan_lib" / "tests"
@pytest.fixture(scope="session")
def clan_core() -> Path:
"""

View File

@@ -1,5 +1,4 @@
import pytest
from clan_cli.flake import Flake
from clan_cli.inventory import load_inventory_json
from clan_cli.secrets.folders import sops_machines_folder
from clan_cli.tests import fixtures_flakes
@@ -25,7 +24,7 @@ def test_machine_subcommands(
]
)
inventory: dict = dict(load_inventory_json(Flake(str(test_flake_with_core.path))))
inventory: dict = dict(load_inventory_json(str(test_flake_with_core.path)))
assert "machine1" in inventory["machines"]
assert "service" not in inventory
@@ -41,7 +40,7 @@ def test_machine_subcommands(
["machines", "delete", "--flake", str(test_flake_with_core.path), "machine1"]
)
inventory_2: dict = dict(load_inventory_json(Flake(str(test_flake_with_core.path))))
inventory_2: dict = dict(load_inventory_json(str(test_flake_with_core.path)))
assert "machine1" not in inventory_2["machines"]
assert "service" not in inventory_2

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
@@ -86,7 +88,7 @@ def test_add_module_to_inventory(
}
}
set_inventory(inventory, Flake(str(base_path)), "Add borgbackup service")
set_inventory(inventory, base_path, "Add borgbackup service")
# cmd = ["facts", "generate", "--flake", str(test_flake_with_core.path), "machine1"]
cmd = [
@@ -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

@@ -7,7 +7,6 @@ from typing import TYPE_CHECKING
from clan_cli.errors import ClanError
from clan_cli.machines import machines
from clan_cli.ssh.host import Host
if TYPE_CHECKING:
from .generate import Generator, Var
@@ -184,5 +183,5 @@ class StoreBase(ABC):
pass
@abstractmethod
def upload(self, host: Host, phases: list[str]) -> None:
def upload(self, phases: list[str]) -> None:
pass

View File

@@ -1,7 +1,6 @@
import argparse
import logging
import os
import shutil
import sys
from dataclasses import dataclass, field
from functools import cached_property
@@ -87,11 +86,6 @@ class Generator:
def bubblewrap_cmd(generator: str, tmpdir: Path) -> list[str]:
test_store = nix_test_store()
real_bash_path = Path("bash")
if os.environ.get("IN_NIX_SANDBOX"):
bash_executable_path = Path(str(shutil.which("bash")))
real_bash_path = bash_executable_path.resolve()
# fmt: off
return nix_shell(
[
@@ -103,7 +97,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
@@ -116,8 +109,8 @@ def bubblewrap_cmd(generator: str, tmpdir: Path) -> list[str]:
"--uid", "1000",
"--gid", "1000",
"--",
str(real_bash_path), "-c", generator
]
"bash", "-c", generator
],
)
# fmt: on
@@ -295,28 +288,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 +304,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 +319,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 +326,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 +344,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
@@ -390,20 +353,19 @@ def _generate_vars_for_machine(
@API.register
def generate_vars_for_machine(
machine_name: str,
generators: list[str],
generators: list[Generator],
all_prompt_values: dict[str, dict[str, str]],
base_dir: Path,
no_sandbox: bool = False,
) -> bool:
from clan_cli.machines.machines import Machine
machine = Machine(name=machine_name, flake=Flake(str(base_dir)))
generators_set = set(generators)
generators_ = [g for g in machine.vars_generators if g.name in generators_set]
return _generate_vars_for_machine(
machine=machine,
generators=generators_,
machine=Machine(
name=machine_name,
flake=Flake(str(base_dir)),
),
generators=generators,
all_prompt_values=all_prompt_values,
no_sandbox=no_sandbox,
)

View File

@@ -4,7 +4,6 @@ from pathlib import Path
from clan_cli.errors import ClanError
from clan_cli.machines.machines import Machine
from clan_cli.ssh.host import Host
from clan_cli.vars._types import StoreBase
from clan_cli.vars.generate import Generator, Var
@@ -73,6 +72,6 @@ class FactStore(StoreBase):
msg = "populate_dir is not implemented for public vars stores"
raise NotImplementedError(msg)
def upload(self, host: Host, phases: list[str]) -> None:
def upload(self, phases: list[str]) -> None:
msg = "upload is not implemented for public vars stores"
raise NotImplementedError(msg)

View File

@@ -6,7 +6,6 @@ from pathlib import Path
from clan_cli.dirs import vm_state_dir
from clan_cli.errors import ClanError
from clan_cli.machines.machines import Machine
from clan_cli.ssh.host import Host
from clan_cli.vars._types import StoreBase
from clan_cli.vars.generate import Generator, Var
@@ -70,6 +69,6 @@ class FactStore(StoreBase):
msg = "populate_dir is not implemented for public vars stores"
raise NotImplementedError(msg)
def upload(self, host: Host, phases: list[str]) -> None:
def upload(self, phases: list[str]) -> None:
msg = "upload is not implemented for public vars stores"
raise NotImplementedError(msg)

View File

@@ -3,7 +3,6 @@ import tempfile
from pathlib import Path
from clan_cli.machines.machines import Machine
from clan_cli.ssh.host import Host
from clan_cli.vars._types import StoreBase
from clan_cli.vars.generate import Generator, Var
@@ -46,6 +45,6 @@ class SecretStore(StoreBase):
shutil.copytree(self.dir, output_dir)
shutil.rmtree(self.dir)
def upload(self, host: Host, phases: list[str]) -> None:
def upload(self, phases: list[str]) -> None:
msg = "Cannot upload secrets with FS backend"
raise NotImplementedError(msg)

View File

@@ -10,7 +10,6 @@ from tempfile import TemporaryDirectory
from clan_cli.cmd import CmdOut, Log, RunOpts, run
from clan_cli.machines.machines import Machine
from clan_cli.nix import nix_shell
from clan_cli.ssh.host import Host
from clan_cli.ssh.upload import upload
from clan_cli.vars._types import StoreBase
from clan_cli.vars.generate import Generator, Var
@@ -147,9 +146,9 @@ class SecretStore(StoreBase):
manifest += hashes
return b"\n".join(manifest)
def needs_upload(self, host: Host) -> bool:
def needs_upload(self) -> bool:
local_hash = self.generate_hash()
remote_hash = host.run(
remote_hash = self.machine.target_host.run(
# TODO get the path to the secrets from the machine
[
"cat",
@@ -225,11 +224,11 @@ class SecretStore(StoreBase):
(output_dir / f".{self._store_backend}_info").write_bytes(self.generate_hash())
def upload(self, host: Host, phases: list[str]) -> None:
def upload(self, phases: list[str]) -> None:
if "partitioning" in phases:
msg = "Cannot upload partitioning secrets"
raise NotImplementedError(msg)
if not self.needs_upload(host):
if not self.needs_upload():
log.info("Secrets already uploaded")
return
with TemporaryDirectory(prefix="vars-upload-") as _tempdir:
@@ -238,4 +237,4 @@ class SecretStore(StoreBase):
upload_dir = Path(
self.machine.deployment["password-store"]["secretLocation"]
)
upload(host, pass_dir, upload_dir)
upload(self.machine.target_host, pass_dir, upload_dir)

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