Compare commits
38 Commits
update-tem
...
ke-disko-s
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
dc915387d9 | ||
|
|
a890b586b4 | ||
|
|
81da1e8b1d | ||
|
|
74c5f71fd7 | ||
|
|
c3f26b3728 | ||
|
|
2b3c5b0524 | ||
|
|
6918a6f1e3 | ||
|
|
71f8948a17 | ||
|
|
44d2a6485e | ||
|
|
2e82109688 | ||
|
|
c3a2891929 | ||
|
|
2e2156bc86 | ||
|
|
802ef94798 | ||
|
|
2ce4f8bf37 | ||
|
|
24d82776e7 | ||
|
|
ff41903e47 | ||
|
|
31e3a37da4 | ||
|
|
690072e29e | ||
|
|
d39fc575c6 | ||
|
|
6019efe40a | ||
|
|
02d35395a8 | ||
|
|
e90ea62ab7 | ||
|
|
5bf1f06244 | ||
|
|
1403f47b0d | ||
|
|
743aa712f5 | ||
|
|
9800e50ce1 | ||
|
|
ef0b61ccd6 | ||
|
|
0d5dbb0fc5 | ||
|
|
9a647907e9 | ||
|
|
5469ab0ae0 | ||
|
|
504533cf5a | ||
|
|
e9f21a01e9 | ||
|
|
f81089930e | ||
|
|
84a7dc7697 | ||
|
|
0d851580e1 | ||
|
|
be384420d5 | ||
|
|
5ebf5b6189 | ||
|
|
d7b476a311 |
@@ -1,89 +0,0 @@
|
|||||||
{
|
|
||||||
pkgs,
|
|
||||||
nixosLib,
|
|
||||||
clan-core,
|
|
||||||
lib,
|
|
||||||
...
|
|
||||||
}:
|
|
||||||
let
|
|
||||||
machines = [
|
|
||||||
"admin"
|
|
||||||
"peer"
|
|
||||||
"signer"
|
|
||||||
];
|
|
||||||
in
|
|
||||||
nixosLib.runTest (
|
|
||||||
{ ... }:
|
|
||||||
{
|
|
||||||
imports = [
|
|
||||||
clan-core.modules.nixosTest.clanTest
|
|
||||||
];
|
|
||||||
|
|
||||||
hostPkgs = pkgs;
|
|
||||||
name = "service-data-mesher";
|
|
||||||
|
|
||||||
clan = {
|
|
||||||
directory = ./.;
|
|
||||||
inventory = {
|
|
||||||
machines = lib.genAttrs machines (_: { });
|
|
||||||
services = {
|
|
||||||
data-mesher.default = {
|
|
||||||
roles.peer.machines = [ "peer" ];
|
|
||||||
roles.admin.machines = [ "admin" ];
|
|
||||||
roles.signer.machines = [ "signer" ];
|
|
||||||
};
|
|
||||||
};
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
defaults =
|
|
||||||
{ config, ... }:
|
|
||||||
{
|
|
||||||
environment.systemPackages = [
|
|
||||||
config.services.data-mesher.package
|
|
||||||
];
|
|
||||||
|
|
||||||
clan.data-mesher.network.interface = "eth1";
|
|
||||||
clan.data-mesher.bootstrapNodes = [
|
|
||||||
"[2001:db8:1::1]:7946" # peer1
|
|
||||||
"[2001:db8:1::2]:7946" # peer2
|
|
||||||
];
|
|
||||||
|
|
||||||
# speed up for testing
|
|
||||||
services.data-mesher.settings = {
|
|
||||||
cluster.join_interval = lib.mkForce "2s";
|
|
||||||
cluster.push_pull_interval = lib.mkForce "5s";
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
nodes = {
|
|
||||||
admin.clan.data-mesher.network.tld = "foo";
|
|
||||||
};
|
|
||||||
|
|
||||||
# TODO Add better test script.
|
|
||||||
testScript = ''
|
|
||||||
|
|
||||||
def resolve(node, success = {}, fail = [], timeout = 60):
|
|
||||||
for hostname, ips in success.items():
|
|
||||||
for ip in ips:
|
|
||||||
node.wait_until_succeeds(f"getent ahosts {hostname} | grep {ip}", timeout)
|
|
||||||
|
|
||||||
for hostname in fail:
|
|
||||||
node.wait_until_fails(f"getent ahosts {hostname}")
|
|
||||||
|
|
||||||
start_all()
|
|
||||||
|
|
||||||
admin.wait_for_unit("data-mesher")
|
|
||||||
signer.wait_for_unit("data-mesher")
|
|
||||||
peer.wait_for_unit("data-mesher")
|
|
||||||
|
|
||||||
# check dns resolution
|
|
||||||
for node in [admin, signer, peer]:
|
|
||||||
resolve(node, {
|
|
||||||
"admin.foo": ["2001:db8:1::1", "192.168.1.1"],
|
|
||||||
"peer.foo": ["2001:db8:1::2", "192.168.1.2"],
|
|
||||||
"signer.foo": ["2001:db8:1::3", "192.168.1.3"]
|
|
||||||
})
|
|
||||||
'';
|
|
||||||
}
|
|
||||||
)
|
|
||||||
@@ -1,4 +0,0 @@
|
|||||||
{
|
|
||||||
"publickey": "age10zxkj45fah3qa8uyg3a36jsd06d839xfq64nrez9etrsf4km0gtsp45gsz",
|
|
||||||
"type": "age"
|
|
||||||
}
|
|
||||||
@@ -1,4 +0,0 @@
|
|||||||
{
|
|
||||||
"publickey": "age1faqrml2ukc6unfm75d3v2vnaf62v92rdxaagg3ty3cfna7vt99gqlzs43l",
|
|
||||||
"type": "age"
|
|
||||||
}
|
|
||||||
@@ -1,4 +0,0 @@
|
|||||||
{
|
|
||||||
"publickey": "age153mke8v2qksyqjc7vta7wglzdqr5epazt83nch0ur5v7kl87cfdsr07qld",
|
|
||||||
"type": "age"
|
|
||||||
}
|
|
||||||
@@ -1,20 +0,0 @@
|
|||||||
{
|
|
||||||
"data": "ENC[AES256_GCM,data:7xyb6WoaN7uRWEO8QRkBw7iytP5hFrA94VRi+sy/UhzqT9AyDPmxB/F8ASFsBbzJUwi0Oqd2E1CeIYRoDhG7JHnDyL2bYonz2RQ=,iv:slh3x774m6oTHAXFwcen1qF+jEchOKCyNsJMbNhqXHE=,tag:wtK8H8PZCESPA1vZCd7Ptw==,type:str]",
|
|
||||||
"sops": {
|
|
||||||
"kms": null,
|
|
||||||
"gcp_kms": null,
|
|
||||||
"azure_kv": null,
|
|
||||||
"hc_vault": null,
|
|
||||||
"age": [
|
|
||||||
{
|
|
||||||
"recipient": "age1qm0p4vf9jvcnn43s6l4prk8zn6cx0ep9gzvevxecv729xz540v8qa742eg",
|
|
||||||
"enc": "-----BEGIN AGE ENCRYPTED FILE-----\nYWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSBPTzZ4RTVNb2I1MTBRMEcy\neU1Eek9GakkydEJBVm9kR3AyY1pEYkorNUYwCkh2WHhNQmc1eWI2cCtEUFFWdzJq\nS0FvQWtoOFkzRVBxVzhuczc0aVprbkkKLS0tIFRLdmpnbzY1Uk9LdklEWnQzZHM2\nVEx3dzhMSnMwaWE0V0J6VTZ5ZVFYMjgKdaICa/hprHxhH89XD7ri0vyTT4rM+Si0\niHcQU4x64dgoJa4gKxgr4k9XncjoNEjJhxL7i/ZNZ5deaaLRn5rKMg==\n-----END AGE ENCRYPTED FILE-----\n"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"lastmodified": "2025-04-08T13:24:55Z",
|
|
||||||
"mac": "ENC[AES256_GCM,data:TJWDHGSRBfOCW8Q+t3YxG3vlpf9a5u7B27AamnOk95huqIv0htqWV3RuV7NoOZ5v2ijqSe/pLfpwrmtdhO2sUBEvhdhJm8UzLShP7AbH9lxV+icJOsY7VSrp+R5W526V46ONP6p47b7fOQBbp03BMz01G191N68WYOf6k2arGxU=,iv:nEyTBwJ2EA+OAl8Ulo5cvFX6Ow2FwzTWooF/rdkPiXg=,tag:oYcG16zR+Fb5XzVsHhq2Qw==,type:str]",
|
|
||||||
"pgp": null,
|
|
||||||
"unencrypted_suffix": "_unencrypted",
|
|
||||||
"version": "3.9.4"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,20 +0,0 @@
|
|||||||
{
|
|
||||||
"data": "ENC[AES256_GCM,data:JOOhvl0clDD/b5YO45CXR3wVopBSNe9dYBG+p5iD+nniN2OgOwBgYPNSCVtc+NemqutD12hFUSfCzXidkv0ijhD1JZeLar9Ygxc=,iv:XctQwSYSvKhDRk/XMacC9uMydZ8e9hnhpoWTgyXiFI0=,tag:foAhBlg4DwpQU2G9DzTo5g==,type:str]",
|
|
||||||
"sops": {
|
|
||||||
"kms": null,
|
|
||||||
"gcp_kms": null,
|
|
||||||
"azure_kv": null,
|
|
||||||
"hc_vault": null,
|
|
||||||
"age": [
|
|
||||||
{
|
|
||||||
"recipient": "age1qm0p4vf9jvcnn43s6l4prk8zn6cx0ep9gzvevxecv729xz540v8qa742eg",
|
|
||||||
"enc": "-----BEGIN AGE ENCRYPTED FILE-----\nYWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSBBVWMvWkp5TnZQcGs5Ykhp\nWC91YkoyZERqdXpxQm5JVmRhaUhueEJETDJVCkM4V0hSYldkV1U2Q0d1TGh3eGNR\nVjJ1VFd6ZEN0SXZjSVEvcnV2WW0vbVUKLS0tIFRCNW9nWHdYaUxLSVVUSXM0OGtN\nVFMzRXExNkYxcFE3QWlxVUM3ay9INm8KV6r8ftpwarly3qXoU9y8KxKrUKLvP9KX\nGsP0pORsaM+qPMsdfEo35CqhAeQu0+6DWd7/67+fUMp6Jr0DthtTmg==\n-----END AGE ENCRYPTED FILE-----\n"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"lastmodified": "2025-04-08T13:25:28Z",
|
|
||||||
"mac": "ENC[AES256_GCM,data:scY9+/fcXhfHEdrsZJLOM6nfjpRaURgTVbCRepUjhUo24B4ByEsAo2B8psVAaGEHEsFRZuoiByqrGzKhyUASmUs+wn+ziOKBTLzu55fOakp8PWYtQ4miiz2TQffp80gCQRJpykcbUgqIKXNSNutt4tosTBL7osXwCEnEQWd+SaA=,iv:1VXNvLP6DUxZYEr1juOLJmZCGbLp33DlwhxHQV9AMD4=,tag:uFM1R8OmkFS74/zkUG0k8A==,type:str]",
|
|
||||||
"pgp": null,
|
|
||||||
"unencrypted_suffix": "_unencrypted",
|
|
||||||
"version": "3.9.4"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,20 +0,0 @@
|
|||||||
{
|
|
||||||
"data": "ENC[AES256_GCM,data:i1YBJdK8XmWnVnZKBpmWggSN8JSOr8pm2Zx+CeE8qqeLZ7xwMO8SYCutM8l94M5vzmmX0CmwzeMZ/JVPbEwFd3ZAImUfh685HOY=,iv:N4rHNaX+WmoPb0EZPqMt+CT1BzaWO9LyoemBxKn+u/s=,tag:PnzSvdGwVnTMK8Do8VzFaQ==,type:str]",
|
|
||||||
"sops": {
|
|
||||||
"kms": null,
|
|
||||||
"gcp_kms": null,
|
|
||||||
"azure_kv": null,
|
|
||||||
"hc_vault": null,
|
|
||||||
"age": [
|
|
||||||
{
|
|
||||||
"recipient": "age1qm0p4vf9jvcnn43s6l4prk8zn6cx0ep9gzvevxecv729xz540v8qa742eg",
|
|
||||||
"enc": "-----BEGIN AGE ENCRYPTED FILE-----\nYWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSB4RXlmcVNGTnlkY2ZqZFlH\nVnh0eHhRNE5hRDNDVkt0TEE0bmRNN2JIVkN3CkxnaGM4Y3M3a0xoK2xMRzBLMHRV\nT1FzKzNRMFZOeWc2K3E5K2FzdUsvWmsKLS0tIENtVlFSWElHN3RtOUY2alhxajhs\naXI1MmR4WC9EVGVFK3dHM1gvVnlZMVUKCyLz0DkdbWfSfccShO1xjWfxhunEIbD0\n6imeIBhZHvVJmZLXnVl7B0pNXo6be7WSBMAUM9gUtCNh4zaChBNwGw==\n-----END AGE ENCRYPTED FILE-----\n"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"lastmodified": "2025-04-08T13:25:52Z",
|
|
||||||
"mac": "ENC[AES256_GCM,data:WFGysoXN95e/RxL094CoL4iueqEcSqCSQZLahwz9HMLi+8HWZIXr55a+jyK7piqR8nBS4BquU5fKhlC6BvEbZFt69t4onTA+LxS3D7A8/TO0CWS0RymUjW9omJUseRQWwAHtE7l0qI5hdOUKhQ+o5pU+2bc3PUlaONM0aOCCoFo=,iv:l1f4aVqLl5VAMfjNxDbxQEQp/qY/nxzgv2GTuPVBoBA=,tag:4PPDCmDrviqdn42RLHQYbA==,type:str]",
|
|
||||||
"pgp": null,
|
|
||||||
"unencrypted_suffix": "_unencrypted",
|
|
||||||
"version": "3.9.4"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,24 +0,0 @@
|
|||||||
{
|
|
||||||
"data": "ENC[AES256_GCM,data:w3bU23Pfe8W89lF+tOmEYPU/A4FkY6n7rgQ6yo+eqCJFxTyHydV6Mg4/g4jaL+4wwIqNYRiMR8J8jLhSvw3Bc59u7Ul+RGwdpiKoBBJfsHjO8r6uOz2u9Raa+iUJH1EJWmGvsQXAILpliZ+klS96VWnGN3pYMEI=,iv:7QbUxta6NPQLZrh6AOcNe+0wkrADuTI9VKVp8q+XoZ8=,tag:ZH0t3RylfQk5U23ZHWaw0g==,type:str]",
|
|
||||||
"sops": {
|
|
||||||
"kms": null,
|
|
||||||
"gcp_kms": null,
|
|
||||||
"azure_kv": null,
|
|
||||||
"hc_vault": null,
|
|
||||||
"age": [
|
|
||||||
{
|
|
||||||
"recipient": "age10zxkj45fah3qa8uyg3a36jsd06d839xfq64nrez9etrsf4km0gtsp45gsz",
|
|
||||||
"enc": "-----BEGIN AGE ENCRYPTED FILE-----\nYWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSBKaTBoSFJVSTdZeW4wZG9p\nWFR1LzVmYS8xWmRqTlNtWFVkSW9jZXpVejJBCkpqZm12L1dDSmNhekVsK1JBOU9r\nZThScGdDakFlRzNsVXp1eE5yOStFSW8KLS0tIFRrTkZBQlRsR2VNcUJvNEkzS2pw\nNksvM296UkFWTkZDVVp1ZVZMNUs4cWsKWTteB1G9Oo38a81PeqKO09NUQetuqosC\nhrToQ6NMo5O7/StmVG228MHbJS3KLXsvh2AFOEPyZrbpB2Opd2wwoA==\n-----END AGE ENCRYPTED FILE-----\n"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"recipient": "age1qm0p4vf9jvcnn43s6l4prk8zn6cx0ep9gzvevxecv729xz540v8qa742eg",
|
|
||||||
"enc": "-----BEGIN AGE ENCRYPTED FILE-----\nYWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSB6U2FWRThRNkVQdk9yZ0VE\nM09iSVhmeldMcDZVaFRDNGtjWTdBa0VIT2pJCkdtd04xSXdicDY3OHI1WXl5TndB\nemtQeW1SS2tVVllPUHhLUTRla3haZGMKLS0tIGN0NVNEN3RKeWM0azBBMnBpQU4r\nTFFzQ0lOcGt0ek9UZmZZRjhibTNTc0EKReUwYBVM1NKX0FD/ZeokFAAknwju5Azq\nGzl4UVJBi5Es0GWORdCGElPXMd7jMud1SwgY04AdZj/dzinCSW4CZw==\n-----END AGE ENCRYPTED FILE-----\n"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"lastmodified": "2025-04-08T13:25:10Z",
|
|
||||||
"mac": "ENC[AES256_GCM,data:0vl9Gt4QeH+GJcnl8FuWSaqQXC8S6Pe50NmeDg5Nl2NWagz8aLCvOFyTqX/Icp/bTi1XQ5icHHhF3YhM+QAvdUL3aO0WGbh92dPRnFuvlZsdtwCFhT+LyHyYHFf6yP+0h/uFpJv9fE6xY22CezA6ZVQ8ywi1epaC548Gr27uVe4=,iv:G4hZVCLkIpbg9uwB7Y8xtHLdnlmBvFrPjxSoqdyHNvM=,tag:uvKwakhUY2aa7v0tmR/o8A==,type:str]",
|
|
||||||
"pgp": null,
|
|
||||||
"unencrypted_suffix": "_unencrypted",
|
|
||||||
"version": "3.9.4"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,3 +0,0 @@
|
|||||||
-----BEGIN PUBLIC KEY-----
|
|
||||||
MCowBQYDK2VwAyEAm204bpSFi4jOjZuXDpIZ/rcJBrbG4zAc7OSA4rAVSYE=
|
|
||||||
-----END PUBLIC KEY-----
|
|
||||||
@@ -1,24 +0,0 @@
|
|||||||
{
|
|
||||||
"data": "ENC[AES256_GCM,data:kERPY40pyvke0mRBnafa4zOaF46rbueRbhpUCXjYP5ORpC7zoOhbdlVBhOsPqE2vfEP4RWkH+ZPdDYXOKXwotBCmlq2i7TfZeoNXFkzWXc3GyM5mndnjCc8hvYEQF1w6xkkVSUt4n06BAw/gT0ppz+vo5dExIA8=,iv:JmYD2o4DGqds6DV7ucUmUD0BRB61exbRsNAtINOR8cQ=,tag:Z58gVnHD+4s21Z84IRw+Vw==,type:str]",
|
|
||||||
"sops": {
|
|
||||||
"kms": null,
|
|
||||||
"gcp_kms": null,
|
|
||||||
"azure_kv": null,
|
|
||||||
"hc_vault": null,
|
|
||||||
"age": [
|
|
||||||
{
|
|
||||||
"recipient": "age1faqrml2ukc6unfm75d3v2vnaf62v92rdxaagg3ty3cfna7vt99gqlzs43l",
|
|
||||||
"enc": "-----BEGIN AGE ENCRYPTED FILE-----\nYWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSB4OFluVThBdUJSTmRVTk94\neFZnLytvcnNSdmQvR3ZkT2UvWFVieFV1SUFNCm9jWHlyZXRwaVdFaG9ocnd4S3FU\ndTZ2dklBbkFVL0hVT0Y2L1o5dnUyNG8KLS0tIGFvYlBJR3l2b3F6OU9uMTFkYjli\nNVFLOWQzOStpU2kzb0xyZUFCMnBmMVUK5Jzssf1XBX25bq0RKlJY8NwtKIytxL/c\nBPPFDZywJiUgw1izsdfGVkRhhSFCQIz+yWIJWzr01NU2jLyFjSfCNw==\n-----END AGE ENCRYPTED FILE-----\n"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"recipient": "age1qm0p4vf9jvcnn43s6l4prk8zn6cx0ep9gzvevxecv729xz540v8qa742eg",
|
|
||||||
"enc": "-----BEGIN AGE ENCRYPTED FILE-----\nYWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSAzYW92c3Q4SktwSnJ1TkRJ\nZEJyZk96cG8ybkpPQzYzVk0xZGs0eCtISVR3CmhDaWxTem1FMjJKNmZNaTkxN01n\nenUvdFI1UkFmL1lzNlM5N0Ixd0dpc1EKLS0tIHpyS2VHaHRRdUovQVgvRmRHaXh3\naFpSNURjTWkxaW9TOXpKL2IvcUFEbmMKq4Ch7DIL34NetFV+xygTdcpQjjmV8v1n\nlvYcjUO/9c3nVkxNMJYGjuxFLuFc4Gw+AyawCjpsIYXRskYRW4UR1w==\n-----END AGE ENCRYPTED FILE-----\n"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"lastmodified": "2025-04-08T13:25:43Z",
|
|
||||||
"mac": "ENC[AES256_GCM,data:YhL2d6i0VpUd15B4ow2BgRpyEm0KEA8NSb7jZcjI58d7d4lAqBMcDQB+8a9e2NZbPk8p1EYl3q4VXbEnuwsJiPZI2kabRusy/IGoHzUTUMFfVaOuUcC0eyINNVSmzJxnCbLCAA1Aj1yXzgRQ0MWr7r0RHMKw0D1e0HxdEsuAPrA=,iv:yPlMmE6+NEEQ9uOZzD3lUTBcfUwGX/Ar+bCu0XKnjIg=,tag:eR22BCFVAlRHdggg9oCeaA==,type:str]",
|
|
||||||
"pgp": null,
|
|
||||||
"unencrypted_suffix": "_unencrypted",
|
|
||||||
"version": "3.9.4"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,3 +0,0 @@
|
|||||||
-----BEGIN PUBLIC KEY-----
|
|
||||||
MCowBQYDK2VwAyEAv5dICFue2fYO0Zi1IyfYjoNfR6713WpISo7+2bSjL18=
|
|
||||||
-----END PUBLIC KEY-----
|
|
||||||
@@ -1,24 +0,0 @@
|
|||||||
{
|
|
||||||
"data": "ENC[AES256_GCM,data:U8F7clQ2Tuj8zy5EoEga/Mc9N3LLZrlFf5m7UJKrP5yybFRCJSBs05hOcNe+LQZdEAvvr0Qbkry1pQyE84gCVbxHvwkD+l3GbguBuLMsW96bHcmstb6AvZyhMDBpm73Azf4lXhNaiB8p2pDWdxV77E+PPw1MNYI=,iv:hQhN6Ak8tB6cXSCnTmmQqHEpXWpWck3uIVCk5pUqFqU=,tag:uC4ljcs92WPlUOfwSkrK9Q==,type:str]",
|
|
||||||
"sops": {
|
|
||||||
"kms": null,
|
|
||||||
"gcp_kms": null,
|
|
||||||
"azure_kv": null,
|
|
||||||
"hc_vault": null,
|
|
||||||
"age": [
|
|
||||||
{
|
|
||||||
"recipient": "age153mke8v2qksyqjc7vta7wglzdqr5epazt83nch0ur5v7kl87cfdsr07qld",
|
|
||||||
"enc": "-----BEGIN AGE ENCRYPTED FILE-----\nYWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSBvV05lejQrdUQvQjZPOG9v\nZ01naXlYZ1JxWHhDT1M1aUs1RWJDSU1acVFFCmdHY094aGRPYWxpdVVxSFVHRU9v\nNnVaeTlpSEdtSWRDMmVMSjdSOEQ4ZlEKLS0tIFo5NVk2bzBxYjZ5ZWpDWTMrQ2VF\nVThWUk0rVXpTY2svSCtiVDhTQ2kvbFkKEM2DBuFtdEj1G/vS1TsyIfQxSFFvPTDq\nCmO7L/J5lHdyfIXzp/FlhdKpjvmchb8gbfJn7IWpKopc7Zimy/JnGQ==\n-----END AGE ENCRYPTED FILE-----\n"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"recipient": "age1qm0p4vf9jvcnn43s6l4prk8zn6cx0ep9gzvevxecv729xz540v8qa742eg",
|
|
||||||
"enc": "-----BEGIN AGE ENCRYPTED FILE-----\nYWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSArNzVUaHkzUzVEMlh1Q3Qr\nOEo0aDJIMG91amJiZG50MEhqblRCTWxRRVVRCk4xZlp4SkJuUHc2UnFyU1prczkz\nNGtlQlRlNnBDRFFvUGhReTh6MTBZaXMKLS0tIGxtaXhUMDM0RU4yQytualdzdTFt\nWGRiVG54MnYrR2lqZVZoT0VkbmV5WUUKbzAnOkn8RYOo7z4RISQ0yN875vSEQMDa\nnnttzVrQuK0/iZvzJ0Zq8U9+JJJKvFB1tHqye6CN0zMbv55CLLnA0g==\n-----END AGE ENCRYPTED FILE-----\n"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"lastmodified": "2025-04-08T13:26:07Z",
|
|
||||||
"mac": "ENC[AES256_GCM,data:uMss4+BiVupFqX7nHnMo+0yZ8RPuFD8VHYK2EtJSqzgurQrZVT4tJwY50mz2gVmwbrm49QYKk5S+H29DU0cM0HiEOgB5P5ObpXTRJPagWQ48CEFrDpBzLplobxulwnN6jJ1dpL3JF3jfrzrnSDFXMvx+n5x/86/AYXYRsi/UeyY=,iv:mPT1svKrNGmYpbL9hh2Bxxakml69q+U6gQ0ZnEcbEyg=,tag:zcZx1lTw/bEsX/1g+6T04g==,type:str]",
|
|
||||||
"pgp": null,
|
|
||||||
"unencrypted_suffix": "_unencrypted",
|
|
||||||
"version": "3.9.4"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,3 +0,0 @@
|
|||||||
-----BEGIN PUBLIC KEY-----
|
|
||||||
MCowBQYDK2VwAyEAeUkW5UIwA1svbNY71ePyJKX68UhxrqIUGQ2jd06w5WM=
|
|
||||||
-----END PUBLIC KEY-----
|
|
||||||
@@ -1,32 +0,0 @@
|
|||||||
{
|
|
||||||
"data": "ENC[AES256_GCM,data:nRlCMF58cnkdUAE2aVHEG1+vAckKtVt48Jr21Bklfbsqe1yTiHPFAMLL1ywgWWWd7FjI/Z8WID9sWzh9J8Vmotw4aJWU/rIQSeF8cJHALvfOxarJIIyb7purAiPoPPs6ggGmSmVFGB1aw8kH1JMcppQN8OItdQM=,iv:qTwaL2mgw6g7heN/H5qcjei3oY+h46PdSe3v2hDlkTs=,tag:jYNULrOPl9mcQTTrx1SDeA==,type:str]",
|
|
||||||
"sops": {
|
|
||||||
"kms": null,
|
|
||||||
"gcp_kms": null,
|
|
||||||
"azure_kv": null,
|
|
||||||
"hc_vault": null,
|
|
||||||
"age": [
|
|
||||||
{
|
|
||||||
"recipient": "age153mke8v2qksyqjc7vta7wglzdqr5epazt83nch0ur5v7kl87cfdsr07qld",
|
|
||||||
"enc": "-----BEGIN AGE ENCRYPTED FILE-----\nYWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSBRcG44cGFBWXk2Z0pmNklv\nTnJ5b0svLytzZmNNRkxCVU1zaDVhNUs2cld3CklsenpWd0g2OEdKKzBMQlNEejRn\nTlEvY01HYjdvVExadnN3aXZIRTZ4YlEKLS0tIGRPUXdNSHZCRDBMbno2MjJqRHBl\nSzdiSURDYitQWFpaSElkdmdicDVjMWsKweQiRqyzXmzabmU2fmgwHtOa9uDmhx9O\ns9NfUhC3ifooQUSeYp58b1ZGJQx5O5bn9q/DaEoit5LTOUprt1pUPA==\n-----END AGE ENCRYPTED FILE-----\n"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"recipient": "age1qm0p4vf9jvcnn43s6l4prk8zn6cx0ep9gzvevxecv729xz540v8qa742eg",
|
|
||||||
"enc": "-----BEGIN AGE ENCRYPTED FILE-----\nYWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSBiTEdlL29sVWFpSDNNaXRJ\ndTJDRkU4VzFPQ0M4MkFha2IxV2FXN2o3ZEFRCjF3UnZ5U1hTc3VvSTIzcWxOZjl0\ncHlLVEFqRk1UbGdxaUxEeDFqbFVYaU0KLS0tIFFyMnJkZnRHdWg4Z1IyRHFkY0I5\nQjdIMGtGLzRGMFM0ektDZ3hzZDdHSmMKvxOQuKgePom0QfPSvn+4vsGHhJ4BoOvW\nc27Vn4/i4hbjfJr4JpULAwyIwt3F0RaTA2M6EkFkY8otEi3vkcpWvA==\n-----END AGE ENCRYPTED FILE-----\n"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"recipient": "age10zxkj45fah3qa8uyg3a36jsd06d839xfq64nrez9etrsf4km0gtsp45gsz",
|
|
||||||
"enc": "-----BEGIN AGE ENCRYPTED FILE-----\nYWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSA5ZzdsaVRnSmsrMGR1Ylg3\nZkpscTdwNUl5NUVXN3kvMU1icE0yZU1WSEJBClB6SlJYZUhDSElRREx5b0VueFUw\nNVFRU3BSU24yWEtpRnJoUC83SDVaUWsKLS0tIGVxNEo3TjlwakpDZlNsSkVCOXlz\nNDgwaE1xNjZkSnJBVlU5YXVHeGxVNFEKsXKyTzq9VsERpXzbFJGv/pbAghFAcXkf\nMmCgQHsfIMBJQUstcO8sAkxv3ced0dAEz8O6NUd0FS2zlhBzt29Rnw==\n-----END AGE ENCRYPTED FILE-----\n"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"recipient": "age1faqrml2ukc6unfm75d3v2vnaf62v92rdxaagg3ty3cfna7vt99gqlzs43l",
|
|
||||||
"enc": "-----BEGIN AGE ENCRYPTED FILE-----\nYWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSBkK1hDMGxCc1IvYXlJMnBF\nWncxaXBQa1RpTWdwUHc3Yk16My8rVHNJc2dFCkNlK2h0dy9oU3Z5ZGhwRWVLYVUz\ncVBKT2x5VnlhbXNmdHkwbmZzVG5sd0EKLS0tIHJaMzhDanF4Rkl3akN4MEIxOHFC\nYWRUZ08xb1UwOFNRaktkMjIzNXZmNkUK1rlbJ96oUNQZLmCmPNDOKxfDMMa+Bl2E\nJPxcNc7XY3WBHa3xFUbcqiPxWxDyaZjhq/LYQGpepiGonGMEzR5JOQ==\n-----END AGE ENCRYPTED FILE-----\n"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"lastmodified": "2025-04-08T13:25:20Z",
|
|
||||||
"mac": "ENC[AES256_GCM,data:za9ku+9lu1TTRjbPcd5LYDM4tJsAYF/yuWFCGkAhqcYguEducsIfoKBwL42ahAzqLjCZp91YJuINtw16mM+Hmlhi/BVwhnXNHqcfnKoAS/zg9KJvWcvXwKMmjEjaBovqaCWXWoKS7dn/wZ7nfGrlsiUilCDkW4BzTIzkqNkyREU=,iv:2X9apXMatwCPRBIRbPxz6PJQwGrlr7O+z+MrsnFq+sQ=,tag:IYvitoV4MhyJyRO1ySxbLQ==,type:str]",
|
|
||||||
"pgp": null,
|
|
||||||
"unencrypted_suffix": "_unencrypted",
|
|
||||||
"version": "3.9.4"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,3 +0,0 @@
|
|||||||
-----BEGIN PUBLIC KEY-----
|
|
||||||
MCowBQYDK2VwAyEA/5j+Js7oxwWvZdfjfEO/3UuRqMxLKXsaNc3/5N2WSaw=
|
|
||||||
-----END PUBLIC KEY-----
|
|
||||||
@@ -94,7 +94,6 @@ in
|
|||||||
|
|
||||||
service-dummy-test = import ./service-dummy-test nixosTestArgs;
|
service-dummy-test = import ./service-dummy-test nixosTestArgs;
|
||||||
service-dummy-test-from-flake = import ./service-dummy-test-from-flake nixosTestArgs;
|
service-dummy-test-from-flake = import ./service-dummy-test-from-flake nixosTestArgs;
|
||||||
service-data-mesher = import ./data-mesher nixosTestArgs;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
packagesToBuild = lib.removeAttrs self'.packages [
|
packagesToBuild = lib.removeAttrs self'.packages [
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
{
|
{
|
||||||
config,
|
|
||||||
pkgs,
|
pkgs,
|
||||||
...
|
...
|
||||||
}:
|
}:
|
||||||
@@ -9,9 +8,14 @@
|
|||||||
config = {
|
config = {
|
||||||
|
|
||||||
warnings = [
|
warnings = [
|
||||||
"The clan.disk-id module is deprecated and will be removed on 2025-07-15.
|
''
|
||||||
Please migrate to user-maintained configuration or the new equivalent clan services
|
The clan.disk-id module is deprecated and will be removed on 2025-07-15.
|
||||||
(https://docs.clan.lol/reference/clanServices)."
|
For migration see: https://docs.clan.lol/guides/migrations/disk-id/
|
||||||
|
|
||||||
|
!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!
|
||||||
|
!!! Please migrate. Otherwise you may not be able to boot your system after that date. !!!
|
||||||
|
!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!
|
||||||
|
''
|
||||||
];
|
];
|
||||||
clan.core.vars.generators.disk-id = {
|
clan.core.vars.generators.disk-id = {
|
||||||
files.diskId.secret = false;
|
files.diskId.secret = false;
|
||||||
|
|||||||
29
clanServices/data-mesher/README.md
Normal file
29
clanServices/data-mesher/README.md
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
This service will set up data-mesher.
|
||||||
|
|
||||||
|
## Usage
|
||||||
|
|
||||||
|
```nix
|
||||||
|
inventory.instances = {
|
||||||
|
data-mesher = {
|
||||||
|
module = {
|
||||||
|
name = "data-mesher";
|
||||||
|
input = "clan-core";
|
||||||
|
};
|
||||||
|
roles.admin.machines.server0 = {
|
||||||
|
settings = {
|
||||||
|
bootstrapNodes = {
|
||||||
|
node1 = "192.168.1.1:7946";
|
||||||
|
node2 = "192.168.1.2:7946";
|
||||||
|
};
|
||||||
|
|
||||||
|
network = {
|
||||||
|
hostTTL = "24h";
|
||||||
|
interface = "tailscale0";
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
roles.peer.machines.server1 = { };
|
||||||
|
roles.signer.machines.server2 = { };
|
||||||
|
};
|
||||||
|
}
|
||||||
|
```
|
||||||
29
clanServices/data-mesher/admin.nix
Normal file
29
clanServices/data-mesher/admin.nix
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
{
|
||||||
|
lib,
|
||||||
|
config,
|
||||||
|
settings,
|
||||||
|
...
|
||||||
|
}:
|
||||||
|
{
|
||||||
|
|
||||||
|
services.data-mesher.initNetwork =
|
||||||
|
let
|
||||||
|
# for a given machine, read it's public key and remove any new lines
|
||||||
|
readHostKey =
|
||||||
|
machine:
|
||||||
|
let
|
||||||
|
path = "${config.clan.core.settings.directory}/vars/per-machine/${machine}/data-mesher-host-key/public_key/value";
|
||||||
|
in
|
||||||
|
builtins.elemAt (lib.splitString "\n" (builtins.readFile path)) 1;
|
||||||
|
in
|
||||||
|
{
|
||||||
|
enable = true;
|
||||||
|
keyPath = config.clan.core.vars.generators.data-mesher-network-key.files.private_key.path;
|
||||||
|
|
||||||
|
tld = settings.network.tld;
|
||||||
|
hostTTL = settings.network.hostTTL;
|
||||||
|
|
||||||
|
# admin and signer host public keys
|
||||||
|
signingKeys = builtins.map readHostKey (builtins.attrNames settings.bootstrapNodes);
|
||||||
|
};
|
||||||
|
}
|
||||||
142
clanServices/data-mesher/default.nix
Normal file
142
clanServices/data-mesher/default.nix
Normal file
@@ -0,0 +1,142 @@
|
|||||||
|
{ ... }:
|
||||||
|
let
|
||||||
|
sharedInterface =
|
||||||
|
{ lib, ... }:
|
||||||
|
{
|
||||||
|
options = {
|
||||||
|
bootstrapNodes = lib.mkOption {
|
||||||
|
type = lib.types.nullOr (lib.types.attrsOf lib.types.str);
|
||||||
|
# the default bootstrap nodes are any machines with the admin or signers role
|
||||||
|
# we iterate through those machines, determining an IP address for them based on their VPN
|
||||||
|
# currently only supports zerotier
|
||||||
|
# default = builtins.foldl' (
|
||||||
|
# urls: name:
|
||||||
|
# let
|
||||||
|
# ipPath = "${config.clan.core.settings.directory}/vars/per-machine/${name}/zerotier/zerotier-ip/value";
|
||||||
|
# in
|
||||||
|
# if builtins.pathExists ipPath then
|
||||||
|
# let
|
||||||
|
# ip = builtins.readFile ipPath;
|
||||||
|
# in
|
||||||
|
# urls ++ [ "[${ip}]:${builtins.toString settings.network.port}" ]
|
||||||
|
# else
|
||||||
|
# urls
|
||||||
|
# ) [ ] (dmLib.machines config).bootstrap;
|
||||||
|
description = ''
|
||||||
|
A list of bootstrap nodes that act as an initial gateway when joining
|
||||||
|
the cluster.
|
||||||
|
'';
|
||||||
|
example = {
|
||||||
|
"node1" = "192.168.1.1:7946";
|
||||||
|
"node2" = "192.168.1.2:7946";
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
network = {
|
||||||
|
interface = lib.mkOption {
|
||||||
|
type = lib.types.str;
|
||||||
|
description = ''
|
||||||
|
The interface over which cluster communication should be performed.
|
||||||
|
All the ip addresses associate with this interface will be part of
|
||||||
|
our host claim, including both ipv4 and ipv6.
|
||||||
|
|
||||||
|
This should be set to an internal/VPN interface.
|
||||||
|
'';
|
||||||
|
example = "tailscale0";
|
||||||
|
};
|
||||||
|
|
||||||
|
port = lib.mkOption {
|
||||||
|
type = lib.types.port;
|
||||||
|
default = 7946;
|
||||||
|
description = ''
|
||||||
|
Port to listen on for cluster communication.
|
||||||
|
'';
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
in
|
||||||
|
{
|
||||||
|
_class = "clan.service";
|
||||||
|
manifest.name = "data-mesher";
|
||||||
|
manifest.description = "Set up data-mesher";
|
||||||
|
manifest.categories = [ "System" ];
|
||||||
|
manifest.readme = builtins.readFile ./README.md;
|
||||||
|
|
||||||
|
roles.admin = {
|
||||||
|
interface =
|
||||||
|
{ lib, ... }:
|
||||||
|
{
|
||||||
|
|
||||||
|
imports = [ sharedInterface ];
|
||||||
|
|
||||||
|
options = {
|
||||||
|
|
||||||
|
network = {
|
||||||
|
tld = lib.mkOption {
|
||||||
|
type = lib.types.str;
|
||||||
|
default = "clan";
|
||||||
|
description = "Top level domain to use for the network";
|
||||||
|
};
|
||||||
|
|
||||||
|
hostTTL = lib.mkOption {
|
||||||
|
type = lib.types.str;
|
||||||
|
default = "${toString (24 * 28)}h";
|
||||||
|
example = "24h";
|
||||||
|
description = "The TTL for hosts in the network, in the form of a Go time.Duration";
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
perInstance =
|
||||||
|
{ settings, roles, ... }:
|
||||||
|
{
|
||||||
|
nixosModule = {
|
||||||
|
imports = [
|
||||||
|
./admin.nix
|
||||||
|
./shared.nix
|
||||||
|
];
|
||||||
|
_module.args = { inherit settings roles; };
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
roles.signer = {
|
||||||
|
interface =
|
||||||
|
{ ... }:
|
||||||
|
{
|
||||||
|
imports = [ sharedInterface ];
|
||||||
|
};
|
||||||
|
perInstance =
|
||||||
|
{ settings, roles, ... }:
|
||||||
|
{
|
||||||
|
nixosModule = {
|
||||||
|
imports = [
|
||||||
|
./signer.nix
|
||||||
|
./shared.nix
|
||||||
|
];
|
||||||
|
_module.args = { inherit settings roles; };
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
roles.peer = {
|
||||||
|
interface =
|
||||||
|
{ ... }:
|
||||||
|
{
|
||||||
|
imports = [ sharedInterface ];
|
||||||
|
};
|
||||||
|
perInstance =
|
||||||
|
{ settings, roles, ... }:
|
||||||
|
{
|
||||||
|
nixosModule = {
|
||||||
|
imports = [
|
||||||
|
./peer.nix
|
||||||
|
./shared.nix
|
||||||
|
];
|
||||||
|
_module.args = { inherit settings roles; };
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
}
|
||||||
17
clanServices/data-mesher/flake-module.nix
Normal file
17
clanServices/data-mesher/flake-module.nix
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
{ lib, ... }:
|
||||||
|
let
|
||||||
|
module = lib.modules.importApply ./default.nix { };
|
||||||
|
in
|
||||||
|
{
|
||||||
|
clan.modules = {
|
||||||
|
data-mesher = module;
|
||||||
|
};
|
||||||
|
perSystem =
|
||||||
|
{ ... }:
|
||||||
|
{
|
||||||
|
clan.nixosTests.service-data-mesher = {
|
||||||
|
imports = [ ./tests/vm/default.nix ];
|
||||||
|
clan.modules."@clan/data-mesher" = module;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
}
|
||||||
2
clanServices/data-mesher/peer.nix
Normal file
2
clanServices/data-mesher/peer.nix
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
{
|
||||||
|
}
|
||||||
86
clanServices/data-mesher/shared.nix
Normal file
86
clanServices/data-mesher/shared.nix
Normal file
@@ -0,0 +1,86 @@
|
|||||||
|
{
|
||||||
|
config,
|
||||||
|
settings,
|
||||||
|
...
|
||||||
|
}:
|
||||||
|
{
|
||||||
|
|
||||||
|
services.data-mesher = {
|
||||||
|
enable = true;
|
||||||
|
openFirewall = true;
|
||||||
|
|
||||||
|
settings = {
|
||||||
|
log_level = "warn";
|
||||||
|
state_dir = "/var/lib/data-mesher";
|
||||||
|
|
||||||
|
# read network id from vars
|
||||||
|
network.id = config.clan.core.vars.generators.data-mesher-network-key.files.public_key.value;
|
||||||
|
|
||||||
|
host = {
|
||||||
|
names = [ config.networking.hostName ];
|
||||||
|
key_path = config.clan.core.vars.generators.data-mesher-host-key.files.private_key.path;
|
||||||
|
};
|
||||||
|
|
||||||
|
cluster = {
|
||||||
|
port = settings.network.port;
|
||||||
|
join_interval = "30s";
|
||||||
|
push_pull_interval = "30s";
|
||||||
|
interface = settings.network.interface;
|
||||||
|
bootstrap_nodes = (builtins.attrValues settings.bootstrapNodes);
|
||||||
|
};
|
||||||
|
|
||||||
|
http.port = 7331;
|
||||||
|
http.interface = "lo";
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
# Generate host key.
|
||||||
|
clan.core.vars.generators.data-mesher-host-key = {
|
||||||
|
files =
|
||||||
|
let
|
||||||
|
owner = config.users.users.data-mesher.name;
|
||||||
|
in
|
||||||
|
{
|
||||||
|
private_key = {
|
||||||
|
inherit owner;
|
||||||
|
};
|
||||||
|
public_key.secret = false;
|
||||||
|
};
|
||||||
|
|
||||||
|
runtimeInputs = [
|
||||||
|
config.services.data-mesher.package
|
||||||
|
];
|
||||||
|
|
||||||
|
script = ''
|
||||||
|
data-mesher generate keypair \
|
||||||
|
--public-key-path "$out"/public_key \
|
||||||
|
--private-key-path "$out"/private_key
|
||||||
|
'';
|
||||||
|
};
|
||||||
|
|
||||||
|
clan.core.vars.generators.data-mesher-network-key = {
|
||||||
|
# generated once per clan
|
||||||
|
share = true;
|
||||||
|
|
||||||
|
files =
|
||||||
|
let
|
||||||
|
owner = config.users.users.data-mesher.name;
|
||||||
|
in
|
||||||
|
{
|
||||||
|
private_key = {
|
||||||
|
inherit owner;
|
||||||
|
};
|
||||||
|
public_key.secret = false;
|
||||||
|
};
|
||||||
|
|
||||||
|
runtimeInputs = [
|
||||||
|
config.services.data-mesher.package
|
||||||
|
];
|
||||||
|
|
||||||
|
script = ''
|
||||||
|
data-mesher generate keypair \
|
||||||
|
--public-key-path "$out"/public_key \
|
||||||
|
--private-key-path "$out"/private_key
|
||||||
|
'';
|
||||||
|
};
|
||||||
|
}
|
||||||
2
clanServices/data-mesher/signer.nix
Normal file
2
clanServices/data-mesher/signer.nix
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
{
|
||||||
|
}
|
||||||
90
clanServices/data-mesher/tests/vm/default.nix
Normal file
90
clanServices/data-mesher/tests/vm/default.nix
Normal file
@@ -0,0 +1,90 @@
|
|||||||
|
{
|
||||||
|
...
|
||||||
|
}:
|
||||||
|
{
|
||||||
|
name = "service-data-mesher";
|
||||||
|
|
||||||
|
clan = {
|
||||||
|
directory = ./.;
|
||||||
|
test.useContainers = true;
|
||||||
|
inventory = {
|
||||||
|
|
||||||
|
machines.peer = { };
|
||||||
|
machines.admin = { };
|
||||||
|
machines.signer = { };
|
||||||
|
|
||||||
|
instances = {
|
||||||
|
data-mesher =
|
||||||
|
let
|
||||||
|
bootstrapNodes = {
|
||||||
|
admin = "[2001:db8:1::1]:7946";
|
||||||
|
peer = "[2001:db8:1::2]:7946";
|
||||||
|
# signer = "2001:db8:1::3:7946";
|
||||||
|
};
|
||||||
|
in
|
||||||
|
{
|
||||||
|
roles.peer.machines.peer.settings = {
|
||||||
|
network.interface = "eth1";
|
||||||
|
inherit bootstrapNodes;
|
||||||
|
};
|
||||||
|
roles.signer.machines.signer.settings = {
|
||||||
|
network.interface = "eth1";
|
||||||
|
inherit bootstrapNodes;
|
||||||
|
};
|
||||||
|
roles.admin.machines.admin.settings = {
|
||||||
|
network.tld = "foo";
|
||||||
|
network.interface = "eth1";
|
||||||
|
inherit bootstrapNodes;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
nodes =
|
||||||
|
let
|
||||||
|
commonConfig =
|
||||||
|
{ lib, config, ... }:
|
||||||
|
{
|
||||||
|
environment.systemPackages = [
|
||||||
|
config.services.data-mesher.package
|
||||||
|
];
|
||||||
|
|
||||||
|
# speed up for testing
|
||||||
|
services.data-mesher.settings = {
|
||||||
|
cluster.join_interval = lib.mkForce "2s";
|
||||||
|
cluster.push_pull_interval = lib.mkForce "5s";
|
||||||
|
};
|
||||||
|
|
||||||
|
};
|
||||||
|
in
|
||||||
|
{
|
||||||
|
peer = commonConfig;
|
||||||
|
admin = commonConfig;
|
||||||
|
signer = commonConfig;
|
||||||
|
};
|
||||||
|
|
||||||
|
testScript = ''
|
||||||
|
def resolve(node, success = {}, fail = [], timeout = 60):
|
||||||
|
for hostname, ips in success.items():
|
||||||
|
for ip in ips:
|
||||||
|
node.wait_until_succeeds(f"getent ahosts {hostname} | grep {ip}", timeout)
|
||||||
|
|
||||||
|
for hostname in fail:
|
||||||
|
node.wait_until_fails(f"getent ahosts {hostname}")
|
||||||
|
|
||||||
|
start_all()
|
||||||
|
|
||||||
|
admin.wait_for_unit("data-mesher")
|
||||||
|
signer.wait_for_unit("data-mesher")
|
||||||
|
peer.wait_for_unit("data-mesher")
|
||||||
|
|
||||||
|
# check dns resolution
|
||||||
|
for node in [admin, signer, peer]:
|
||||||
|
resolve(node, {
|
||||||
|
"admin.foo": ["2001:db8:1::1", "192.168.1.1"],
|
||||||
|
"peer.foo": ["2001:db8:1::2", "192.168.1.2"],
|
||||||
|
"signer.foo": ["2001:db8:1::3", "192.168.1.3"]
|
||||||
|
})
|
||||||
|
'';
|
||||||
|
}
|
||||||
6
clanServices/data-mesher/tests/vm/sops/machines/admin/key.json
Executable file
6
clanServices/data-mesher/tests/vm/sops/machines/admin/key.json
Executable file
@@ -0,0 +1,6 @@
|
|||||||
|
[
|
||||||
|
{
|
||||||
|
"publickey": "age1r99qtxl0v86wg8ndcem87yk5wag5xcsk98ngaumqzww6t7pyms0q5cyl80",
|
||||||
|
"type": "age"
|
||||||
|
}
|
||||||
|
]
|
||||||
6
clanServices/data-mesher/tests/vm/sops/machines/peer/key.json
Executable file
6
clanServices/data-mesher/tests/vm/sops/machines/peer/key.json
Executable file
@@ -0,0 +1,6 @@
|
|||||||
|
[
|
||||||
|
{
|
||||||
|
"publickey": "age1hgjs2yqxhcxfgtvhydnfe5wzlagxw2dw4hu658e8neduy0lkye0skmjfc7",
|
||||||
|
"type": "age"
|
||||||
|
}
|
||||||
|
]
|
||||||
6
clanServices/data-mesher/tests/vm/sops/machines/signer/key.json
Executable file
6
clanServices/data-mesher/tests/vm/sops/machines/signer/key.json
Executable file
@@ -0,0 +1,6 @@
|
|||||||
|
[
|
||||||
|
{
|
||||||
|
"publickey": "age1k6h9mespmnr9zhtwwqlhnla80x5jhpd4c2p7hp0nfanr5tspup0s0rld2f",
|
||||||
|
"type": "age"
|
||||||
|
}
|
||||||
|
]
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
{
|
||||||
|
"data": "ENC[AES256_GCM,data:87WFWukgpTGlH67MTkHxzTosABK/6flJObt+u9UrGSOzBr1lx4V5IsMQ9HAM4jvLpveBNH4hlFDCxbD5666n2oYylGoyBph2vAg=,iv:GKLcU7Xqmb0ImvY7M71NddkOlUDSPa/fcXrXny2iZ1o=,tag:589QMSZeXdmTxRFtMFasZg==,type:str]",
|
||||||
|
"sops": {
|
||||||
|
"age": [
|
||||||
|
{
|
||||||
|
"recipient": "age1qm0p4vf9jvcnn43s6l4prk8zn6cx0ep9gzvevxecv729xz540v8qa742eg",
|
||||||
|
"enc": "-----BEGIN AGE ENCRYPTED FILE-----\nYWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSBFaXlqZEU0eHRZZjBncDE1\nV2hzTGZiVy9rM0NnWjc1NlpHVVZEUFd5S2pJCmo3Nm11bGQyWWt1R2tHS2pOYlpn\nY3lGa0w3UFpDT1RLSDU4cnJ2YVBkSU0KLS0tIEJjZVc1YXJqcHczYSt6WjV3ai93\nakdPd3VHWkVnWkdhNCtZakp4VXhBUG8Kg3xd9w5oW3/q+s59LkDy5N+xmvuvHRmh\njUv6KFLaB81yv3kb7bzj8E3aMzX0x2fMIDZ3EoPVggqA/sCWQu0p5Q==\n-----END AGE ENCRYPTED FILE-----\n"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"lastmodified": "2025-07-09T10:02:45Z",
|
||||||
|
"mac": "ENC[AES256_GCM,data:IWKfE1Y6SNg/SK+OOAmra5SwqAUfhepCNPClWPDWpOyJDwXSpk/OKl7hi3KFfIZOGupaC0xV2tTni0Uj6IBwf8zW2Mb/b1T+fWkGiyafoKlucfNPXPCob/fyf4Ju4iD/u1mD5BYYYqNTNqJWE+MCyQigL0MPE4tXGEPDa7htM6w=,iv:5RKArbEKnYjacopfL+4QhzGB8txqc3gnlwNPfRWQSlM=,tag:mdXf02nYiW7CexIbUUaMyw==,type:str]",
|
||||||
|
"unencrypted_suffix": "_unencrypted",
|
||||||
|
"version": "3.10.2"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
{
|
||||||
|
"data": "ENC[AES256_GCM,data:C9evAr01JpYiMBwuy31h+G9phm+uOYoQu+PegPFAMRbjgkjh0R+uolKtweedtHumMhzEkvz7y+BlfrriVh16ceyMozfzDEkVSWM=,iv:jM4Qx4B/j5Mvc3ybOf+10hKU19l1fCc5KcKulKgMP3c=,tag:mz01kIv5kU6u3f2+FeItYA==,type:str]",
|
||||||
|
"sops": {
|
||||||
|
"age": [
|
||||||
|
{
|
||||||
|
"recipient": "age1qm0p4vf9jvcnn43s6l4prk8zn6cx0ep9gzvevxecv729xz540v8qa742eg",
|
||||||
|
"enc": "-----BEGIN AGE ENCRYPTED FILE-----\nYWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSAydzZrdDVidGpyd1NXT0Fu\nUEtZV3I4S0p5Z095QjBGaXpwOExJSkxVclVJCm54Vk12czQ5dm5TUExNNzlEcFNp\nUWorcWc1c1pvL3pkUFlQY3BJUGhUS3MKLS0tIHd2a291M0xkcjJvTXNnelRNZXda\nQi93R3FQVm0xTXBGR3E3SVpIMzgvR3MKmps5ObV1nODBQ0TKgZ++RLkjCEQM6sMn\nzonKtBingYzfeq+0+cASVkHZJpt/t0G5wmTgivKfv0OIP5eNSgIWFw==\n-----END AGE ENCRYPTED FILE-----\n"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"lastmodified": "2025-07-09T10:02:57Z",
|
||||||
|
"mac": "ENC[AES256_GCM,data:Jk5eL2SmNpakrGF4N/31Q/PWShV5KYfA8NmlxEkD82UsIpPiIJ4Nec6NOoo7Y4bl/J53MLjK3u0/S6q7vv0Tih6+ze6hIddMJHTCp2qqclJvpH2xn6Ln+2ZK4okK2ZbWeSDF+LHc6nIpBak8JVjC/d8dQFT2L49Dkufc1nCD46w=,iv:oR0aQzjaEpFNrpWGc1TX6/zpg0WSfQjVG6VjAMwoLTI=,tag:pigUaCkVv91tynuaNoZenA==,type:str]",
|
||||||
|
"unencrypted_suffix": "_unencrypted",
|
||||||
|
"version": "3.10.2"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
{
|
||||||
|
"data": "ENC[AES256_GCM,data:bIx3chjDwy4epCyFuJoZlO7EglT/vEg6pdf6x+ISxqekGrrGNdiGtw3Z9foXWAPQrzngVztbwIlcEpUusKwoRPpdGIj5YzbGZbU=,iv:Gi1hjn6cL8z+LP5g6o3bUMsuIzoZRr8e3j3EBwG3p+Y=,tag:ttIfOLhDroV/WK57KBFd0w==,type:str]",
|
||||||
|
"sops": {
|
||||||
|
"age": [
|
||||||
|
{
|
||||||
|
"recipient": "age1qm0p4vf9jvcnn43s6l4prk8zn6cx0ep9gzvevxecv729xz540v8qa742eg",
|
||||||
|
"enc": "-----BEGIN AGE ENCRYPTED FILE-----\nYWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSBHNVh6OGE4aGJxbFd2Zks1\nL1ZoNkgrQjFSVFFUL2UzOGNqRXFkZURTMkJRCnZMWk00enRndzNXQmFvMG1UekI0\nUjhwZW9sQnFvb0FGbVE0N042UjF2OTAKLS0tIEdickxQdDdaZkVmN3RsemJzSElY\nWThGQVNMcnpxRlJ3bC9wVE56blljQUUK21wWOBiQc0Kyvl047nJ1N6QKR0/5Dd6r\nlqhhdFWninzqfVXJUk2pcMio8RVlvBujDsyjrPuhbRceSi+bUXIn+w==\n-----END AGE ENCRYPTED FILE-----\n"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"lastmodified": "2025-07-09T10:03:08Z",
|
||||||
|
"mac": "ENC[AES256_GCM,data:kA2KCDZkZuR5rD7uU4xn5sIkizcnpGcoa3PYMbl73eux7JJYuSpUojFBRcYo1WCwMeOQUGsqo8LVF/rYhH4BVJ9LERs5zTLBaUsTarY8r/UK0Q5lNYZqIrqcb5LgOf1uCvfdXg5yfaFgPFJrEqjeekb9bx8xvhDZXpsND93rrUI=,iv:B6JqWWcQV/MxP4ucAIe7EnLiq9c4pnAUj3dnEp9IXJU=,tag:1i0Fv2i7Lak5JzIbPa2/cw==,type:str]",
|
||||||
|
"unencrypted_suffix": "_unencrypted",
|
||||||
|
"version": "3.10.2"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
{
|
||||||
|
"data": "ENC[AES256_GCM,data:2FgvnmawAdk+/k+RVWNsKQlUFUF+pZrrEBuupdG50uLNyxHd7Gi772gKNgHWyzZ/lpODg5mQi0rL+GmZYQwtZ7h76AGUEeQvuMMTzVUop69txxwhJD2dxZyhUAxZpibwo/St84ai+8+VksLkCSYfTXCulaeOVh4=,iv:YkPNq4zDj35PRNgt2kHEkHhbLcVc9dHP/zrAwdd94sM=,tag:KwW/74C7Z/+3dNoXB3NHwQ==,type:str]",
|
||||||
|
"sops": {
|
||||||
|
"age": [
|
||||||
|
{
|
||||||
|
"recipient": "age1qm0p4vf9jvcnn43s6l4prk8zn6cx0ep9gzvevxecv729xz540v8qa742eg",
|
||||||
|
"enc": "-----BEGIN AGE ENCRYPTED FILE-----\nYWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSBoaS94M0JsR2Q5N21DNnFB\nUHgvelRTK3FKZkNKcTJFbEJ1VGFIM256MVVRCmw5YjdyTVlXMlFpWnczV2dTSzhu\nSm5mMVRPeU1pYVFZNEN5MjJFZHVTejgKLS0tIDB0V2hSRkt5QzFYald0TWVza1lC\ncGNXemhGcklENTJiV1QvTFZxUDNRRlUK2dVEzSbdDNXZy7rQi5/Vq4KyHq5rMtEz\npTI8i1rFKIAy4TC7to03bOIudOIzKSCCzX31xARkM6qON0vEU9aHFg==\n-----END AGE ENCRYPTED FILE-----\n"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"recipient": "age1r99qtxl0v86wg8ndcem87yk5wag5xcsk98ngaumqzww6t7pyms0q5cyl80",
|
||||||
|
"enc": "-----BEGIN AGE ENCRYPTED FILE-----\nYWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSBEOEMzcExzTTF3MmpaenRN\ncS9RbnM0aStZSjNqbjF4QisrRjhoaDg1T0ZBCmFVOWJYZkFaOXBOUGJTdytYWk52\nVXV1MDdmSWQ1OS9iODAvN2c2Q3VGYXMKLS0tIEQxeWR4bmRoOWJ2Z1FyUk1PUk1n\nM0c5Ri9FdG9FNE9CZ29VSmgvN2xDdjgKjfG38gVOXXN2ftGiCPxMFbnh7lKM1USl\nqf11k+rgvR8M9XsDy2SnirKAaNmpks1dR6Zs5ppQuYJDEYyQCrEO5g==\n-----END AGE ENCRYPTED FILE-----\n"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"lastmodified": "2025-07-09T10:02:45Z",
|
||||||
|
"mac": "ENC[AES256_GCM,data:TEH57vUZ/swTsWQPJ1X3J//xa1Q1LYPETZS7fuXCH1LCK51u88XGqVpNzSETREQ8LAOt34qN284b03UQIBGTeTr7I9cqt+/l8ew/0rFTiO3aiaT49q9aBkeFZlA+gy47r4hkhMmzGQJMUenvnzTHwT3Pw2RES5Vjs/2TSitpqlA=,iv:ffIotRGKU8y6j/VDLKbTmA8dZJVP5vafeG4F3wd60tc=,tag:q4xOwzLw5jxDR0pPIy2irA==,type:str]",
|
||||||
|
"unencrypted_suffix": "_unencrypted",
|
||||||
|
"version": "3.10.2"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
-----BEGIN PUBLIC KEY-----
|
||||||
|
MCowBQYDK2VwAyEAi6qF8u2uvPXlSflB4fzJNlOhj5PgAmRiv+JyyYOOgg4=
|
||||||
|
-----END PUBLIC KEY-----
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
{
|
||||||
|
"data": "ENC[AES256_GCM,data:LUNuEP/xSmzJ44sheoIYN6F24Qpr3svn6rTVUpr4KZA8uVJ9gPUd4ko4+pDisc9PyXCcxx+cYGRqr1cBp8Q3R+IyFFlR2HzuReQJaScvgjlntGtMJ2hin/aBp4pHS0F4nqPcKKROiZvIN4NHsxQ6XRVDOZbI3kE=,iv:BdRHjQXJL/OGgmqWaEDLit/zHgduNfPe3GUmYDrWLPw=,tag:N0n7CCiu+COgrfrwHUwQBQ==,type:str]",
|
||||||
|
"sops": {
|
||||||
|
"age": [
|
||||||
|
{
|
||||||
|
"recipient": "age1hgjs2yqxhcxfgtvhydnfe5wzlagxw2dw4hu658e8neduy0lkye0skmjfc7",
|
||||||
|
"enc": "-----BEGIN AGE ENCRYPTED FILE-----\nYWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSBCYlhrU2c1NnIyTzlVRHEx\nQTFxOUY1OWJXcHl1OHpPdWN1ZGpQV0UvZ1NzCnlKbmx0bllWMTd1ZnIxUHY0ZUU0\nVG9Jb3grSEdWeVpwaHoyQUxvNERqT00KLS0tIGtwZm5aMU1DOUhJbVVpVzIxZFow\nNVEvMy91SEg3M094MEFBSkVMRkhKZmMKuUzbEITGkYS39G14JXbKWLjiQFd4SVft\nWH34B97TFhOqusVF3zHsSCMxm/0BMeBvLxO/3RmzlwBtgNiKOqLwtQ==\n-----END AGE ENCRYPTED FILE-----\n"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"recipient": "age1qm0p4vf9jvcnn43s6l4prk8zn6cx0ep9gzvevxecv729xz540v8qa742eg",
|
||||||
|
"enc": "-----BEGIN AGE ENCRYPTED FILE-----\nYWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSA2SUhJQW5EN0VKVHpQdlZC\nYTczdVJiRFdFNGtURFc2SmxKWFFycjZkQUgwCnRBVkJvUytuUDlhVlhFYno2cnBR\nRUdjL0lab1MwZzhGTklyVWZDVFJmN3cKLS0tIFRjOC9DS3llWGZWMGI2aThVYTRu\nVEFhK2Y2YkRTZHEyMWV0Q05ISHdhVVUKo9bPdV1dUeIkm4gI0r9V/s1dAfJC+H5Z\nEIUdYA7fl3jRZ01cSZ0iYWlvdl2jj0XzKafZsEQU7rL0jg9zbA2s2g==\n-----END AGE ENCRYPTED FILE-----\n"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"lastmodified": "2025-07-09T10:02:59Z",
|
||||||
|
"mac": "ENC[AES256_GCM,data:+JtuPacwUMHXtp93DZmkiVne7bQUP8J7VpoS8koM0oJWJqZoQRHd9qH/04lrpp8q/YoOXtqXwhViZvFLieJVRexiXf/AAHfAfMn0EI7ois9oHhscN88Ps9nY6JUxhNd0h0OrUA58KKhrkGoqreAKAPADtVhaVCmWbU7vMUu1StE=,iv:BmJnTsgMSbl4XsBUkhSLfKd0XjhrEQfurEkaRJ6uD/g=,tag:jg21c4y4bQp0RwWTXkxF1A==,type:str]",
|
||||||
|
"unencrypted_suffix": "_unencrypted",
|
||||||
|
"version": "3.10.2"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
-----BEGIN PUBLIC KEY-----
|
||||||
|
MCowBQYDK2VwAyEA7kRKjQpj+BXPe5buvDZtBAcU1HIcfGmbuHZqaVm3zCo=
|
||||||
|
-----END PUBLIC KEY-----
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
{
|
||||||
|
"data": "ENC[AES256_GCM,data:armAfuTE0mkoy1fxAysCX/UPNM4/mt9P6/zEDwtagTSvQjMTwVzzsM+kRdLOUV4fbZ7HdqMceaZWzurAQJenXvWlBXgn87YFOFBSpf3OnpEwCTUs9H8dsVrdSUk4SrKjCjV33mybTrae/h9tMHdkRhKJzPD1+/8=,iv:x9KVGqT2Ug6B6PNwzL7NVDQqyOmFUptUsHAJEdn30dg=,tag:XSSO6JvXaXq8aezYvpF65Q==,type:str]",
|
||||||
|
"sops": {
|
||||||
|
"age": [
|
||||||
|
{
|
||||||
|
"recipient": "age1k6h9mespmnr9zhtwwqlhnla80x5jhpd4c2p7hp0nfanr5tspup0s0rld2f",
|
||||||
|
"enc": "-----BEGIN AGE ENCRYPTED FILE-----\nYWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSBIMVUwMEFzVjJhYXg5MXR4\nMzZPZUFrUWdEU2hPWUVDNHpVVENpdEdYSWtnCnN0R2pVdEIxYWZXYWNBb3N5bGNK\naVpWOXp5aWVJWG9vUWtMUnhYSmMyV0UKLS0tIEtMdFAybk1PN0t2M2lkaEYzUTY3\nVzVOdTBFbnlNVTAvRU5kU0dReEZ6MlUKNHIkAUUAqnuMtXbvXqLxQwuFALsnD/i0\naBCiz6J4S18uqt3kFbXAEksbD7jCexI8m5SMp4iuumWJ/Bx1lL4TWg==\n-----END AGE ENCRYPTED FILE-----\n"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"recipient": "age1qm0p4vf9jvcnn43s6l4prk8zn6cx0ep9gzvevxecv729xz540v8qa742eg",
|
||||||
|
"enc": "-----BEGIN AGE ENCRYPTED FILE-----\nYWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSBkbzBFSGt1dXI2bDN5TmFU\nY3N6djNmMTh2ek4vUzdHbTF6Z1hDQ2t5WVNJClEzZDZiaVpBekFrYTYweDNsNmk5\nTlhYZGRNd0llMndyMkZWMyt5N3pwTE0KLS0tIGJJbU9vbnBhSE5vRW1pRG83cEFJ\nR2xDTHk3VkJaVUZSVThRV3Jldkp6cnMK1V37txaSFYfLQM0qqRWjojyTN4fTJkRm\nGO3yHX9uwo/4D2xI7LM48n4vnNhSF05bWpq0X4r13fI4DofCJeEo1g==\n-----END AGE ENCRYPTED FILE-----\n"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"lastmodified": "2025-07-09T10:03:11Z",
|
||||||
|
"mac": "ENC[AES256_GCM,data:qD1w+DO8cWFDQMBOrmO9FvxvJRn+mlUbh13exTGgmsdPn3uzTXknIDDHeWfkpF699nSzS6wRmgrB21e55rBU6iHMx1TW16S8wvCoYMFwib8zTrJzND7EJr/gRwQa0N080kBY3xBivKLUFlctgKtFUYZ9GQ6UTQeq18QKPoROjww=,iv:1mt8Er6YHxQ42F5Kb+xNtjbCAzokbeoNlHesC9Uzmhk=,tag:provO4tKDzoL5PHDg5EmhA==,type:str]",
|
||||||
|
"unencrypted_suffix": "_unencrypted",
|
||||||
|
"version": "3.10.2"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
-----BEGIN PUBLIC KEY-----
|
||||||
|
MCowBQYDK2VwAyEAVA6c25s+yNe5225PnELDV9FwbWi9ppLoTfgmdY8kILo=
|
||||||
|
-----END PUBLIC KEY-----
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
{
|
||||||
|
"data": "ENC[AES256_GCM,data:VzcB/JABSPoFdKYhRSn+nKxasn9zO/9fyNMrg3XstBelQNPpbO8mhmcnSamc/7e5GkpoVWgLRSULvosv+o6sz9EHRZ3UpSLBBTkDGAJmoBnkR8DbstPA9EgScpQ9IGOUP5tQ0oEOcJC3FrivdbWIzeXjpWb9BrU=,iv:6BNUrubJ9aNCkgonDRNgdyckCTndkPVDLE4X3J5d2zA=,tag:YqHTiGslEkslzUk24bmPZg==,type:str]",
|
||||||
|
"sops": {
|
||||||
|
"age": [
|
||||||
|
{
|
||||||
|
"recipient": "age1hgjs2yqxhcxfgtvhydnfe5wzlagxw2dw4hu658e8neduy0lkye0skmjfc7",
|
||||||
|
"enc": "-----BEGIN AGE ENCRYPTED FILE-----\nYWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSBwK2lMUTkrSmM4dHQxU0tI\nRVV6Wm4wWlJMYXBGbGdubExrMi8vRnJjdVd3CjI3aFVpdHRURHp6UEk3ZEZMcDZT\nZWZWaGFWYmY2Mk1iQ1BjalZkUnpUUm8KLS0tIEhFUVhBUjg1dC9LWHg2TytkRTlX\nNnlJZkJQc2ExK1BwaVVFcEw2b3BLZjQK8kqf3ZP9uLtbjCJLSEYpAqgq9zOS2HrY\n5MbPAKQI8iCUfnegti6hU+/MxjvPlaX1vT4V0Kd3gT4Khjl+OPw0Og==\n-----END AGE ENCRYPTED FILE-----\n"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"recipient": "age1r99qtxl0v86wg8ndcem87yk5wag5xcsk98ngaumqzww6t7pyms0q5cyl80",
|
||||||
|
"enc": "-----BEGIN AGE ENCRYPTED FILE-----\nYWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSBWeUk3ZW9rdnZBTk9vQlFZ\nTzFZVDAvcXZyQjdkcGNNbnA0T3UyM3lzVERvCjFreE9RdWxnb2xWWmI4amJVdHBv\nNE9JN2tFazRnSGhiM0FId2RCUHNKWVEKLS0tIGlmM3JNSVZtR21ndFliUVpLTzJO\ncHJ2SjI1OExQK2hEN01WdG9wZ3RmVTAKi0BXp9yV2/9a9NeT7aTSK2CfkQ5yColJ\nm0+uv5AJndZ9IsaZGJxNOdAOspYdvsW38hFdfjUtVuUCyIOPc20WUg==\n-----END AGE ENCRYPTED FILE-----\n"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"recipient": "age1k6h9mespmnr9zhtwwqlhnla80x5jhpd4c2p7hp0nfanr5tspup0s0rld2f",
|
||||||
|
"enc": "-----BEGIN AGE ENCRYPTED FILE-----\nYWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSBkSUR1QVMvZ3F0NUxXd00z\nOWJGZFlsUy8vUmMxa1NoakZRVmJrSmd1RzBrCk1ZcDlBMFB0WVdWeFZaT3ZBTTh5\nS2RReWpUOGRBdGV6MDdjcEY5dFYrdjAKLS0tIG9oRWhUaWJZSElRdmlOZmRKSnNq\nUUNDZFdZbmM0c25MOGpvem1JSm9pVWsKxCLPivdHc6IN6Jbf9FujLGJaXP6ieO1S\nKsrs3Fe0RdYcEKI7P9EQNebQD2kKXficM0kKV5lRRVtW5024PftWoQ==\n-----END AGE ENCRYPTED FILE-----\n"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"recipient": "age1qm0p4vf9jvcnn43s6l4prk8zn6cx0ep9gzvevxecv729xz540v8qa742eg",
|
||||||
|
"enc": "-----BEGIN AGE ENCRYPTED FILE-----\nYWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSB3amkyWWlxSTJkZEdMZFhL\nU2t5OGFIa25TRmdFM0ZNcUhFRHk0eDJQN2tjCm9UcUs2V0lEZ0hyNU9uaDVrckpj\nZ1JSQlhNeExjOER2aFJTM2NDS25PN2MKLS0tIFhmM21rT0Z4aUI5TUZyNnNBQ3Jy\nSDAxejhhZDZNQTVCNjNUSTBsZncra1kKFFQrFxNMyg0AEMb1wpKBc7LOVtEHyFZW\n/o7L52fTNa0GFJ3SVEdqg0PpnRzTyA8F5L77FBGKtx6auCVVHyZZ9g==\n-----END AGE ENCRYPTED FILE-----\n"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"lastmodified": "2025-07-09T10:02:48Z",
|
||||||
|
"mac": "ENC[AES256_GCM,data:HooesDb1S24Cfb7H0lVTA8fAjM2QAN9MaJFvOSHniR6ICJAX8t8X0xfWIFRFuwPjAxi4kpBYSjW0420Yz9lZ2m4Fxswo1TV3lzHDVN2u9hdrsfpKXg5fW+2oZihuvCRStDagT3l2fKv+C+gBnGs1qyCM60BStvrEiQxTxTTHfho=,iv:kL8N0qBj4q+ZJbNJ8Y8RcV1KpUUMvNCpdwKbTPGpG6k=,tag:o2PmRsSkqTP5Idq7veGDOw==,type:str]",
|
||||||
|
"unencrypted_suffix": "_unencrypted",
|
||||||
|
"version": "3.10.2"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
-----BEGIN PUBLIC KEY-----
|
||||||
|
MCowBQYDK2VwAyEA/MuamRX6ZLcJunm7lZvlai0OZh++YuqMa56GiTwO68A=
|
||||||
|
-----END PUBLIC KEY-----
|
||||||
@@ -1,8 +1,11 @@
|
|||||||
{ ... }:
|
{ ... }:
|
||||||
{
|
{
|
||||||
_class = "clan.service";
|
_class = "clan.service";
|
||||||
manifest.name = "clan-core/users";
|
manifest.name = "clan-core/user";
|
||||||
manifest.description = "Automatically generates and configures a password for the specified user account.";
|
manifest.description = ''
|
||||||
|
An instance of this module will create a user account on the added machines,
|
||||||
|
along with a generated password that is constant across machines and user settings.
|
||||||
|
'';
|
||||||
manifest.categories = [ "System" ];
|
manifest.categories = [ "System" ];
|
||||||
manifest.readme = builtins.readFile ./README.md;
|
manifest.readme = builtins.readFile ./README.md;
|
||||||
|
|
||||||
@@ -20,7 +23,57 @@
|
|||||||
type = lib.types.bool;
|
type = lib.types.bool;
|
||||||
default = true;
|
default = true;
|
||||||
example = false;
|
example = false;
|
||||||
description = "Whether the user should be prompted.";
|
description = ''
|
||||||
|
Whether the user should be prompted for a password.
|
||||||
|
|
||||||
|
Effects:
|
||||||
|
|
||||||
|
- *enabled* (`true`) - Prompt for a passwort during the machine installation or update workflow.
|
||||||
|
- *disabled* (`false`) - Generate a passwort during the machine installation or update workflow.
|
||||||
|
|
||||||
|
The password can be shown in two steps:
|
||||||
|
|
||||||
|
- `clan vars list <machine-name>`
|
||||||
|
- `clan vars get <machine-name> <name-of-password-variable>`
|
||||||
|
'';
|
||||||
|
};
|
||||||
|
regularUser = lib.mkOption {
|
||||||
|
type = lib.types.bool;
|
||||||
|
default = true;
|
||||||
|
example = false;
|
||||||
|
description = ''
|
||||||
|
Whether the user should be a regular user or a system user.
|
||||||
|
|
||||||
|
Regular users are normal users that can log in and have a home directory.
|
||||||
|
|
||||||
|
System users are used for system services and do not have a home directory.
|
||||||
|
|
||||||
|
!!! Warning
|
||||||
|
`root` cannot be a regular user.
|
||||||
|
You must set this to `false` for `root`
|
||||||
|
'';
|
||||||
|
};
|
||||||
|
groups = lib.mkOption {
|
||||||
|
type = lib.types.listOf lib.types.str;
|
||||||
|
default = [ ];
|
||||||
|
example = [
|
||||||
|
"wheel"
|
||||||
|
"networkmanager"
|
||||||
|
"video"
|
||||||
|
"input"
|
||||||
|
];
|
||||||
|
description = ''
|
||||||
|
Additional groups the user should be added to.
|
||||||
|
You can add any group that exists on your system.
|
||||||
|
Make sure these group exists on all machines where the user is enabled.
|
||||||
|
|
||||||
|
Commonly used groups:
|
||||||
|
|
||||||
|
- "wheel" - Allows the user to run commands as root using `sudo`.
|
||||||
|
- "networkmanager" - Allows the user to manage network connections.
|
||||||
|
- "video" - Allows the user to access video devices.
|
||||||
|
- "input" - Allows the user to access input devices.
|
||||||
|
'';
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
@@ -36,9 +89,13 @@
|
|||||||
...
|
...
|
||||||
}:
|
}:
|
||||||
{
|
{
|
||||||
users.mutableUsers = false;
|
users.users.${settings.user} = {
|
||||||
users.users.${settings.user}.hashedPasswordFile =
|
isNormalUser = settings.regularUser;
|
||||||
config.clan.core.vars.generators."user-password-${settings.user}".files.user-password-hash.path;
|
extraGroups = settings.groups;
|
||||||
|
|
||||||
|
hashedPasswordFile =
|
||||||
|
config.clan.core.vars.generators."user-password-${settings.user}".files.user-password-hash.path;
|
||||||
|
};
|
||||||
|
|
||||||
clan.core.vars.generators."user-password-${settings.user}" = {
|
clan.core.vars.generators."user-password-${settings.user}" = {
|
||||||
|
|
||||||
@@ -81,4 +138,11 @@
|
|||||||
};
|
};
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
|
perMachine = {
|
||||||
|
nixosModule = {
|
||||||
|
# Immutable users to ensure that this module has exclusive control over the users.
|
||||||
|
users.mutableUsers = false;
|
||||||
|
};
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,6 +13,8 @@
|
|||||||
roles.default.machines."server".settings = {
|
roles.default.machines."server".settings = {
|
||||||
user = "root";
|
user = "root";
|
||||||
prompt = false;
|
prompt = false;
|
||||||
|
# Important: 'root' must not be a regular user. See: https://github.com/NixOS/nixpkgs/issues/424404
|
||||||
|
regularUser = false;
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
user-password-test = {
|
user-password-test = {
|
||||||
@@ -31,7 +33,6 @@
|
|||||||
server = {
|
server = {
|
||||||
users.users.testuser.group = "testuser";
|
users.users.testuser.group = "testuser";
|
||||||
users.groups.testuser = { };
|
users.groups.testuser = { };
|
||||||
users.users.testuser.isNormalUser = true;
|
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -79,6 +79,7 @@ nav:
|
|||||||
- Migrate existing Flakes: guides/migrations/migration-guide.md
|
- Migrate existing Flakes: guides/migrations/migration-guide.md
|
||||||
- Migrate inventory Services: guides/migrations/migrate-inventory-services.md
|
- Migrate inventory Services: guides/migrations/migrate-inventory-services.md
|
||||||
- Facts Vars Migration: guides/migrations/migration-facts-vars.md
|
- Facts Vars Migration: guides/migrations/migration-facts-vars.md
|
||||||
|
- Disk id: guides/migrations/disk-id.md
|
||||||
- macOS: guides/macos.md
|
- macOS: guides/macos.md
|
||||||
- Reference:
|
- Reference:
|
||||||
- Overview: reference/index.md
|
- Overview: reference/index.md
|
||||||
@@ -86,6 +87,7 @@ nav:
|
|||||||
- Overview: reference/clanServices/index.md
|
- Overview: reference/clanServices/index.md
|
||||||
- reference/clanServices/admin.md
|
- reference/clanServices/admin.md
|
||||||
- reference/clanServices/borgbackup.md
|
- reference/clanServices/borgbackup.md
|
||||||
|
- reference/clanServices/data-mesher.md
|
||||||
- reference/clanServices/emergency-access.md
|
- reference/clanServices/emergency-access.md
|
||||||
- reference/clanServices/garage.md
|
- reference/clanServices/garage.md
|
||||||
- reference/clanServices/hello-world.md
|
- reference/clanServices/hello-world.md
|
||||||
|
|||||||
@@ -48,7 +48,7 @@ nix shell git+https://git.clan.lol/clan/clan-core#clan-cli --refresh
|
|||||||
clan --help
|
clan --help
|
||||||
```
|
```
|
||||||
|
|
||||||
Should print the avilable commands.
|
Should print the available commands.
|
||||||
|
|
||||||
Also checkout the [cli-reference documentation](../../reference/cli/index.md).
|
Also checkout the [cli-reference documentation](../../reference/cli/index.md).
|
||||||
|
|
||||||
|
|||||||
98
docs/site/guides/migrations/disk-id.md
Normal file
98
docs/site/guides/migrations/disk-id.md
Normal file
@@ -0,0 +1,98 @@
|
|||||||
|
# Migrate disko config from `clanModules.disk-id`
|
||||||
|
|
||||||
|
If you previously bootstrapped a machine's disk using `clanModules.disk-id`, you should now migrate to a standalone, self-contained disko configuration. This ensures long-term stability and avoids reliance on dynamic values from Clan.
|
||||||
|
|
||||||
|
If your `disko.nix` currently looks something like this:
|
||||||
|
|
||||||
|
```nix title="disko.nix"
|
||||||
|
{
|
||||||
|
lib,
|
||||||
|
clan-core,
|
||||||
|
config,
|
||||||
|
...
|
||||||
|
}:
|
||||||
|
|
||||||
|
let
|
||||||
|
suffix = config.clan.core.vars.generators.disk-id.files.diskId.value;
|
||||||
|
in
|
||||||
|
{
|
||||||
|
imports = [
|
||||||
|
clan-core.clanModules.disk-id
|
||||||
|
];
|
||||||
|
|
||||||
|
# DO NOT EDIT THIS FILE AFTER INSTALLATION of a machine
|
||||||
|
# Otherwise your system might not boot because of missing partitions / filesystems
|
||||||
|
boot.loader.grub.efiSupport = lib.mkDefault true;
|
||||||
|
boot.loader.grub.efiInstallAsRemovable = lib.mkDefault true;
|
||||||
|
disko.devices = {
|
||||||
|
disk = {
|
||||||
|
"main" = {
|
||||||
|
# suffix is to prevent disk name collisions
|
||||||
|
name = "main-" + suffix;
|
||||||
|
type = "disk";
|
||||||
|
# Set the following in flake.nix for each maschine:
|
||||||
|
# device = <uuid>;
|
||||||
|
content = {
|
||||||
|
# edlied
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Step 1: Retrieve your `disk-id`
|
||||||
|
|
||||||
|
Run the following command to retrieve the generated disk ID for your machine:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
clan vars list <machineName>
|
||||||
|
```
|
||||||
|
|
||||||
|
Which should print the generated `disk-id/diskId` value in clear text
|
||||||
|
You should see output like:
|
||||||
|
|
||||||
|
```terminal-session
|
||||||
|
disk-id/diskId: fcef30a749f8451d8f60c46e1ead726f
|
||||||
|
# ...
|
||||||
|
# elided
|
||||||
|
```
|
||||||
|
|
||||||
|
Copy this value — you'll need it in the next step.
|
||||||
|
|
||||||
|
## ✍️ Step 2: Replace Dynamic Configuration with Static Values
|
||||||
|
|
||||||
|
✅ Goal: Make your disko.nix file standalone.
|
||||||
|
|
||||||
|
We are going to make three changes:
|
||||||
|
|
||||||
|
- Remove `let in, imports, {lib,clan-core,config, ...}:` to isolate the file.
|
||||||
|
- Replace `suffix` with the actual disk-id
|
||||||
|
- Move `disko.devices.disk.main.device` from `flake.nix` or `configuration.nix` into this file.
|
||||||
|
|
||||||
|
```{.nix title="disko.nix" hl_lines="7-9 11-14"}
|
||||||
|
{
|
||||||
|
boot.loader.grub.efiSupport = lib.mkDefault true;
|
||||||
|
boot.loader.grub.efiInstallAsRemovable = lib.mkDefault true;
|
||||||
|
disko.devices = {
|
||||||
|
disk = {
|
||||||
|
"main" = {
|
||||||
|
# ↓ Copy the disk-id into place
|
||||||
|
name = "main-fcef30a749f8451d8f60c46e1ead726f";
|
||||||
|
type = "disk";
|
||||||
|
|
||||||
|
# Some earlier guides had this line in a flake.nix
|
||||||
|
# disko.devices.disk.main.device = "/dev/disk/by-id/__CHANGE_ME__";
|
||||||
|
# ↓ Copy the '/dev/disk/by-id' into here instead
|
||||||
|
device = "/dev/disk/by-id/nvme-eui.e8238fa6bf530001001b448b4aec2929";
|
||||||
|
|
||||||
|
# edlied;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
These steps are only needed for existing configurations that depend on the `diskId` module.
|
||||||
|
|
||||||
|
For newer machines clan offers simple *disk templates* via its [templates cli](../../reference/cli/templates.md)
|
||||||
@@ -22,6 +22,7 @@ in
|
|||||||
type = attrsWith {
|
type = attrsWith {
|
||||||
placeholder = "mappedServiceName";
|
placeholder = "mappedServiceName";
|
||||||
elemType = submoduleWith {
|
elemType = submoduleWith {
|
||||||
|
class = "clan.service";
|
||||||
modules = [
|
modules = [
|
||||||
(
|
(
|
||||||
{ name, ... }:
|
{ name, ... }:
|
||||||
|
|||||||
@@ -122,6 +122,7 @@ in
|
|||||||
evalServices =
|
evalServices =
|
||||||
{ modules, prefix }:
|
{ modules, prefix }:
|
||||||
lib.evalModules {
|
lib.evalModules {
|
||||||
|
class = "clan";
|
||||||
specialArgs = {
|
specialArgs = {
|
||||||
inherit clanLib;
|
inherit clanLib;
|
||||||
_ctx = prefix;
|
_ctx = prefix;
|
||||||
|
|||||||
37
nixosModules/clanCore/vars/secret/sops/collectFiles.nix
Normal file
37
nixosModules/clanCore/vars/secret/sops/collectFiles.nix
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
# collectFiles helper function
|
||||||
|
{
|
||||||
|
lib ? import <nixpkgs/lib>,
|
||||||
|
}:
|
||||||
|
let
|
||||||
|
inherit (lib)
|
||||||
|
filterAttrs
|
||||||
|
flatten
|
||||||
|
mapAttrsToList
|
||||||
|
;
|
||||||
|
in
|
||||||
|
generators:
|
||||||
|
let
|
||||||
|
relevantFiles =
|
||||||
|
generator:
|
||||||
|
filterAttrs (
|
||||||
|
_name: f: f.secret && f.deploy && (f.neededFor == "users" || f.neededFor == "services")
|
||||||
|
) generator.files;
|
||||||
|
allFiles = flatten (
|
||||||
|
mapAttrsToList (
|
||||||
|
gen_name: generator:
|
||||||
|
mapAttrsToList (fname: file: {
|
||||||
|
name = fname;
|
||||||
|
generator = gen_name;
|
||||||
|
neededForUsers = file.neededFor == "users";
|
||||||
|
inherit (generator) share;
|
||||||
|
inherit (file)
|
||||||
|
owner
|
||||||
|
group
|
||||||
|
mode
|
||||||
|
restartUnits
|
||||||
|
;
|
||||||
|
}) (relevantFiles generator)
|
||||||
|
) generators
|
||||||
|
);
|
||||||
|
in
|
||||||
|
allFiles
|
||||||
@@ -7,7 +7,7 @@
|
|||||||
}:
|
}:
|
||||||
let
|
let
|
||||||
|
|
||||||
inherit (import ./funcs.nix { inherit lib; }) collectFiles;
|
collectFiles = import ./collectFiles.nix { inherit lib; };
|
||||||
|
|
||||||
machineName = config.clan.core.settings.machine.name;
|
machineName = config.clan.core.settings.machine.name;
|
||||||
|
|
||||||
|
|||||||
@@ -1,42 +0,0 @@
|
|||||||
{
|
|
||||||
lib ? import <nixpkgs/lib>,
|
|
||||||
...
|
|
||||||
}:
|
|
||||||
let
|
|
||||||
|
|
||||||
inherit (lib)
|
|
||||||
filterAttrs
|
|
||||||
flatten
|
|
||||||
mapAttrsToList
|
|
||||||
;
|
|
||||||
in
|
|
||||||
{
|
|
||||||
|
|
||||||
collectFiles =
|
|
||||||
generators:
|
|
||||||
let
|
|
||||||
relevantFiles =
|
|
||||||
generator:
|
|
||||||
filterAttrs (
|
|
||||||
_name: f: f.secret && f.deploy && (f.neededFor == "users" || f.neededFor == "services")
|
|
||||||
) generator.files;
|
|
||||||
allFiles = flatten (
|
|
||||||
mapAttrsToList (
|
|
||||||
gen_name: generator:
|
|
||||||
mapAttrsToList (fname: file: {
|
|
||||||
name = fname;
|
|
||||||
generator = gen_name;
|
|
||||||
neededForUsers = file.neededFor == "users";
|
|
||||||
inherit (generator) share;
|
|
||||||
inherit (file)
|
|
||||||
owner
|
|
||||||
group
|
|
||||||
mode
|
|
||||||
restartUnits
|
|
||||||
;
|
|
||||||
}) (relevantFiles generator)
|
|
||||||
) generators
|
|
||||||
);
|
|
||||||
in
|
|
||||||
allFiles;
|
|
||||||
}
|
|
||||||
@@ -23,9 +23,6 @@
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"path": "../clan-cli/clan_lib"
|
"path": "../clan-cli/clan_lib"
|
||||||
},
|
|
||||||
{
|
|
||||||
"path": "ui-2d"
|
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"settings": {
|
"settings": {
|
||||||
|
|||||||
@@ -1,10 +1,13 @@
|
|||||||
import logging
|
import logging
|
||||||
|
import threading
|
||||||
from abc import ABC, abstractmethod
|
from abc import ABC, abstractmethod
|
||||||
from contextlib import ExitStack
|
from contextlib import ExitStack
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass, field
|
||||||
from typing import TYPE_CHECKING, Any
|
from typing import TYPE_CHECKING, Any
|
||||||
|
|
||||||
from clan_lib.api import ApiResponse
|
from clan_lib.api import ApiResponse
|
||||||
|
from clan_lib.api.tasks import WebThread
|
||||||
|
from clan_lib.async_run import set_should_cancel
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
from .middleware import Middleware
|
from .middleware import Middleware
|
||||||
@@ -32,6 +35,7 @@ class ApiBridge(ABC):
|
|||||||
"""Generic interface for API bridges that can handle method calls from different sources."""
|
"""Generic interface for API bridges that can handle method calls from different sources."""
|
||||||
|
|
||||||
middleware_chain: tuple["Middleware", ...]
|
middleware_chain: tuple["Middleware", ...]
|
||||||
|
threads: dict[str, WebThread] = field(default_factory=dict)
|
||||||
|
|
||||||
@abstractmethod
|
@abstractmethod
|
||||||
def send_api_response(self, response: BackendResponse) -> None:
|
def send_api_response(self, response: BackendResponse) -> None:
|
||||||
@@ -87,3 +91,51 @@ class ApiBridge(ABC):
|
|||||||
)
|
)
|
||||||
|
|
||||||
self.send_api_response(response)
|
self.send_api_response(response)
|
||||||
|
|
||||||
|
def process_request_in_thread(
|
||||||
|
self,
|
||||||
|
request: BackendRequest,
|
||||||
|
*,
|
||||||
|
thread_name: str = "ApiBridgeThread",
|
||||||
|
wait_for_completion: bool = False,
|
||||||
|
timeout: float = 60.0,
|
||||||
|
) -> None:
|
||||||
|
"""Process an API request in a separate thread with cancellation support.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
request: The API request to process
|
||||||
|
thread_name: Name for the thread (for debugging)
|
||||||
|
wait_for_completion: Whether to wait for the thread to complete
|
||||||
|
timeout: Timeout in seconds when waiting for completion
|
||||||
|
"""
|
||||||
|
op_key = request.op_key or "unknown"
|
||||||
|
|
||||||
|
def thread_task(stop_event: threading.Event) -> None:
|
||||||
|
set_should_cancel(lambda: stop_event.is_set())
|
||||||
|
try:
|
||||||
|
log.debug(
|
||||||
|
f"Processing {request.method_name} with args {request.args} "
|
||||||
|
f"and header {request.header} in thread {thread_name}"
|
||||||
|
)
|
||||||
|
self.process_request(request)
|
||||||
|
finally:
|
||||||
|
self.threads.pop(op_key, None)
|
||||||
|
|
||||||
|
stop_event = threading.Event()
|
||||||
|
thread = threading.Thread(
|
||||||
|
target=thread_task, args=(stop_event,), name=thread_name
|
||||||
|
)
|
||||||
|
thread.start()
|
||||||
|
|
||||||
|
self.threads[op_key] = WebThread(thread=thread, stop_event=stop_event)
|
||||||
|
|
||||||
|
if wait_for_completion:
|
||||||
|
# Wait for the thread to complete (this blocks until response is sent)
|
||||||
|
thread.join(timeout=timeout)
|
||||||
|
|
||||||
|
# Handle timeout
|
||||||
|
if thread.is_alive():
|
||||||
|
stop_event.set() # Cancel the thread
|
||||||
|
self.send_api_error_response(
|
||||||
|
op_key, "Request timeout", ["api_bridge", request.method_name]
|
||||||
|
)
|
||||||
|
|||||||
@@ -9,6 +9,8 @@ gi.require_version("Gtk", "4.0")
|
|||||||
|
|
||||||
from clan_lib.api import ApiError, ErrorDataClass, SuccessDataClass
|
from clan_lib.api import ApiError, ErrorDataClass, SuccessDataClass
|
||||||
from clan_lib.api.directory import FileRequest
|
from clan_lib.api.directory import FileRequest
|
||||||
|
from clan_lib.clan.check import check_clan_valid
|
||||||
|
from clan_lib.flake import Flake
|
||||||
from gi.repository import Gio, GLib, Gtk
|
from gi.repository import Gio, GLib, Gtk
|
||||||
|
|
||||||
gi.require_version("Gtk", "4.0")
|
gi.require_version("Gtk", "4.0")
|
||||||
@@ -22,13 +24,58 @@ def remove_none(_list: list) -> list:
|
|||||||
RESULT: dict[str, SuccessDataClass[list[str] | None] | ErrorDataClass] = {}
|
RESULT: dict[str, SuccessDataClass[list[str] | None] | ErrorDataClass] = {}
|
||||||
|
|
||||||
|
|
||||||
def open_file(
|
def get_clan_folder(*, op_key: str) -> SuccessDataClass[Flake] | ErrorDataClass:
|
||||||
|
"""
|
||||||
|
Opens the clan folder using the GTK file dialog.
|
||||||
|
Returns the path to the clan folder or an error if it fails.
|
||||||
|
"""
|
||||||
|
file_request = FileRequest(
|
||||||
|
mode="select_folder",
|
||||||
|
title="Select Clan Folder",
|
||||||
|
initial_folder=str(Path.home()),
|
||||||
|
)
|
||||||
|
response = get_system_file(file_request, op_key=op_key)
|
||||||
|
|
||||||
|
if isinstance(response, ErrorDataClass):
|
||||||
|
return response
|
||||||
|
|
||||||
|
if not response.data or len(response.data) == 0:
|
||||||
|
return ErrorDataClass(
|
||||||
|
op_key=op_key,
|
||||||
|
status="error",
|
||||||
|
errors=[
|
||||||
|
ApiError(
|
||||||
|
message="No folder selected",
|
||||||
|
description="You must select a folder to open.",
|
||||||
|
location=["get_clan_folder"],
|
||||||
|
)
|
||||||
|
],
|
||||||
|
)
|
||||||
|
|
||||||
|
clan_folder = Flake(response.data[0])
|
||||||
|
if not check_clan_valid(clan_folder):
|
||||||
|
return ErrorDataClass(
|
||||||
|
op_key=op_key,
|
||||||
|
status="error",
|
||||||
|
errors=[
|
||||||
|
ApiError(
|
||||||
|
message="Invalid clan folder",
|
||||||
|
description=f"The selected folder '{clan_folder}' is not a valid clan folder.",
|
||||||
|
location=["get_clan_folder"],
|
||||||
|
)
|
||||||
|
],
|
||||||
|
)
|
||||||
|
|
||||||
|
return SuccessDataClass(op_key=op_key, data=clan_folder, status="success")
|
||||||
|
|
||||||
|
|
||||||
|
def get_system_file(
|
||||||
file_request: FileRequest, *, op_key: str
|
file_request: FileRequest, *, op_key: str
|
||||||
) -> SuccessDataClass[list[str] | None] | ErrorDataClass:
|
) -> SuccessDataClass[list[str] | None] | ErrorDataClass:
|
||||||
GLib.idle_add(gtk_open_file, file_request, op_key)
|
GLib.idle_add(gtk_open_file, file_request, op_key)
|
||||||
|
|
||||||
while RESULT.get(op_key) is None:
|
while RESULT.get(op_key) is None:
|
||||||
time.sleep(0.2)
|
time.sleep(0.1)
|
||||||
response = RESULT[op_key]
|
response = RESULT[op_key]
|
||||||
del RESULT[op_key]
|
del RESULT[op_key]
|
||||||
return response
|
return response
|
||||||
@@ -59,7 +106,7 @@ def gtk_open_file(file_request: FileRequest, op_key: str) -> bool:
|
|||||||
ApiError(
|
ApiError(
|
||||||
message=e.__class__.__name__,
|
message=e.__class__.__name__,
|
||||||
description=str(e),
|
description=str(e),
|
||||||
location=["open_file"],
|
location=["get_system_file"],
|
||||||
)
|
)
|
||||||
],
|
],
|
||||||
)
|
)
|
||||||
@@ -87,7 +134,7 @@ def gtk_open_file(file_request: FileRequest, op_key: str) -> bool:
|
|||||||
ApiError(
|
ApiError(
|
||||||
message=e.__class__.__name__,
|
message=e.__class__.__name__,
|
||||||
description=str(e),
|
description=str(e),
|
||||||
location=["open_file"],
|
location=["get_system_file"],
|
||||||
)
|
)
|
||||||
],
|
],
|
||||||
)
|
)
|
||||||
@@ -115,7 +162,7 @@ def gtk_open_file(file_request: FileRequest, op_key: str) -> bool:
|
|||||||
ApiError(
|
ApiError(
|
||||||
message=e.__class__.__name__,
|
message=e.__class__.__name__,
|
||||||
description=str(e),
|
description=str(e),
|
||||||
location=["open_file"],
|
location=["get_system_file"],
|
||||||
)
|
)
|
||||||
],
|
],
|
||||||
)
|
)
|
||||||
@@ -143,7 +190,7 @@ def gtk_open_file(file_request: FileRequest, op_key: str) -> bool:
|
|||||||
ApiError(
|
ApiError(
|
||||||
message=e.__class__.__name__,
|
message=e.__class__.__name__,
|
||||||
description=str(e),
|
description=str(e),
|
||||||
location=["open_file"],
|
location=["get_system_file"],
|
||||||
)
|
)
|
||||||
],
|
],
|
||||||
)
|
)
|
||||||
@@ -192,7 +239,7 @@ def gtk_open_file(file_request: FileRequest, op_key: str) -> bool:
|
|||||||
dialog.select_folder(callback=on_folder_select)
|
dialog.select_folder(callback=on_folder_select)
|
||||||
if file_request.mode == "open_multiple_files":
|
if file_request.mode == "open_multiple_files":
|
||||||
dialog.open_multiple(callback=on_file_select_multiple)
|
dialog.open_multiple(callback=on_file_select_multiple)
|
||||||
elif file_request.mode == "open_file":
|
elif file_request.mode == "get_system_file":
|
||||||
dialog.open(callback=on_file_select)
|
dialog.open(callback=on_file_select)
|
||||||
elif file_request.mode == "save":
|
elif file_request.mode == "save":
|
||||||
dialog.save(callback=on_save_finish)
|
dialog.save(callback=on_save_finish)
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ from clan_lib.dirs import user_data_dir
|
|||||||
from clan_lib.log_manager import LogGroupConfig, LogManager
|
from clan_lib.log_manager import LogGroupConfig, LogManager
|
||||||
from clan_lib.log_manager import api as log_manager_api
|
from clan_lib.log_manager import api as log_manager_api
|
||||||
|
|
||||||
from clan_app.api.file_gtk import open_file
|
from clan_app.api.file_gtk import get_clan_folder, get_system_file
|
||||||
from clan_app.api.middleware import (
|
from clan_app.api.middleware import (
|
||||||
ArgumentParsingMiddleware,
|
ArgumentParsingMiddleware,
|
||||||
LoggingMiddleware,
|
LoggingMiddleware,
|
||||||
@@ -56,7 +56,10 @@ def app_run(app_opts: ClanAppOptions) -> int:
|
|||||||
|
|
||||||
# Populate the API global with all functions
|
# Populate the API global with all functions
|
||||||
load_in_all_api_functions()
|
load_in_all_api_functions()
|
||||||
API.overwrite_fn(open_file)
|
|
||||||
|
# Create a shared threads dictionary for both HTTP and Webview modes
|
||||||
|
shared_threads: dict[str, tasks.WebThread] = {}
|
||||||
|
tasks.BAKEND_THREADS = shared_threads
|
||||||
|
|
||||||
# Start HTTP API server if requested
|
# Start HTTP API server if requested
|
||||||
http_server = None
|
http_server = None
|
||||||
@@ -72,6 +75,7 @@ def app_run(app_opts: ClanAppOptions) -> int:
|
|||||||
swagger_dist=Path(swagger_dist) if swagger_dist else None,
|
swagger_dist=Path(swagger_dist) if swagger_dist else None,
|
||||||
host=app_opts.http_host,
|
host=app_opts.http_host,
|
||||||
port=app_opts.http_port,
|
port=app_opts.http_port,
|
||||||
|
shared_threads=shared_threads,
|
||||||
)
|
)
|
||||||
|
|
||||||
# Add middleware to HTTP server
|
# Add middleware to HTTP server
|
||||||
@@ -103,20 +107,20 @@ def app_run(app_opts: ClanAppOptions) -> int:
|
|||||||
# Create webview if not running in HTTP-only mode
|
# Create webview if not running in HTTP-only mode
|
||||||
if not app_opts.http_api:
|
if not app_opts.http_api:
|
||||||
webview = Webview(
|
webview = Webview(
|
||||||
debug=app_opts.debug, title="Clan App", size=Size(1280, 1024, SizeHint.NONE)
|
debug=app_opts.debug,
|
||||||
|
title="Clan App",
|
||||||
|
size=Size(1280, 1024, SizeHint.NONE),
|
||||||
|
shared_threads=shared_threads,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
API.overwrite_fn(get_system_file)
|
||||||
|
API.overwrite_fn(get_clan_folder)
|
||||||
|
|
||||||
# Add middleware to the webview
|
# Add middleware to the webview
|
||||||
webview.add_middleware(ArgumentParsingMiddleware(api=API))
|
webview.add_middleware(ArgumentParsingMiddleware(api=API))
|
||||||
webview.add_middleware(LoggingMiddleware(log_manager=log_manager))
|
webview.add_middleware(LoggingMiddleware(log_manager=log_manager))
|
||||||
webview.add_middleware(MethodExecutionMiddleware(api=API))
|
webview.add_middleware(MethodExecutionMiddleware(api=API))
|
||||||
|
|
||||||
# Create the bridge
|
|
||||||
webview.create_bridge()
|
|
||||||
|
|
||||||
# Init BAKEND_THREADS global in tasks module
|
|
||||||
tasks.BAKEND_THREADS = webview.threads
|
|
||||||
|
|
||||||
webview.bind_jsonschema_api(API, log_manager=log_manager)
|
webview.bind_jsonschema_api(API, log_manager=log_manager)
|
||||||
webview.navigate(content_uri)
|
webview.navigate(content_uri)
|
||||||
webview.run()
|
webview.run()
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
import json
|
import json
|
||||||
import logging
|
import logging
|
||||||
import threading
|
|
||||||
import uuid
|
import uuid
|
||||||
from http.server import BaseHTTPRequestHandler
|
from http.server import BaseHTTPRequestHandler
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
@@ -9,7 +8,6 @@ from urllib.parse import urlparse
|
|||||||
|
|
||||||
from clan_lib.api import MethodRegistry, SuccessDataClass, dataclass_to_dict
|
from clan_lib.api import MethodRegistry, SuccessDataClass, dataclass_to_dict
|
||||||
from clan_lib.api.tasks import WebThread
|
from clan_lib.api.tasks import WebThread
|
||||||
from clan_lib.async_run import set_should_cancel
|
|
||||||
|
|
||||||
from clan_app.api.api_bridge import ApiBridge, BackendRequest, BackendResponse
|
from clan_app.api.api_bridge import ApiBridge, BackendRequest, BackendResponse
|
||||||
|
|
||||||
@@ -35,11 +33,12 @@ class HttpBridge(ApiBridge, BaseHTTPRequestHandler):
|
|||||||
*,
|
*,
|
||||||
openapi_file: Path | None = None,
|
openapi_file: Path | None = None,
|
||||||
swagger_dist: Path | None = None,
|
swagger_dist: Path | None = None,
|
||||||
|
shared_threads: dict[str, WebThread] | None = None,
|
||||||
) -> None:
|
) -> None:
|
||||||
# Initialize API bridge fields
|
# Initialize API bridge fields
|
||||||
self.api = api
|
self.api = api
|
||||||
self.middleware_chain = middleware_chain
|
self.middleware_chain = middleware_chain
|
||||||
self.threads: dict[str, WebThread] = {}
|
self.threads = shared_threads if shared_threads is not None else {}
|
||||||
|
|
||||||
# Initialize OpenAPI/Swagger fields
|
# Initialize OpenAPI/Swagger fields
|
||||||
self.openapi_file = openapi_file
|
self.openapi_file = openapi_file
|
||||||
@@ -329,31 +328,13 @@ class HttpBridge(ApiBridge, BaseHTTPRequestHandler):
|
|||||||
self, api_request: BackendRequest, method_name: str
|
self, api_request: BackendRequest, method_name: str
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Process the API request in a separate thread."""
|
"""Process the API request in a separate thread."""
|
||||||
op_key = api_request.op_key or "unknown"
|
# Use the inherited thread processing method
|
||||||
|
self.process_request_in_thread(
|
||||||
def thread_task(stop_event: threading.Event) -> None:
|
api_request,
|
||||||
set_should_cancel(lambda: stop_event.is_set())
|
thread_name="HttpThread",
|
||||||
try:
|
wait_for_completion=True,
|
||||||
self.process_request(api_request)
|
timeout=60.0,
|
||||||
finally:
|
|
||||||
self.threads.pop(op_key, None)
|
|
||||||
|
|
||||||
stop_event = threading.Event()
|
|
||||||
thread = threading.Thread(
|
|
||||||
target=thread_task, args=(stop_event,), name="HttpThread"
|
|
||||||
)
|
)
|
||||||
thread.start()
|
|
||||||
self.threads[op_key] = WebThread(thread=thread, stop_event=stop_event)
|
|
||||||
|
|
||||||
# Wait for the thread to complete (this blocks until response is sent)
|
|
||||||
thread.join(timeout=60.0)
|
|
||||||
|
|
||||||
# Handle timeout
|
|
||||||
if thread.is_alive():
|
|
||||||
stop_event.set() # Cancel the thread
|
|
||||||
self.send_api_error_response(
|
|
||||||
op_key, "Request timeout", ["http_bridge", method_name]
|
|
||||||
)
|
|
||||||
|
|
||||||
def log_message(self, format: str, *args: Any) -> None: # noqa: A002
|
def log_message(self, format: str, *args: Any) -> None: # noqa: A002
|
||||||
"""Override default logging to use our logger."""
|
"""Override default logging to use our logger."""
|
||||||
|
|||||||
@@ -1,10 +1,11 @@
|
|||||||
import logging
|
import logging
|
||||||
import threading
|
import threading
|
||||||
from http.server import HTTPServer
|
from http.server import HTTPServer, ThreadingHTTPServer
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import TYPE_CHECKING, Any
|
from typing import TYPE_CHECKING, Any
|
||||||
|
|
||||||
from clan_lib.api import MethodRegistry
|
from clan_lib.api import MethodRegistry
|
||||||
|
from clan_lib.api.tasks import WebThread
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
from clan_app.api.middleware import Middleware
|
from clan_app.api.middleware import Middleware
|
||||||
@@ -24,6 +25,7 @@ class HttpApiServer:
|
|||||||
port: int = 8080,
|
port: int = 8080,
|
||||||
openapi_file: Path | None = None,
|
openapi_file: Path | None = None,
|
||||||
swagger_dist: Path | None = None,
|
swagger_dist: Path | None = None,
|
||||||
|
shared_threads: dict[str, WebThread] | None = None,
|
||||||
) -> None:
|
) -> None:
|
||||||
self.api = api
|
self.api = api
|
||||||
self.openapi = openapi_file
|
self.openapi = openapi_file
|
||||||
@@ -34,6 +36,7 @@ class HttpApiServer:
|
|||||||
self._server_thread: threading.Thread | None = None
|
self._server_thread: threading.Thread | None = None
|
||||||
# Bridge is now the request handler itself, no separate instance needed
|
# Bridge is now the request handler itself, no separate instance needed
|
||||||
self._middleware: list[Middleware] = []
|
self._middleware: list[Middleware] = []
|
||||||
|
self.shared_threads = shared_threads if shared_threads is not None else {}
|
||||||
|
|
||||||
def add_middleware(self, middleware: "Middleware") -> None:
|
def add_middleware(self, middleware: "Middleware") -> None:
|
||||||
"""Add middleware to the middleware chain."""
|
"""Add middleware to the middleware chain."""
|
||||||
@@ -58,6 +61,7 @@ class HttpApiServer:
|
|||||||
middleware_chain = tuple(self._middleware)
|
middleware_chain = tuple(self._middleware)
|
||||||
openapi_file = self.openapi
|
openapi_file = self.openapi
|
||||||
swagger_dist = self.swagger_dist
|
swagger_dist = self.swagger_dist
|
||||||
|
shared_threads = self.shared_threads
|
||||||
|
|
||||||
class RequestHandler(HttpBridge):
|
class RequestHandler(HttpBridge):
|
||||||
def __init__(self, request: Any, client_address: Any, server: Any) -> None:
|
def __init__(self, request: Any, client_address: Any, server: Any) -> None:
|
||||||
@@ -69,6 +73,7 @@ class HttpApiServer:
|
|||||||
server=server,
|
server=server,
|
||||||
openapi_file=openapi_file,
|
openapi_file=openapi_file,
|
||||||
swagger_dist=swagger_dist,
|
swagger_dist=swagger_dist,
|
||||||
|
shared_threads=shared_threads,
|
||||||
)
|
)
|
||||||
|
|
||||||
return RequestHandler
|
return RequestHandler
|
||||||
@@ -79,9 +84,9 @@ class HttpApiServer:
|
|||||||
log.warning("HTTP server is already running")
|
log.warning("HTTP server is already running")
|
||||||
return
|
return
|
||||||
|
|
||||||
# Create the server
|
# Create the server using ThreadingHTTPServer for concurrent request handling
|
||||||
handler_class = self._create_request_handler()
|
handler_class = self._create_request_handler()
|
||||||
self._server = HTTPServer((self.host, self.port), handler_class)
|
self._server = ThreadingHTTPServer((self.host, self.port), handler_class)
|
||||||
|
|
||||||
def run_server() -> None:
|
def run_server() -> None:
|
||||||
if self._server:
|
if self._server:
|
||||||
|
|||||||
@@ -2,7 +2,8 @@
|
|||||||
<html lang="en">
|
<html lang="en">
|
||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<title>Swagger UI</title>
|
<title>Swagger UI with Interceptors</title>
|
||||||
|
<!-- Assuming these files are in the same directory -->
|
||||||
<link rel="stylesheet" type="text/css" href="./swagger-ui.css" />
|
<link rel="stylesheet" type="text/css" href="./swagger-ui.css" />
|
||||||
<link rel="stylesheet" type="text/css" href="index.css" />
|
<link rel="stylesheet" type="text/css" href="index.css" />
|
||||||
<link
|
<link
|
||||||
@@ -23,14 +24,100 @@
|
|||||||
<div id="swagger-ui"></div>
|
<div id="swagger-ui"></div>
|
||||||
<script src="./swagger-ui-bundle.js" charset="UTF-8"></script>
|
<script src="./swagger-ui-bundle.js" charset="UTF-8"></script>
|
||||||
<script src="./swagger-ui-standalone-preset.js" charset="UTF-8"></script>
|
<script src="./swagger-ui-standalone-preset.js" charset="UTF-8"></script>
|
||||||
<script src="./swagger-initializer.js" charset="UTF-8"></script>
|
<!-- Your swagger-initializer.js is not needed if you configure directly in the HTML -->
|
||||||
<script>
|
<script>
|
||||||
window.onload = () => {
|
window.onload = () => {
|
||||||
SwaggerUIBundle({
|
SwaggerUIBundle({
|
||||||
url: "./openapi.json", // Path to your OpenAPI 3 spec (YAML or JSON)
|
url: "./openapi.json", // Path to your OpenAPI 3 spec
|
||||||
dom_id: "#swagger-ui",
|
dom_id: "#swagger-ui",
|
||||||
presets: [SwaggerUIBundle.presets.apis, SwaggerUIStandalonePreset],
|
presets: [SwaggerUIBundle.presets.apis, SwaggerUIStandalonePreset],
|
||||||
layout: "StandaloneLayout",
|
layout: "StandaloneLayout",
|
||||||
|
tryItOutEnabled: true,
|
||||||
|
deepLinking: true,
|
||||||
|
displayOperationId: true,
|
||||||
|
|
||||||
|
// --- INTERCEPTORS START HERE ---
|
||||||
|
|
||||||
|
/**
|
||||||
|
* requestInterceptor
|
||||||
|
* This function is called before a request is sent.
|
||||||
|
* It takes the request object and must return a modified request object.
|
||||||
|
* We will use it to wrap the user's input.
|
||||||
|
*/
|
||||||
|
requestInterceptor: (request) => {
|
||||||
|
console.log("Intercepting request:", request);
|
||||||
|
|
||||||
|
// Only modify requests that have a body (like POST, PUT)
|
||||||
|
if (request.body) {
|
||||||
|
try {
|
||||||
|
// The body from the UI is a string, so we parse it to an object.
|
||||||
|
const originalBody = JSON.parse(request.body);
|
||||||
|
|
||||||
|
// Create the new, nested structure.
|
||||||
|
const newBody = {
|
||||||
|
body: originalBody,
|
||||||
|
header: {}, // Add an empty header object as per your example
|
||||||
|
};
|
||||||
|
|
||||||
|
// Replace the original body with the new, stringified, nested structure.
|
||||||
|
request.body = JSON.stringify(newBody);
|
||||||
|
|
||||||
|
// Update the 'Content-Length' header to match the new body size.
|
||||||
|
request.headers["Content-Length"] = new Blob([
|
||||||
|
request.body,
|
||||||
|
]).size;
|
||||||
|
|
||||||
|
console.log("Modified request body:", request.body);
|
||||||
|
} catch (e) {
|
||||||
|
// If the user's input isn't valid JSON, don't modify the request.
|
||||||
|
console.error(
|
||||||
|
"Request Interceptor: Could not parse body as JSON.",
|
||||||
|
e,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return request; // Always return the request object
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* responseInterceptor
|
||||||
|
* This function is called after a response is received, but before it's displayed.
|
||||||
|
* It takes the response object and must return a modified response object.
|
||||||
|
* We will use it to un-nest the data for display.
|
||||||
|
*/
|
||||||
|
responseInterceptor: (response) => {
|
||||||
|
console.log("Intercepting response:", response);
|
||||||
|
|
||||||
|
// Check if the response was successful and has data to process.
|
||||||
|
if (response.ok && response.data) {
|
||||||
|
try {
|
||||||
|
// The response data is a string, so we parse it into an object.
|
||||||
|
const fullResponse = JSON.parse(response.data);
|
||||||
|
|
||||||
|
// Check if the expected 'body' property exists.
|
||||||
|
if (fullResponse && typeof fullResponse.body !== "undefined") {
|
||||||
|
console.log(
|
||||||
|
"Found nested 'body' property. Un-nesting for display.",
|
||||||
|
);
|
||||||
|
|
||||||
|
// Replace the response's data with JUST the nested 'body' object.
|
||||||
|
// We stringify it with pretty-printing (2-space indentation) for readability in the UI.
|
||||||
|
response.data = JSON.stringify(fullResponse.body, null, 2);
|
||||||
|
response.text = response.data; // Also update the 'text' property
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
// If the response isn't the expected JSON structure, do nothing.
|
||||||
|
// This prevents errors on other endpoints that have a normal response.
|
||||||
|
console.error(
|
||||||
|
"Response Interceptor: Could not parse response or un-nest data.",
|
||||||
|
e,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return response; // Always return the response object
|
||||||
|
},
|
||||||
|
|
||||||
|
// --- INTERCEPTORS END HERE ---
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -1,27 +1,33 @@
|
|||||||
"""Tests for HTTP API components."""
|
"""Tests for HTTP API components."""
|
||||||
|
|
||||||
import json
|
import json
|
||||||
|
import logging
|
||||||
|
import threading
|
||||||
import time
|
import time
|
||||||
from unittest.mock import Mock
|
from unittest.mock import Mock
|
||||||
from urllib.request import Request, urlopen
|
from urllib.request import Request, urlopen
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
from clan_lib.api import MethodRegistry
|
from clan_lib.api import MethodRegistry, tasks
|
||||||
|
from clan_lib.async_run import is_async_cancelled
|
||||||
from clan_lib.log_manager import LogManager
|
from clan_lib.log_manager import LogManager
|
||||||
|
|
||||||
from clan_app.api.middleware import (
|
from clan_app.api.middleware import (
|
||||||
ArgumentParsingMiddleware,
|
ArgumentParsingMiddleware,
|
||||||
LoggingMiddleware,
|
|
||||||
MethodExecutionMiddleware,
|
MethodExecutionMiddleware,
|
||||||
)
|
)
|
||||||
from clan_app.deps.http.http_server import HttpApiServer
|
from clan_app.deps.http.http_server import HttpApiServer
|
||||||
|
|
||||||
|
log = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
@pytest.fixture
|
||||||
def mock_api() -> MethodRegistry:
|
def mock_api() -> MethodRegistry:
|
||||||
"""Create a mock API with test methods."""
|
"""Create a mock API with test methods."""
|
||||||
api = MethodRegistry()
|
api = MethodRegistry()
|
||||||
|
|
||||||
|
api.register(tasks.delete_task)
|
||||||
|
|
||||||
@api.register
|
@api.register
|
||||||
def test_method(message: str) -> dict[str, str]:
|
def test_method(message: str) -> dict[str, str]:
|
||||||
return {"response": f"Hello {message}!"}
|
return {"response": f"Hello {message}!"}
|
||||||
@@ -31,6 +37,19 @@ def mock_api() -> MethodRegistry:
|
|||||||
msg = "Test error"
|
msg = "Test error"
|
||||||
raise ValueError(msg)
|
raise ValueError(msg)
|
||||||
|
|
||||||
|
@api.register
|
||||||
|
def run_task_blocking(wtime: int) -> str:
|
||||||
|
"""A long blocking task that simulates a long-running operation."""
|
||||||
|
time.sleep(1)
|
||||||
|
|
||||||
|
for i in range(wtime):
|
||||||
|
if is_async_cancelled():
|
||||||
|
log.debug("Task was cancelled")
|
||||||
|
return "Task was cancelled"
|
||||||
|
log.debug(f"Processing {i} for {wtime}")
|
||||||
|
time.sleep(1)
|
||||||
|
return f"Task completed with wtime: {wtime}"
|
||||||
|
|
||||||
return api
|
return api
|
||||||
|
|
||||||
|
|
||||||
@@ -50,7 +69,7 @@ def http_bridge(
|
|||||||
"""Create HTTP bridge dependencies for testing."""
|
"""Create HTTP bridge dependencies for testing."""
|
||||||
middleware_chain = (
|
middleware_chain = (
|
||||||
ArgumentParsingMiddleware(api=mock_api),
|
ArgumentParsingMiddleware(api=mock_api),
|
||||||
LoggingMiddleware(log_manager=mock_log_manager),
|
# LoggingMiddleware(log_manager=mock_log_manager),
|
||||||
MethodExecutionMiddleware(api=mock_api),
|
MethodExecutionMiddleware(api=mock_api),
|
||||||
)
|
)
|
||||||
return mock_api, middleware_chain
|
return mock_api, middleware_chain
|
||||||
@@ -67,7 +86,7 @@ def http_server(mock_api: MethodRegistry, mock_log_manager: Mock) -> HttpApiServ
|
|||||||
|
|
||||||
# Add middleware
|
# Add middleware
|
||||||
server.add_middleware(ArgumentParsingMiddleware(api=mock_api))
|
server.add_middleware(ArgumentParsingMiddleware(api=mock_api))
|
||||||
server.add_middleware(LoggingMiddleware(log_manager=mock_log_manager))
|
# server.add_middleware(LoggingMiddleware(log_manager=mock_log_manager))
|
||||||
server.add_middleware(MethodExecutionMiddleware(api=mock_api))
|
server.add_middleware(MethodExecutionMiddleware(api=mock_api))
|
||||||
|
|
||||||
# Bridge will be created automatically when accessed
|
# Bridge will be created automatically when accessed
|
||||||
@@ -84,7 +103,7 @@ class TestHttpBridge:
|
|||||||
# We'll test initialization through the server
|
# We'll test initialization through the server
|
||||||
api, middleware_chain = http_bridge
|
api, middleware_chain = http_bridge
|
||||||
assert api is not None
|
assert api is not None
|
||||||
assert len(middleware_chain) == 3
|
assert len(middleware_chain) == 2
|
||||||
|
|
||||||
def test_http_bridge_middleware_setup(self, http_bridge: tuple) -> None:
|
def test_http_bridge_middleware_setup(self, http_bridge: tuple) -> None:
|
||||||
"""Test that middleware is properly set up."""
|
"""Test that middleware is properly set up."""
|
||||||
@@ -92,10 +111,10 @@ class TestHttpBridge:
|
|||||||
|
|
||||||
# Test that we can create the bridge with middleware
|
# Test that we can create the bridge with middleware
|
||||||
# The actual HTTP handling will be tested through the server integration tests
|
# The actual HTTP handling will be tested through the server integration tests
|
||||||
assert len(middleware_chain) == 3
|
assert len(middleware_chain) == 2
|
||||||
assert isinstance(middleware_chain[0], ArgumentParsingMiddleware)
|
assert isinstance(middleware_chain[0], ArgumentParsingMiddleware)
|
||||||
assert isinstance(middleware_chain[1], LoggingMiddleware)
|
# assert isinstance(middleware_chain[1], LoggingMiddleware)
|
||||||
assert isinstance(middleware_chain[2], MethodExecutionMiddleware)
|
assert isinstance(middleware_chain[1], MethodExecutionMiddleware)
|
||||||
|
|
||||||
|
|
||||||
class TestHttpApiServer:
|
class TestHttpApiServer:
|
||||||
@@ -248,7 +267,7 @@ class TestIntegration:
|
|||||||
|
|
||||||
# Add middleware
|
# Add middleware
|
||||||
server.add_middleware(ArgumentParsingMiddleware(api=mock_api))
|
server.add_middleware(ArgumentParsingMiddleware(api=mock_api))
|
||||||
server.add_middleware(LoggingMiddleware(log_manager=mock_log_manager))
|
# server.add_middleware(LoggingMiddleware(log_manager=mock_log_manager))
|
||||||
server.add_middleware(MethodExecutionMiddleware(api=mock_api))
|
server.add_middleware(MethodExecutionMiddleware(api=mock_api))
|
||||||
|
|
||||||
# Bridge will be created automatically when accessed
|
# Bridge will be created automatically when accessed
|
||||||
@@ -281,6 +300,73 @@ class TestIntegration:
|
|||||||
# Always stop server
|
# Always stop server
|
||||||
server.stop()
|
server.stop()
|
||||||
|
|
||||||
|
def test_blocking_task(
|
||||||
|
self, mock_api: MethodRegistry, mock_log_manager: Mock
|
||||||
|
) -> None:
|
||||||
|
shared_threads: dict[str, tasks.WebThread] = {}
|
||||||
|
tasks.BAKEND_THREADS = shared_threads
|
||||||
|
|
||||||
|
"""Test a long-running blocking task."""
|
||||||
|
server: HttpApiServer = HttpApiServer(
|
||||||
|
api=mock_api,
|
||||||
|
host="127.0.0.1",
|
||||||
|
port=8083,
|
||||||
|
shared_threads=shared_threads,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Add middleware
|
||||||
|
server.add_middleware(ArgumentParsingMiddleware(api=mock_api))
|
||||||
|
# server.add_middleware(LoggingMiddleware(log_manager=mock_log_manager))
|
||||||
|
server.add_middleware(MethodExecutionMiddleware(api=mock_api))
|
||||||
|
|
||||||
|
# Start server
|
||||||
|
server.start()
|
||||||
|
time.sleep(0.1) # Give server time to start
|
||||||
|
|
||||||
|
blocking_op_key = "b37f920f-ce8c-4c8d-b595-28ca983d265e" # str(uuid.uuid4())
|
||||||
|
|
||||||
|
def parallel_task() -> None:
|
||||||
|
# Make API call
|
||||||
|
request_data: dict = {
|
||||||
|
"body": {"wtime": 60},
|
||||||
|
"header": {"op_key": blocking_op_key},
|
||||||
|
}
|
||||||
|
req: Request = Request(
|
||||||
|
"http://127.0.0.1:8083/api/v1/run_task_blocking",
|
||||||
|
data=json.dumps(request_data).encode(),
|
||||||
|
headers={"Content-Type": "application/json"},
|
||||||
|
)
|
||||||
|
response = urlopen(req)
|
||||||
|
data: dict = json.loads(response.read().decode())
|
||||||
|
|
||||||
|
# thread.join()
|
||||||
|
assert "body" in data
|
||||||
|
assert data["body"]["status"] == "success"
|
||||||
|
assert data["body"]["data"] == "Task was cancelled"
|
||||||
|
|
||||||
|
thread = threading.Thread(
|
||||||
|
target=parallel_task,
|
||||||
|
name="ParallelTaskThread",
|
||||||
|
daemon=True,
|
||||||
|
)
|
||||||
|
thread.start()
|
||||||
|
|
||||||
|
time.sleep(1)
|
||||||
|
request_data: dict = {
|
||||||
|
"body": {"task_id": blocking_op_key},
|
||||||
|
}
|
||||||
|
req: Request = Request(
|
||||||
|
"http://127.0.0.1:8083/api/v1/delete_task",
|
||||||
|
data=json.dumps(request_data).encode(),
|
||||||
|
headers={"Content-Type": "application/json"},
|
||||||
|
)
|
||||||
|
response = urlopen(req)
|
||||||
|
data: dict = json.loads(response.read().decode())
|
||||||
|
|
||||||
|
assert "body" in data
|
||||||
|
assert "header" in data
|
||||||
|
assert data["body"]["status"] == "success"
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
pytest.main([__file__, "-v"])
|
pytest.main([__file__, "-v"])
|
||||||
|
|||||||
@@ -45,6 +45,7 @@ class Webview:
|
|||||||
debug: bool = False
|
debug: bool = False
|
||||||
size: Size | None = None
|
size: Size | None = None
|
||||||
window: int | None = None
|
window: int | None = None
|
||||||
|
shared_threads: dict[str, WebThread] | None = None
|
||||||
|
|
||||||
# initialized later
|
# initialized later
|
||||||
_bridge: "WebviewBridge | None" = None
|
_bridge: "WebviewBridge | None" = None
|
||||||
@@ -116,7 +117,17 @@ class Webview:
|
|||||||
"""Create and initialize the WebviewBridge with current middleware."""
|
"""Create and initialize the WebviewBridge with current middleware."""
|
||||||
from .webview_bridge import WebviewBridge
|
from .webview_bridge import WebviewBridge
|
||||||
|
|
||||||
bridge = WebviewBridge(webview=self, middleware_chain=tuple(self._middleware))
|
# Use shared_threads if provided, otherwise let WebviewBridge use its default
|
||||||
|
if self.shared_threads is not None:
|
||||||
|
bridge = WebviewBridge(
|
||||||
|
webview=self,
|
||||||
|
middleware_chain=tuple(self._middleware),
|
||||||
|
threads=self.shared_threads,
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
bridge = WebviewBridge(
|
||||||
|
webview=self, middleware_chain=tuple(self._middleware), threads={}
|
||||||
|
)
|
||||||
self._bridge = bridge
|
self._bridge = bridge
|
||||||
return bridge
|
return bridge
|
||||||
|
|
||||||
|
|||||||
@@ -1,12 +1,10 @@
|
|||||||
import json
|
import json
|
||||||
import logging
|
import logging
|
||||||
import threading
|
from dataclasses import dataclass
|
||||||
from dataclasses import dataclass, field
|
|
||||||
from typing import TYPE_CHECKING
|
from typing import TYPE_CHECKING
|
||||||
|
|
||||||
from clan_lib.api import dataclass_to_dict
|
from clan_lib.api import dataclass_to_dict
|
||||||
from clan_lib.api.tasks import WebThread
|
from clan_lib.api.tasks import WebThread
|
||||||
from clan_lib.async_run import set_should_cancel
|
|
||||||
|
|
||||||
from clan_app.api.api_bridge import ApiBridge, BackendRequest, BackendResponse
|
from clan_app.api.api_bridge import ApiBridge, BackendRequest, BackendResponse
|
||||||
|
|
||||||
@@ -23,7 +21,7 @@ class WebviewBridge(ApiBridge):
|
|||||||
"""Webview-specific implementation of the API bridge."""
|
"""Webview-specific implementation of the API bridge."""
|
||||||
|
|
||||||
webview: "Webview"
|
webview: "Webview"
|
||||||
threads: dict[str, WebThread] = field(default_factory=dict)
|
threads: dict[str, WebThread] # Inherited from ApiBridge
|
||||||
|
|
||||||
def send_api_response(self, response: BackendResponse) -> None:
|
def send_api_response(self, response: BackendResponse) -> None:
|
||||||
"""Send response back to the webview client."""
|
"""Send response back to the webview client."""
|
||||||
@@ -84,21 +82,9 @@ class WebviewBridge(ApiBridge):
|
|||||||
)
|
)
|
||||||
return
|
return
|
||||||
|
|
||||||
# Process in a separate thread
|
# Process in a separate thread using the inherited method
|
||||||
def thread_task(stop_event: threading.Event) -> None:
|
self.process_request_in_thread(
|
||||||
set_should_cancel(lambda: stop_event.is_set())
|
api_request,
|
||||||
|
thread_name="WebviewThread",
|
||||||
try:
|
wait_for_completion=False,
|
||||||
log.debug(
|
|
||||||
f"Calling {method_name}({json.dumps(api_request.args, indent=4)}) with header {json.dumps(api_request.header, indent=4)} and op_key {op_key}"
|
|
||||||
)
|
|
||||||
self.process_request(api_request)
|
|
||||||
finally:
|
|
||||||
self.threads.pop(op_key, None)
|
|
||||||
|
|
||||||
stop_event = threading.Event()
|
|
||||||
thread = threading.Thread(
|
|
||||||
target=thread_task, args=(stop_event,), name="WebviewThread"
|
|
||||||
)
|
)
|
||||||
thread.start()
|
|
||||||
self.threads[op_key] = WebThread(thread=thread, stop_event=stop_event)
|
|
||||||
|
|||||||
@@ -21,7 +21,7 @@ exclude = ["result", "**/__pycache__"]
|
|||||||
clan_app = ["**/assets/*"]
|
clan_app = ["**/assets/*"]
|
||||||
|
|
||||||
[tool.pytest.ini_options]
|
[tool.pytest.ini_options]
|
||||||
testpaths = "tests"
|
testpaths = [ "tests", "clan_app" ]
|
||||||
faulthandler_timeout = 60
|
faulthandler_timeout = 60
|
||||||
log_level = "DEBUG"
|
log_level = "DEBUG"
|
||||||
log_format = "%(levelname)s: %(message)s\n %(pathname)s:%(lineno)d::%(funcName)s"
|
log_format = "%(levelname)s: %(message)s\n %(pathname)s:%(lineno)d::%(funcName)s"
|
||||||
|
|||||||
@@ -58,6 +58,7 @@ mkShell {
|
|||||||
with ps;
|
with ps;
|
||||||
[
|
[
|
||||||
mypy
|
mypy
|
||||||
|
pytest-cov
|
||||||
]
|
]
|
||||||
++ (clan-app.devshellPyDeps ps)
|
++ (clan-app.devshellPyDeps ps)
|
||||||
))
|
))
|
||||||
|
|||||||
@@ -1,5 +1,11 @@
|
|||||||
// Working SolidJS + Three.js cube scene with grid arrangement
|
// Working SolidJS + Three.js cube scene with reactive positioning
|
||||||
import { createSignal, createEffect, onCleanup, onMount } from "solid-js";
|
import {
|
||||||
|
createSignal,
|
||||||
|
createEffect,
|
||||||
|
onCleanup,
|
||||||
|
onMount,
|
||||||
|
createMemo,
|
||||||
|
} from "solid-js";
|
||||||
import * as THREE from "three";
|
import * as THREE from "three";
|
||||||
|
|
||||||
// Cube Data Model
|
// Cube Data Model
|
||||||
@@ -29,27 +35,117 @@ export function CubeScene() {
|
|||||||
let isAnimating = false; // Flag to prevent multiple loops
|
let isAnimating = false; // Flag to prevent multiple loops
|
||||||
let frameCount = 0;
|
let frameCount = 0;
|
||||||
|
|
||||||
const [cubes, setCubes] = createSignal<CubeData[]>([]);
|
const [ids, setIds] = createSignal<string[]>([]);
|
||||||
const [selectedIds, setSelectedIds] = createSignal<Set<string>>(new Set());
|
const [selectedIds, setSelectedIds] = createSignal<Set<string>>(new Set());
|
||||||
|
const [deletingIds, setDeletingIds] = createSignal<Set<string>>(new Set());
|
||||||
|
const [creatingIds, setCreatingIds] = createSignal<Set<string>>(new Set());
|
||||||
const [cameraInfo, setCameraInfo] = createSignal({
|
const [cameraInfo, setCameraInfo] = createSignal({
|
||||||
position: { x: 0, y: 0, z: 0 },
|
position: { x: 0, y: 0, z: 0 },
|
||||||
spherical: { radius: 0, theta: 0, phi: 0 },
|
spherical: { radius: 0, theta: 0, phi: 0 },
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Animation configuration
|
||||||
|
const ANIMATION_DURATION = 800; // milliseconds
|
||||||
|
const DELETE_ANIMATION_DURATION = 400; // milliseconds
|
||||||
|
const CREATE_ANIMATION_DURATION = 600; // milliseconds
|
||||||
|
|
||||||
// Grid configuration
|
// Grid configuration
|
||||||
const GRID_SIZE = 10;
|
const GRID_SIZE = 2;
|
||||||
const CUBE_SPACING = 2;
|
const CUBE_SPACING = 2;
|
||||||
|
|
||||||
// Calculate grid position for a cube index with floating effect
|
// Calculate grid position for a cube index with floating effect
|
||||||
function getGridPosition(index: number): [number, number, number] {
|
// function getGridPosition(index: number): [number, number, number] {
|
||||||
const x =
|
// const x =
|
||||||
(index % GRID_SIZE) * CUBE_SPACING - (GRID_SIZE * CUBE_SPACING) / 2;
|
// (index % GRID_SIZE) * CUBE_SPACING - (GRID_SIZE * CUBE_SPACING) / 2;
|
||||||
const z =
|
// const z =
|
||||||
Math.floor(index / GRID_SIZE) * CUBE_SPACING -
|
// Math.floor(index / GRID_SIZE) * CUBE_SPACING -
|
||||||
(GRID_SIZE * CUBE_SPACING) / 2;
|
// (GRID_SIZE * CUBE_SPACING) / 2;
|
||||||
|
// return [x, 0.5, z];
|
||||||
|
// }
|
||||||
|
// function getGridPosition(index: number): [number, number, number] {
|
||||||
|
// if (index === 0) return [0, 0.5, 0];
|
||||||
|
|
||||||
|
// let x = 0, z = 0;
|
||||||
|
// let layer = 1;
|
||||||
|
// let value = 1;
|
||||||
|
|
||||||
|
// while (true) {
|
||||||
|
// // right
|
||||||
|
// for (let i = 0; i < layer; i++) {
|
||||||
|
// x += 1;
|
||||||
|
// if (value++ === index) return [x * CUBE_SPACING, 0.5, z * CUBE_SPACING];
|
||||||
|
// }
|
||||||
|
// // down
|
||||||
|
// for (let i = 0; i < layer; i++) {
|
||||||
|
// z += 1;
|
||||||
|
// if (value++ === index) return [x * CUBE_SPACING, 0.5, z * CUBE_SPACING];
|
||||||
|
// }
|
||||||
|
// layer++;
|
||||||
|
// // left
|
||||||
|
// for (let i = 0; i < layer; i++) {
|
||||||
|
// x -= 1;
|
||||||
|
// if (value++ === index) return [x * CUBE_SPACING, 0.5, z * CUBE_SPACING];
|
||||||
|
// }
|
||||||
|
// // up
|
||||||
|
// for (let i = 0; i < layer; i++) {
|
||||||
|
// z -= 1;
|
||||||
|
// if (value++ === index) return [x * CUBE_SPACING, 0.5, z * CUBE_SPACING];
|
||||||
|
// }
|
||||||
|
// layer++;
|
||||||
|
|
||||||
|
// if (layer > 100) {
|
||||||
|
// console.warn("Exceeded grid size, returning last position");
|
||||||
|
// // If we exceed the index, return the last position
|
||||||
|
// return [x * CUBE_SPACING, 0.5, z * CUBE_SPACING];
|
||||||
|
// }
|
||||||
|
// }
|
||||||
|
// }
|
||||||
|
|
||||||
|
// Circle IDEA:
|
||||||
|
// Need to talk with timo and W about this
|
||||||
|
function getCirclePosition(
|
||||||
|
index: number,
|
||||||
|
total: number,
|
||||||
|
): [number, number, number] {
|
||||||
|
const r = Math.sqrt(total) * CUBE_SPACING; // Radius based on total cubes
|
||||||
|
const x = Math.cos((index / total) * 2 * Math.PI) * r;
|
||||||
|
const z = Math.sin((index / total) * 2 * Math.PI) * r;
|
||||||
|
// Position cubes at y = 0.5 to float above the ground
|
||||||
return [x, 0.5, z];
|
return [x, 0.5, z];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Reactive cubes memo - this recalculates whenever ids() changes
|
||||||
|
const cubes = createMemo(() => {
|
||||||
|
const currentIds = ids();
|
||||||
|
const deleting = deletingIds();
|
||||||
|
const creating = creatingIds();
|
||||||
|
|
||||||
|
// Include both active and deleting cubes for smooth transitions
|
||||||
|
const allIds = [...new Set([...currentIds, ...Array.from(deleting)])];
|
||||||
|
|
||||||
|
return allIds.map((id, index) => {
|
||||||
|
const isDeleting = deleting.has(id);
|
||||||
|
const isCreating = creating.has(id);
|
||||||
|
const activeIndex = currentIds.indexOf(id);
|
||||||
|
|
||||||
|
return {
|
||||||
|
id,
|
||||||
|
position: getCirclePosition(
|
||||||
|
isDeleting ? -1 : activeIndex >= 0 ? activeIndex : index,
|
||||||
|
currentIds.length,
|
||||||
|
),
|
||||||
|
// position: getGridPosition(isDeleting ? -1 : activeIndex >= 0 ? activeIndex : index),
|
||||||
|
isDeleting,
|
||||||
|
isCreating,
|
||||||
|
// targetPosition: activeIndex >= 0 ? getGridPosition(activeIndex) : getGridPosition(index),
|
||||||
|
targetPosition:
|
||||||
|
activeIndex >= 0
|
||||||
|
? getCirclePosition(activeIndex, currentIds.length)
|
||||||
|
: getCirclePosition(index, currentIds.length),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
// Create multi-colored cube materials for different faces
|
// Create multi-colored cube materials for different faces
|
||||||
function createCubeMaterials() {
|
function createCubeMaterials() {
|
||||||
const materials = [
|
const materials = [
|
||||||
@@ -62,6 +158,7 @@ export function CubeScene() {
|
|||||||
];
|
];
|
||||||
return materials;
|
return materials;
|
||||||
}
|
}
|
||||||
|
|
||||||
function createBaseMaterials() {
|
function createBaseMaterials() {
|
||||||
const materials = [
|
const materials = [
|
||||||
new THREE.MeshBasicMaterial({ color: 0xdce4e5 }), // Right face - medium
|
new THREE.MeshBasicMaterial({ color: 0xdce4e5 }), // Right face - medium
|
||||||
@@ -74,7 +171,154 @@ export function CubeScene() {
|
|||||||
return materials;
|
return materials;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Create white base for cube
|
// Animation helper function
|
||||||
|
function animateToPosition(
|
||||||
|
mesh: THREE.Mesh,
|
||||||
|
targetPosition: [number, number, number],
|
||||||
|
duration: number = ANIMATION_DURATION,
|
||||||
|
) {
|
||||||
|
const startPosition = mesh.position.clone();
|
||||||
|
const endPosition = new THREE.Vector3(...targetPosition);
|
||||||
|
const startTime = Date.now();
|
||||||
|
|
||||||
|
function animate() {
|
||||||
|
const elapsed = Date.now() - startTime;
|
||||||
|
const progress = Math.min(elapsed / duration, 1);
|
||||||
|
|
||||||
|
// Smooth easing function
|
||||||
|
const easeProgress = 1 - Math.pow(1 - progress, 3);
|
||||||
|
|
||||||
|
mesh.position.lerpVectors(startPosition, endPosition, easeProgress);
|
||||||
|
|
||||||
|
if (progress < 1) {
|
||||||
|
requestAnimationFrame(animate);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
animate();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create animation helper
|
||||||
|
function animateCreate(
|
||||||
|
mesh: THREE.Mesh,
|
||||||
|
baseMesh: THREE.Mesh,
|
||||||
|
onComplete: () => void,
|
||||||
|
) {
|
||||||
|
const startTime = Date.now();
|
||||||
|
|
||||||
|
// Start with zero scale and full opacity
|
||||||
|
mesh.scale.setScalar(0);
|
||||||
|
baseMesh.scale.setScalar(0);
|
||||||
|
|
||||||
|
// Ensure materials are fully opaque
|
||||||
|
if (Array.isArray(mesh.material)) {
|
||||||
|
mesh.material.forEach((material) => {
|
||||||
|
(material as THREE.MeshBasicMaterial).opacity = 1;
|
||||||
|
material.transparent = false;
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
(mesh.material as THREE.MeshBasicMaterial).opacity = 1;
|
||||||
|
mesh.material.transparent = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Array.isArray(baseMesh.material)) {
|
||||||
|
baseMesh.material.forEach((material) => {
|
||||||
|
(material as THREE.MeshBasicMaterial).opacity = 1;
|
||||||
|
material.transparent = false;
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
(baseMesh.material as THREE.MeshBasicMaterial).opacity = 1;
|
||||||
|
baseMesh.material.transparent = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
function animate() {
|
||||||
|
const elapsed = Date.now() - startTime;
|
||||||
|
const progress = Math.min(elapsed / CREATE_ANIMATION_DURATION, 1);
|
||||||
|
|
||||||
|
// Smooth easing function with slight overshoot effect
|
||||||
|
let easeProgress;
|
||||||
|
if (progress < 0.8) {
|
||||||
|
// First 80% - smooth scale up
|
||||||
|
easeProgress = 1 - Math.pow(1 - progress / 0.8, 3);
|
||||||
|
} else {
|
||||||
|
// Last 20% - slight overshoot and settle
|
||||||
|
const overshootProgress = (progress - 0.8) / 0.2;
|
||||||
|
const overshoot = Math.sin(overshootProgress * Math.PI) * 0.1;
|
||||||
|
easeProgress = 1 + overshoot;
|
||||||
|
}
|
||||||
|
|
||||||
|
const scale = easeProgress;
|
||||||
|
mesh.scale.setScalar(scale);
|
||||||
|
baseMesh.scale.setScalar(scale);
|
||||||
|
|
||||||
|
if (progress >= 1) {
|
||||||
|
// Ensure final scale is exactly 1
|
||||||
|
mesh.scale.setScalar(1);
|
||||||
|
baseMesh.scale.setScalar(1);
|
||||||
|
onComplete();
|
||||||
|
} else {
|
||||||
|
requestAnimationFrame(animate);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
animate();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Delete animation helper
|
||||||
|
function animateDelete(
|
||||||
|
mesh: THREE.Mesh,
|
||||||
|
baseMesh: THREE.Mesh,
|
||||||
|
onComplete: () => void,
|
||||||
|
) {
|
||||||
|
const startTime = Date.now();
|
||||||
|
const startScale = mesh.scale.clone();
|
||||||
|
const startOpacity = Array.isArray(mesh.material)
|
||||||
|
? (mesh.material[0] as THREE.MeshBasicMaterial).opacity
|
||||||
|
: (mesh.material as THREE.MeshBasicMaterial).opacity;
|
||||||
|
|
||||||
|
function animate() {
|
||||||
|
const elapsed = Date.now() - startTime;
|
||||||
|
const progress = Math.min(elapsed / DELETE_ANIMATION_DURATION, 1);
|
||||||
|
|
||||||
|
// Smooth easing function
|
||||||
|
const easeProgress = 1 - Math.pow(1 - progress, 3);
|
||||||
|
const scale = 1 - easeProgress;
|
||||||
|
const opacity = startOpacity * (1 - easeProgress);
|
||||||
|
|
||||||
|
mesh.scale.setScalar(scale);
|
||||||
|
baseMesh.scale.setScalar(scale);
|
||||||
|
|
||||||
|
// Update opacity for all materials
|
||||||
|
if (Array.isArray(mesh.material)) {
|
||||||
|
mesh.material.forEach((material) => {
|
||||||
|
(material as THREE.MeshBasicMaterial).opacity = opacity;
|
||||||
|
material.transparent = true;
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
(mesh.material as THREE.MeshBasicMaterial).opacity = opacity;
|
||||||
|
mesh.material.transparent = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Array.isArray(baseMesh.material)) {
|
||||||
|
baseMesh.material.forEach((material) => {
|
||||||
|
(material as THREE.MeshBasicMaterial).opacity = opacity;
|
||||||
|
material.transparent = true;
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
(baseMesh.material as THREE.MeshBasicMaterial).opacity = opacity;
|
||||||
|
baseMesh.material.transparent = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (progress >= 1) {
|
||||||
|
onComplete();
|
||||||
|
} else {
|
||||||
|
requestAnimationFrame(animate);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
animate();
|
||||||
|
}
|
||||||
|
|
||||||
function createCubeBase(cube_pos: [number, number, number]) {
|
function createCubeBase(cube_pos: [number, number, number]) {
|
||||||
const baseMaterials = createBaseMaterials();
|
const baseMaterials = createBaseMaterials();
|
||||||
const base = new THREE.Mesh(sharedBaseGeometry, baseMaterials);
|
const base = new THREE.Mesh(sharedBaseGeometry, baseMaterials);
|
||||||
@@ -87,45 +331,55 @@ export function CubeScene() {
|
|||||||
// === Add/Delete Cube API ===
|
// === Add/Delete Cube API ===
|
||||||
function addCube() {
|
function addCube() {
|
||||||
const id = crypto.randomUUID();
|
const id = crypto.randomUUID();
|
||||||
const currentCount = cubes().length;
|
|
||||||
const cube: CubeData = {
|
// Add to creating set first
|
||||||
id,
|
setCreatingIds((prev) => new Set([...prev, id]));
|
||||||
position: getGridPosition(currentCount),
|
|
||||||
color: "blue",
|
// Add to ids
|
||||||
};
|
setIds((prev) => [...prev, id]);
|
||||||
setCubes((prev) => [...prev, cube]);
|
|
||||||
|
// Remove from creating set after animation completes
|
||||||
|
setTimeout(() => {
|
||||||
|
setCreatingIds((prev) => {
|
||||||
|
const next = new Set(prev);
|
||||||
|
next.delete(id);
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
}, CREATE_ANIMATION_DURATION);
|
||||||
|
}
|
||||||
|
|
||||||
|
function deleteSelectedCubes(selectedSet: Set<string>) {
|
||||||
|
if (selectedSet.size === 0) return;
|
||||||
|
|
||||||
|
// Add to deleting set to start animation
|
||||||
|
setDeletingIds(selectedSet);
|
||||||
|
|
||||||
|
// Start delete animations
|
||||||
|
selectedSet.forEach((id) => {
|
||||||
|
const mesh = meshMap.get(id);
|
||||||
|
const base = baseMap.get(id);
|
||||||
|
|
||||||
|
if (mesh && base) {
|
||||||
|
animateDelete(mesh, base, () => {
|
||||||
|
// Remove from deleting set when animation completes
|
||||||
|
setDeletingIds((prev) => {
|
||||||
|
const next = new Set(prev);
|
||||||
|
next.delete(id);
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Remove from ids after a short delay to allow animation to start
|
||||||
|
setTimeout(() => {
|
||||||
|
setIds((prev) => prev.filter((id) => !selectedSet.has(id)));
|
||||||
|
setSelectedIds(new Set<string>()); // Clear selection after deletion
|
||||||
|
}, 50);
|
||||||
}
|
}
|
||||||
|
|
||||||
function deleteCube(id: string) {
|
function deleteCube(id: string) {
|
||||||
// Remove cube mesh
|
deleteSelectedCubes(new Set([id]));
|
||||||
const mesh = meshMap.get(id);
|
|
||||||
if (mesh) {
|
|
||||||
scene.remove(mesh);
|
|
||||||
mesh.geometry.dispose();
|
|
||||||
// Dispose materials properly
|
|
||||||
if (Array.isArray(mesh.material)) {
|
|
||||||
mesh.material.forEach((material) => material.dispose());
|
|
||||||
} else {
|
|
||||||
mesh.material.dispose();
|
|
||||||
}
|
|
||||||
meshMap.delete(id);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Remove base mesh - THIS WAS MISSING!
|
|
||||||
const base = baseMap.get(id);
|
|
||||||
if (base) {
|
|
||||||
scene.remove(base);
|
|
||||||
base.geometry.dispose();
|
|
||||||
// Dispose base materials properly
|
|
||||||
if (Array.isArray(base.material)) {
|
|
||||||
base.material.forEach((material) => material.dispose());
|
|
||||||
} else {
|
|
||||||
base.material.dispose();
|
|
||||||
}
|
|
||||||
baseMap.delete(id);
|
|
||||||
}
|
|
||||||
|
|
||||||
setCubes((prev) => prev.filter((c) => c.id !== id));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function toggleSelection(id: string) {
|
function toggleSelection(id: string) {
|
||||||
@@ -186,7 +440,8 @@ export function CubeScene() {
|
|||||||
onMount(() => {
|
onMount(() => {
|
||||||
// Scene setup
|
// Scene setup
|
||||||
scene = new THREE.Scene();
|
scene = new THREE.Scene();
|
||||||
scene.background = new THREE.Color(0xf0f0f0);
|
// Transparent background
|
||||||
|
scene.background = null;
|
||||||
|
|
||||||
// Camera setup
|
// Camera setup
|
||||||
camera = new THREE.PerspectiveCamera(
|
camera = new THREE.PerspectiveCamera(
|
||||||
@@ -199,7 +454,7 @@ export function CubeScene() {
|
|||||||
camera.lookAt(0, 0, 0);
|
camera.lookAt(0, 0, 0);
|
||||||
|
|
||||||
// Renderer setup
|
// Renderer setup
|
||||||
renderer = new THREE.WebGLRenderer({ antialias: true });
|
renderer = new THREE.WebGLRenderer({ antialias: true, alpha: true });
|
||||||
renderer.setSize(container.clientWidth, container.clientHeight);
|
renderer.setSize(container.clientWidth, container.clientHeight);
|
||||||
renderer.shadowMap.enabled = true;
|
renderer.shadowMap.enabled = true;
|
||||||
renderer.shadowMap.type = THREE.PCFSoftShadowMap;
|
renderer.shadowMap.type = THREE.PCFSoftShadowMap;
|
||||||
@@ -378,14 +633,20 @@ export function CubeScene() {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
// Effect to manage cube meshes
|
// Effect to manage cube meshes - this runs whenever cubes() changes
|
||||||
createEffect(() => {
|
createEffect(() => {
|
||||||
|
const currentCubes = cubes();
|
||||||
const existing = new Set(meshMap.keys());
|
const existing = new Set(meshMap.keys());
|
||||||
|
const deleting = deletingIds();
|
||||||
|
const creating = creatingIds();
|
||||||
|
|
||||||
// Update existing cubes and create new ones
|
// Update existing cubes and create new ones
|
||||||
cubes().forEach((cube) => {
|
currentCubes.forEach((cube) => {
|
||||||
if (!meshMap.has(cube.id)) {
|
const existingMesh = meshMap.get(cube.id);
|
||||||
// Create cube mesh
|
const existingBase = baseMap.get(cube.id);
|
||||||
|
|
||||||
|
if (!existingMesh) {
|
||||||
|
// Create new cube mesh
|
||||||
const cubeMaterials = createCubeMaterials();
|
const cubeMaterials = createCubeMaterials();
|
||||||
const mesh = new THREE.Mesh(sharedCubeGeometry, cubeMaterials);
|
const mesh = new THREE.Mesh(sharedCubeGeometry, cubeMaterials);
|
||||||
mesh.castShadow = true;
|
mesh.castShadow = true;
|
||||||
@@ -395,24 +656,123 @@ export function CubeScene() {
|
|||||||
scene.add(mesh);
|
scene.add(mesh);
|
||||||
meshMap.set(cube.id, mesh);
|
meshMap.set(cube.id, mesh);
|
||||||
|
|
||||||
// Create base mesh
|
// Create new base mesh
|
||||||
const base = createCubeBase(cube.position);
|
const base = createCubeBase(cube.position);
|
||||||
base.userData.id = cube.id;
|
base.userData.id = cube.id;
|
||||||
scene.add(base);
|
scene.add(base);
|
||||||
baseMap.set(cube.id, base);
|
baseMap.set(cube.id, base);
|
||||||
|
|
||||||
|
// Start create animation if this cube is being created
|
||||||
|
if (creating.has(cube.id)) {
|
||||||
|
animateCreate(mesh, base, () => {
|
||||||
|
// Animation complete callback - could add additional logic here
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} else if (!deleting.has(cube.id)) {
|
||||||
|
// Only animate position if not being deleted
|
||||||
|
const targetPosition = cube.targetPosition || cube.position;
|
||||||
|
const currentPosition = existingMesh.position.toArray() as [
|
||||||
|
number,
|
||||||
|
number,
|
||||||
|
number,
|
||||||
|
];
|
||||||
|
const target = targetPosition;
|
||||||
|
|
||||||
|
// Check if position actually changed
|
||||||
|
if (
|
||||||
|
Math.abs(currentPosition[0] - target[0]) > 0.01 ||
|
||||||
|
Math.abs(currentPosition[1] - target[1]) > 0.01 ||
|
||||||
|
Math.abs(currentPosition[2] - target[2]) > 0.01
|
||||||
|
) {
|
||||||
|
animateToPosition(existingMesh, target);
|
||||||
|
|
||||||
|
if (existingBase) {
|
||||||
|
animateToPosition(existingBase, [
|
||||||
|
target[0],
|
||||||
|
target[1] - 0.5 - 0.025,
|
||||||
|
target[2],
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
existing.delete(cube.id);
|
existing.delete(cube.id);
|
||||||
});
|
});
|
||||||
|
|
||||||
// Remove cubes that are no longer in the state
|
// Remove cubes that are no longer in the state and not being deleted
|
||||||
existing.forEach((id) => {
|
existing.forEach((id) => {
|
||||||
deleteCube(id);
|
if (!deleting.has(id)) {
|
||||||
|
// Remove cube mesh
|
||||||
|
const mesh = meshMap.get(id);
|
||||||
|
if (mesh) {
|
||||||
|
scene.remove(mesh);
|
||||||
|
mesh.geometry.dispose();
|
||||||
|
// Dispose materials properly
|
||||||
|
if (Array.isArray(mesh.material)) {
|
||||||
|
mesh.material.forEach((material) => material.dispose());
|
||||||
|
} else {
|
||||||
|
mesh.material.dispose();
|
||||||
|
}
|
||||||
|
meshMap.delete(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Remove base mesh
|
||||||
|
const base = baseMap.get(id);
|
||||||
|
if (base) {
|
||||||
|
scene.remove(base);
|
||||||
|
base.geometry.dispose();
|
||||||
|
// Dispose base materials properly
|
||||||
|
if (Array.isArray(base.material)) {
|
||||||
|
base.material.forEach((material) => material.dispose());
|
||||||
|
} else {
|
||||||
|
base.material.dispose();
|
||||||
|
}
|
||||||
|
baseMap.delete(id);
|
||||||
|
}
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
updateMeshColors();
|
updateMeshColors();
|
||||||
});
|
});
|
||||||
|
|
||||||
// Effect to update colors when selection changes
|
// Effect to clean up deleted cubes after animation
|
||||||
|
createEffect(() => {
|
||||||
|
const deleting = deletingIds();
|
||||||
|
const currentIds = ids();
|
||||||
|
|
||||||
|
// Clean up cubes that finished their delete animation
|
||||||
|
deleting.forEach((id) => {
|
||||||
|
if (!currentIds.includes(id)) {
|
||||||
|
// Check if this cube has finished its animation
|
||||||
|
const mesh = meshMap.get(id);
|
||||||
|
if (mesh && mesh.scale.x <= 0.01) {
|
||||||
|
// Remove cube mesh
|
||||||
|
scene.remove(mesh);
|
||||||
|
mesh.geometry.dispose();
|
||||||
|
if (Array.isArray(mesh.material)) {
|
||||||
|
mesh.material.forEach((material) => material.dispose());
|
||||||
|
} else {
|
||||||
|
mesh.material.dispose();
|
||||||
|
}
|
||||||
|
meshMap.delete(id);
|
||||||
|
|
||||||
|
// Remove base mesh
|
||||||
|
const base = baseMap.get(id);
|
||||||
|
if (base) {
|
||||||
|
scene.remove(base);
|
||||||
|
base.geometry.dispose();
|
||||||
|
if (Array.isArray(base.material)) {
|
||||||
|
base.material.forEach((material) => material.dispose());
|
||||||
|
} else {
|
||||||
|
base.material.dispose();
|
||||||
|
}
|
||||||
|
baseMap.delete(id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
createEffect(() => {
|
createEffect(() => {
|
||||||
selectedIds(); // Track the signal
|
selectedIds(); // Track the signal
|
||||||
updateMeshColors();
|
updateMeshColors();
|
||||||
@@ -450,8 +810,11 @@ export function CubeScene() {
|
|||||||
<div>
|
<div>
|
||||||
<div style={{ "margin-bottom": "10px" }}>
|
<div style={{ "margin-bottom": "10px" }}>
|
||||||
<button onClick={addCube}>Add Cube</button>
|
<button onClick={addCube}>Add Cube</button>
|
||||||
|
<button onClick={() => deleteSelectedCubes(selectedIds())}>
|
||||||
|
Delete Selected
|
||||||
|
</button>
|
||||||
<span style={{ "margin-left": "10px" }}>
|
<span style={{ "margin-left": "10px" }}>
|
||||||
Selected: {selectedIds().size} cubes
|
Selected: {selectedIds().size} cubes | Total: {ids().length} cubes
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -484,7 +847,7 @@ export function CubeScene() {
|
|||||||
ref={(el) => (container = el)}
|
ref={(el) => (container = el)}
|
||||||
style={{
|
style={{
|
||||||
width: "100%",
|
width: "100%",
|
||||||
height: "500px",
|
height: "1000px",
|
||||||
border: "1px solid #ccc",
|
border: "1px solid #ccc",
|
||||||
cursor: "grab",
|
cursor: "grab",
|
||||||
}}
|
}}
|
||||||
|
|||||||
@@ -249,6 +249,26 @@ def complete_groups(
|
|||||||
return groups_dict
|
return groups_dict
|
||||||
|
|
||||||
|
|
||||||
|
def complete_templates_disko(
|
||||||
|
prefix: str, parsed_args: argparse.Namespace, **kwargs: Any
|
||||||
|
) -> Iterable[str]:
|
||||||
|
"""
|
||||||
|
Provides completion functionality for disko templates
|
||||||
|
"""
|
||||||
|
|
||||||
|
from clan_lib.templates import list_templates
|
||||||
|
|
||||||
|
flake = clan_dir_result if (clan_dir_result := clan_dir(None)) is not None else "."
|
||||||
|
|
||||||
|
list_all_templates = list_templates(Flake(flake))
|
||||||
|
disko_template_list = list_all_templates.builtins.get("disko")
|
||||||
|
if disko_template_list:
|
||||||
|
disko_templates = list(disko_template_list)
|
||||||
|
disko_dict = dict.fromkeys(disko_templates, "disko")
|
||||||
|
return disko_dict
|
||||||
|
return []
|
||||||
|
|
||||||
|
|
||||||
def complete_target_host(
|
def complete_target_host(
|
||||||
prefix: str, parsed_args: argparse.Namespace, **kwargs: Any
|
prefix: str, parsed_args: argparse.Namespace, **kwargs: Any
|
||||||
) -> Iterable[str]:
|
) -> Iterable[str]:
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
# !/usr/bin/env python3
|
# !/usr/bin/env python3
|
||||||
import argparse
|
import argparse
|
||||||
|
|
||||||
|
from .apply import register_apply_parser
|
||||||
from .list import register_list_parser
|
from .list import register_list_parser
|
||||||
|
|
||||||
|
|
||||||
@@ -12,5 +13,9 @@ def register_parser(parser: argparse.ArgumentParser) -> None:
|
|||||||
help="the command to run",
|
help="the command to run",
|
||||||
required=True,
|
required=True,
|
||||||
)
|
)
|
||||||
list_parser = subparser.add_parser("list", help="List avilable templates")
|
list_parser = subparser.add_parser("list", help="List available templates")
|
||||||
|
apply_parser = subparser.add_parser(
|
||||||
|
"apply", help="Apply a template of the specified type"
|
||||||
|
)
|
||||||
register_list_parser(list_parser)
|
register_list_parser(list_parser)
|
||||||
|
register_apply_parser(apply_parser)
|
||||||
|
|||||||
15
pkgs/clan-cli/clan_cli/templates/apply.py
Normal file
15
pkgs/clan-cli/clan_cli/templates/apply.py
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
import argparse
|
||||||
|
|
||||||
|
from .apply_disk import register_apply_disk_template_parser
|
||||||
|
|
||||||
|
|
||||||
|
def register_apply_parser(parser: argparse.ArgumentParser) -> None:
|
||||||
|
subparser = parser.add_subparsers(
|
||||||
|
title="template_type",
|
||||||
|
description="the template type to apply",
|
||||||
|
help="the template type to apply",
|
||||||
|
required=True,
|
||||||
|
)
|
||||||
|
disk_parser = subparser.add_parser("disk", help="Apply a disk template")
|
||||||
|
|
||||||
|
register_apply_disk_template_parser(disk_parser)
|
||||||
83
pkgs/clan-cli/clan_cli/templates/apply_disk.py
Normal file
83
pkgs/clan-cli/clan_cli/templates/apply_disk.py
Normal file
@@ -0,0 +1,83 @@
|
|||||||
|
import argparse
|
||||||
|
import logging
|
||||||
|
from collections.abc import Sequence
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from clan_lib.api.disk import set_machine_disk_schema
|
||||||
|
from clan_lib.machines.machines import Machine
|
||||||
|
|
||||||
|
from clan_cli.completions import add_dynamic_completer, complete_templates_disko
|
||||||
|
|
||||||
|
log = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
class AppendSetAction(argparse.Action):
|
||||||
|
def __init__(self, option_strings: str, dest: str, **kwargs: Any) -> None:
|
||||||
|
super().__init__(option_strings, dest, **kwargs)
|
||||||
|
|
||||||
|
def __call__(
|
||||||
|
self,
|
||||||
|
parser: argparse.ArgumentParser,
|
||||||
|
namespace: argparse.Namespace,
|
||||||
|
values: str | Sequence[str] | None,
|
||||||
|
option_string: str | None = None,
|
||||||
|
) -> None:
|
||||||
|
lst = getattr(namespace, self.dest)
|
||||||
|
assert isinstance(values, list), "values must be a list"
|
||||||
|
lst.append((values[0], values[1]))
|
||||||
|
|
||||||
|
|
||||||
|
def apply_command(args: argparse.Namespace) -> None:
|
||||||
|
"""Apply a disk template to a machine."""
|
||||||
|
set_tuples: list[tuple[str, str]] = args.set
|
||||||
|
|
||||||
|
placeholders = dict(set_tuples)
|
||||||
|
|
||||||
|
set_machine_disk_schema(
|
||||||
|
Machine(args.to_machine, args.flake),
|
||||||
|
args.template,
|
||||||
|
placeholders,
|
||||||
|
force=args.force,
|
||||||
|
check_hw=not args.skip_hardware_check,
|
||||||
|
)
|
||||||
|
log.info(f"Applied disk template '{args.template}' to machine '{args.to_machine}' ")
|
||||||
|
|
||||||
|
|
||||||
|
def register_apply_disk_template_parser(parser: argparse.ArgumentParser) -> None:
|
||||||
|
parser.add_argument(
|
||||||
|
"--to-machine",
|
||||||
|
type=str,
|
||||||
|
required=True,
|
||||||
|
help="The machine to apply the template to",
|
||||||
|
)
|
||||||
|
|
||||||
|
template_action = parser.add_argument(
|
||||||
|
"--template",
|
||||||
|
type=str,
|
||||||
|
required=True,
|
||||||
|
help="The name of the disk template to apply",
|
||||||
|
)
|
||||||
|
add_dynamic_completer(template_action, complete_templates_disko)
|
||||||
|
|
||||||
|
parser.add_argument(
|
||||||
|
"--set",
|
||||||
|
help="Set a placeholder in the template to a value",
|
||||||
|
nargs=2,
|
||||||
|
metavar=("placeholder", "value"),
|
||||||
|
action=AppendSetAction,
|
||||||
|
default=[],
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--force",
|
||||||
|
help="Force apply the template even if the machine already has a disk schema",
|
||||||
|
action="store_true",
|
||||||
|
default=False,
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--skip-hardware-check",
|
||||||
|
help="Disables hardware checking. By default this command checks that the facter.json report exists and validates provided options",
|
||||||
|
action="store_true",
|
||||||
|
default=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
parser.set_defaults(func=apply_command)
|
||||||
@@ -71,8 +71,8 @@ def substitute(
|
|||||||
|
|
||||||
with file.open() as f:
|
with file.open() as f:
|
||||||
for line in f:
|
for line in f:
|
||||||
|
line = line.replace("__NIXPKGS__", str(nixpkgs_source()))
|
||||||
if clan_core_replacement:
|
if clan_core_replacement:
|
||||||
line = line.replace("__NIXPKGS__", str(nixpkgs_source()))
|
|
||||||
line = line.replace("__CLAN_CORE__", clan_core_replacement)
|
line = line.replace("__CLAN_CORE__", clan_core_replacement)
|
||||||
line = line.replace(
|
line = line.replace(
|
||||||
"git+https://git.clan.lol/clan/clan-core", clan_core_replacement
|
"git+https://git.clan.lol/clan/clan-core", clan_core_replacement
|
||||||
@@ -385,6 +385,7 @@ def test_flake(
|
|||||||
flake_template="test_flake",
|
flake_template="test_flake",
|
||||||
monkeypatch=monkeypatch,
|
monkeypatch=monkeypatch,
|
||||||
)
|
)
|
||||||
|
|
||||||
# check that git diff on ./sops is empty
|
# check that git diff on ./sops is empty
|
||||||
if (temporary_home / "test_flake" / "sops").exists():
|
if (temporary_home / "test_flake" / "sops").exists():
|
||||||
git_proc = sp.run(
|
git_proc = sp.run(
|
||||||
|
|||||||
@@ -97,22 +97,22 @@ class MethodRegistry:
|
|||||||
|
|
||||||
def register_abstract(self, fn: Callable[..., T]) -> Callable[..., T]:
|
def register_abstract(self, fn: Callable[..., T]) -> Callable[..., T]:
|
||||||
@wraps(fn)
|
@wraps(fn)
|
||||||
def wrapper(*args: Any, op_key: str, **kwargs: Any) -> ApiResponse[T]:
|
def wrapper(*args: Any, **kwargs: Any) -> ApiResponse[T]:
|
||||||
msg = f"""{fn.__name__} - The platform didn't implement this function.
|
msg = f"""{fn.__name__} - The platform didn't implement this function.
|
||||||
|
|
||||||
---
|
---
|
||||||
# Example
|
# Example
|
||||||
|
|
||||||
The function 'open_file()' depends on the platform.
|
The function 'get_system_file()' depends on the platform.
|
||||||
|
|
||||||
def open_file(file_request: FileRequest) -> str | None:
|
def get_system_file(file_request: FileRequest) -> str | None:
|
||||||
# In GTK we open a file dialog window
|
# In GTK we open a file dialog window
|
||||||
# In Android we open a file picker dialog
|
# In Android we open a file picker dialog
|
||||||
# and so on.
|
# and so on.
|
||||||
pass
|
pass
|
||||||
|
|
||||||
# At runtime the clan-app must override platform specific functions
|
# At runtime the clan-app must override platform specific functions
|
||||||
API.register(open_file)
|
API.register(get_system_file)
|
||||||
---
|
---
|
||||||
"""
|
"""
|
||||||
raise NotImplementedError(msg)
|
raise NotImplementedError(msg)
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ from dataclasses import dataclass, field
|
|||||||
from typing import Any, Literal
|
from typing import Any, Literal
|
||||||
|
|
||||||
from clan_lib.cmd import RunOpts, run
|
from clan_lib.cmd import RunOpts, run
|
||||||
|
from clan_lib.flake import Flake
|
||||||
from clan_lib.nix import nix_shell
|
from clan_lib.nix import nix_shell
|
||||||
|
|
||||||
from . import API
|
from . import API
|
||||||
@@ -19,7 +20,7 @@ class FileFilter:
|
|||||||
@dataclass
|
@dataclass
|
||||||
class FileRequest:
|
class FileRequest:
|
||||||
# Mode of the os dialog window
|
# Mode of the os dialog window
|
||||||
mode: Literal["open_file", "select_folder", "save", "open_multiple_files"]
|
mode: Literal["get_system_file", "select_folder", "save", "open_multiple_files"]
|
||||||
# Title of the os dialog window
|
# Title of the os dialog window
|
||||||
title: str | None = field(default=None)
|
title: str | None = field(default=None)
|
||||||
# Pre-applied filters for the file dialog
|
# Pre-applied filters for the file dialog
|
||||||
@@ -29,12 +30,25 @@ class FileRequest:
|
|||||||
|
|
||||||
|
|
||||||
@API.register_abstract
|
@API.register_abstract
|
||||||
def open_file(file_request: FileRequest) -> list[str] | None:
|
def get_system_file(file_request: FileRequest) -> list[str] | None:
|
||||||
"""
|
"""
|
||||||
Abstract api method to open a file dialog window.
|
Api method to open a file dialog window.
|
||||||
It must return the name of the selected file or None if no file was selected.
|
|
||||||
|
Implementations is specific to the platform and
|
||||||
|
returns the name of the selected file or None if no file was selected.
|
||||||
"""
|
"""
|
||||||
msg = "open_file() is not implemented"
|
msg = "get_system_file() is not implemented"
|
||||||
|
raise NotImplementedError(msg)
|
||||||
|
|
||||||
|
|
||||||
|
@API.register_abstract
|
||||||
|
def get_clan_folder() -> Flake:
|
||||||
|
"""
|
||||||
|
Api method to open the clan folder.
|
||||||
|
|
||||||
|
Implementations is specific to the platform and returns the path to the clan folder.
|
||||||
|
"""
|
||||||
|
msg = "get_clan_folder() is not implemented"
|
||||||
raise NotImplementedError(msg)
|
raise NotImplementedError(msg)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -72,8 +72,18 @@ templates: dict[str, dict[str, Callable[[dict[str, Any]], Placeholder]]] = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def get_empty_placeholder(label: str) -> Placeholder:
|
||||||
|
return Placeholder(
|
||||||
|
label,
|
||||||
|
options=None,
|
||||||
|
required=not label.endswith("*"),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
@API.register
|
@API.register
|
||||||
def get_machine_disk_schemas(machine: Machine) -> dict[str, DiskSchema]:
|
def get_machine_disk_schemas(
|
||||||
|
machine: Machine, check_hw: bool = True
|
||||||
|
) -> dict[str, DiskSchema]:
|
||||||
"""
|
"""
|
||||||
Get the available disk schemas.
|
Get the available disk schemas.
|
||||||
This function reads the disk schemas from the templates directory and returns them as a dictionary.
|
This function reads the disk schemas from the templates directory and returns them as a dictionary.
|
||||||
@@ -89,11 +99,13 @@ def get_machine_disk_schemas(machine: Machine) -> dict[str, DiskSchema]:
|
|||||||
hw_report = {}
|
hw_report = {}
|
||||||
|
|
||||||
hw_report_path = HardwareConfig.NIXOS_FACTER.config_path(machine)
|
hw_report_path = HardwareConfig.NIXOS_FACTER.config_path(machine)
|
||||||
if not hw_report_path.exists():
|
if check_hw and not hw_report_path.exists():
|
||||||
msg = "Hardware configuration missing"
|
msg = "Hardware configuration missing"
|
||||||
raise ClanError(msg)
|
raise ClanError(msg)
|
||||||
with hw_report_path.open("r") as hw_report_file:
|
|
||||||
hw_report = json.load(hw_report_file)
|
if hw_report_path.exists():
|
||||||
|
with hw_report_path.open("r") as hw_report_file:
|
||||||
|
hw_report = json.load(hw_report_file)
|
||||||
|
|
||||||
for disk_template in disk_templates.iterdir():
|
for disk_template in disk_templates.iterdir():
|
||||||
if disk_template.is_dir():
|
if disk_template.is_dir():
|
||||||
@@ -109,7 +121,10 @@ def get_machine_disk_schemas(machine: Machine) -> dict[str, DiskSchema]:
|
|||||||
placeholders = {}
|
placeholders = {}
|
||||||
|
|
||||||
if placeholder_getters:
|
if placeholder_getters:
|
||||||
placeholders = {k: v(hw_report) for k, v in placeholder_getters.items()}
|
placeholders = {
|
||||||
|
k: v(hw_report) if hw_report else get_empty_placeholder(k)
|
||||||
|
for k, v in placeholder_getters.items()
|
||||||
|
}
|
||||||
|
|
||||||
raw_readme = (disk_template / "README.md").read_text()
|
raw_readme = (disk_template / "README.md").read_text()
|
||||||
frontmatter, readme = extract_frontmatter(
|
frontmatter, readme = extract_frontmatter(
|
||||||
@@ -139,30 +154,37 @@ def set_machine_disk_schema(
|
|||||||
# Use get disk schemas to get the placeholders and their options
|
# Use get disk schemas to get the placeholders and their options
|
||||||
placeholders: dict[str, str],
|
placeholders: dict[str, str],
|
||||||
force: bool = False,
|
force: bool = False,
|
||||||
|
check_hw: bool = True,
|
||||||
) -> None:
|
) -> None:
|
||||||
"""
|
"""
|
||||||
Set the disk placeholders of the template
|
Set the disk placeholders of the template
|
||||||
"""
|
"""
|
||||||
|
# Ensure the machine exists
|
||||||
|
machine.get_inv_machine()
|
||||||
|
|
||||||
# Assert the hw-config must exist before setting the disk
|
# Assert the hw-config must exist before setting the disk
|
||||||
hw_config = get_machine_hardware_config(machine)
|
hw_config = get_machine_hardware_config(machine)
|
||||||
hw_config_path = hw_config.config_path(machine)
|
hw_config_path = hw_config.config_path(machine)
|
||||||
|
|
||||||
if not hw_config_path.exists():
|
if check_hw:
|
||||||
msg = "Hardware configuration must exist before applying disk schema"
|
if not hw_config_path.exists():
|
||||||
raise ClanError(msg)
|
msg = "Hardware configuration must exist for checking."
|
||||||
|
msg += f"\nrun 'clan machines update-hardware-config {machine.name}' to generate a hardware report. Alternatively disable hardware checking to skip this check"
|
||||||
|
raise ClanError(msg)
|
||||||
|
|
||||||
if hw_config != HardwareConfig.NIXOS_FACTER:
|
if hw_config != HardwareConfig.NIXOS_FACTER:
|
||||||
msg = "Hardware configuration must use type FACTER for applying disk schema automatically"
|
msg = "Hardware configuration must use type FACTER for applying disk schema automatically"
|
||||||
raise ClanError(msg)
|
raise ClanError(msg)
|
||||||
|
|
||||||
disk_schema_path = clan_templates(TemplateType.DISK) / f"{schema_name}/default.nix"
|
disk_schema_path = clan_templates(TemplateType.DISK) / f"{schema_name}/default.nix"
|
||||||
|
|
||||||
if not disk_schema_path.exists():
|
if not disk_schema_path.exists():
|
||||||
msg = f"Disk schema not found at {disk_schema_path}"
|
msg = f"Disk schema '{schema_name}' not found at {disk_schema_path}"
|
||||||
|
msg += f"\nAvailable schemas: {', '.join([p.name for p in clan_templates(TemplateType.DISK).iterdir()])}"
|
||||||
raise ClanError(msg)
|
raise ClanError(msg)
|
||||||
|
|
||||||
# Check that the placeholders are valid
|
# Check that the placeholders are valid
|
||||||
disk_schema = get_machine_disk_schemas(machine)[schema_name]
|
disk_schema = get_machine_disk_schemas(machine, check_hw)[schema_name]
|
||||||
# check that all required placeholders are present
|
# check that all required placeholders are present
|
||||||
for placeholder_name, schema_placeholder in disk_schema.placeholders.items():
|
for placeholder_name, schema_placeholder in disk_schema.placeholders.items():
|
||||||
if schema_placeholder.required and placeholder_name not in placeholders:
|
if schema_placeholder.required and placeholder_name not in placeholders:
|
||||||
@@ -183,12 +205,15 @@ def set_machine_disk_schema(
|
|||||||
description=f"Available placeholders: {disk_schema.placeholders.keys()}",
|
description=f"Available placeholders: {disk_schema.placeholders.keys()}",
|
||||||
)
|
)
|
||||||
|
|
||||||
# Invalid value. Check if the value is one of the provided options
|
# Checking invalid value: if the value is one of the provided options
|
||||||
if ph.options and placeholder_value not in ph.options:
|
if check_hw and ph.options and placeholder_value not in ph.options:
|
||||||
msg = (
|
msg = (
|
||||||
f"Invalid value {placeholder_value} for placeholder {placeholder_name}"
|
f"Invalid value {placeholder_value} for placeholder {placeholder_name}"
|
||||||
)
|
)
|
||||||
raise ClanError(msg, description=f"Valid options: {ph.options}")
|
raise ClanError(
|
||||||
|
msg,
|
||||||
|
description=f"Valid options: \n{'\n'.join(ph.options)}",
|
||||||
|
)
|
||||||
|
|
||||||
placeholders_toml = "\n".join(
|
placeholders_toml = "\n".join(
|
||||||
[f"""# {k} = "{v}" """ for k, v in placeholders.items() if v is not None]
|
[f"""# {k} = "{v}" """ for k, v in placeholders.items() if v is not None]
|
||||||
@@ -221,6 +246,9 @@ def set_machine_disk_schema(
|
|||||||
disk_config.write(header)
|
disk_config.write(header)
|
||||||
disk_config.write(config_str)
|
disk_config.write(config_str)
|
||||||
|
|
||||||
|
# TODO: return files to commit
|
||||||
|
# Don't commit here
|
||||||
|
# The top level command will usually collect files and commit them in batches
|
||||||
commit_file(
|
commit_file(
|
||||||
disko_file_path,
|
disko_file_path,
|
||||||
machine.flake.path,
|
machine.flake.path,
|
||||||
|
|||||||
@@ -324,10 +324,10 @@ def test_private_public_fields() -> None:
|
|||||||
def test_literal_field() -> None:
|
def test_literal_field() -> None:
|
||||||
@dataclass
|
@dataclass
|
||||||
class Person:
|
class Person:
|
||||||
name: Literal["open_file", "select_folder", "save"]
|
name: Literal["get_system_file", "select_folder", "save"]
|
||||||
|
|
||||||
data = {"name": "open_file"}
|
data = {"name": "get_system_file"}
|
||||||
expected = Person(name="open_file")
|
expected = Person(name="get_system_file")
|
||||||
assert from_dict(Person, data) == expected
|
assert from_dict(Person, data) == expected
|
||||||
|
|
||||||
assert dataclass_to_dict(expected) == data
|
assert dataclass_to_dict(expected) == data
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ def delete_task(task_id: str) -> None:
|
|||||||
"""Cancel a task by its op_key."""
|
"""Cancel a task by its op_key."""
|
||||||
assert BAKEND_THREADS is not None, "Backend threads not initialized"
|
assert BAKEND_THREADS is not None, "Backend threads not initialized"
|
||||||
future = BAKEND_THREADS.get(task_id)
|
future = BAKEND_THREADS.get(task_id)
|
||||||
|
|
||||||
log.debug(f"Thread ID: {threading.get_ident()}")
|
log.debug(f"Thread ID: {threading.get_ident()}")
|
||||||
if future:
|
if future:
|
||||||
future.stop_event.set()
|
future.stop_event.set()
|
||||||
|
|||||||
37
pkgs/clan-cli/clan_lib/clan/check.py
Normal file
37
pkgs/clan-cli/clan_lib/clan/check.py
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
import logging
|
||||||
|
|
||||||
|
from clan_lib.api import API
|
||||||
|
from clan_lib.errors import ClanError
|
||||||
|
from clan_lib.flake import Flake
|
||||||
|
|
||||||
|
log = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
@API.register
|
||||||
|
def check_clan_valid(flake: Flake) -> bool:
|
||||||
|
"""Check if a clan is valid by verifying if it has the clanInternals attribute.
|
||||||
|
Args:
|
||||||
|
flake: The Flake instance representing the clan.
|
||||||
|
Returns:
|
||||||
|
bool: True if the clan exists, False otherwise.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
flake.prefetch()
|
||||||
|
except ClanError as e:
|
||||||
|
msg = f"Flake {flake} is not valid: {e}"
|
||||||
|
log.info(msg)
|
||||||
|
return False
|
||||||
|
|
||||||
|
if flake.is_local and not flake.path.exists():
|
||||||
|
msg = f"Path {flake} does not exist"
|
||||||
|
log.info(msg)
|
||||||
|
return False
|
||||||
|
|
||||||
|
try:
|
||||||
|
flake.select("clanInternals.inventoryClass.directory")
|
||||||
|
except ClanError as e:
|
||||||
|
msg = f"Flake {flake} is not a valid clan directory: {e}"
|
||||||
|
log.info(msg)
|
||||||
|
return False
|
||||||
|
|
||||||
|
return True
|
||||||
24
pkgs/clan-cli/clan_lib/clan/check_test.py
Normal file
24
pkgs/clan-cli/clan_lib/clan/check_test.py
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from clan_cli.tests.fixtures_flakes import FlakeForTest
|
||||||
|
|
||||||
|
from clan_lib.clan.check import check_clan_valid
|
||||||
|
from clan_lib.flake import Flake
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.with_core
|
||||||
|
def test_check_clan_valid(
|
||||||
|
temporary_home: Path, test_flake_with_core: FlakeForTest, test_flake: FlakeForTest
|
||||||
|
) -> None:
|
||||||
|
# Test with a valid clan
|
||||||
|
flake = Flake(str(test_flake_with_core.path))
|
||||||
|
assert check_clan_valid(flake) is True
|
||||||
|
|
||||||
|
# Test with an invalid clan
|
||||||
|
flake = Flake(str(test_flake.path))
|
||||||
|
assert check_clan_valid(flake) is False
|
||||||
|
|
||||||
|
# Test with a non-existent clan
|
||||||
|
flake = Flake(str(temporary_home))
|
||||||
|
assert check_clan_valid(flake) is False
|
||||||
@@ -1,9 +1,13 @@
|
|||||||
|
import logging
|
||||||
|
|
||||||
from clan_lib.api import API
|
from clan_lib.api import API
|
||||||
from clan_lib.errors import ClanError
|
from clan_lib.errors import ClanError
|
||||||
from clan_lib.flake import Flake
|
from clan_lib.flake import Flake
|
||||||
from clan_lib.nix_models.clan import InventoryMeta
|
from clan_lib.nix_models.clan import InventoryMeta
|
||||||
from clan_lib.persist.inventory_store import InventoryStore
|
from clan_lib.persist.inventory_store import InventoryStore
|
||||||
|
|
||||||
|
log = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
@API.register
|
@API.register
|
||||||
def get_clan_details(flake: Flake) -> InventoryMeta:
|
def get_clan_details(flake: Flake) -> InventoryMeta:
|
||||||
|
|||||||
@@ -65,14 +65,14 @@ def get_machine(flake: Flake, name: str) -> InventoryMachine:
|
|||||||
InventoryMachine: An instance representing the machine's inventory details.
|
InventoryMachine: An instance representing the machine's inventory details.
|
||||||
|
|
||||||
Raises:
|
Raises:
|
||||||
ClanError: If the machine with the specified name is not found in the inventory.
|
ClanError: If the machine with the specified name is not found in the clan
|
||||||
"""
|
"""
|
||||||
inventory_store = InventoryStore(flake=flake)
|
inventory_store = InventoryStore(flake=flake)
|
||||||
inventory = inventory_store.read()
|
inventory = inventory_store.read()
|
||||||
|
|
||||||
machine_inv = inventory.get("machines", {}).get(name)
|
machine_inv = inventory.get("machines", {}).get(name)
|
||||||
if machine_inv is None:
|
if machine_inv is None:
|
||||||
msg = f"Machine {name} not found in inventory"
|
msg = f"Machine {name} does not exist"
|
||||||
raise ClanError(msg)
|
raise ClanError(msg)
|
||||||
|
|
||||||
return InventoryMachine(**machine_inv)
|
return InventoryMachine(**machine_inv)
|
||||||
|
|||||||
@@ -118,7 +118,7 @@ def run_machine_hardware_info(
|
|||||||
commit_file(
|
commit_file(
|
||||||
hw_file,
|
hw_file,
|
||||||
opts.machine.flake.path,
|
opts.machine.flake.path,
|
||||||
f"machines/{opts.machine}/{hw_file.name}: update hardware configuration",
|
f"machines/{opts.machine.name}/{hw_file.name}: update hardware configuration",
|
||||||
)
|
)
|
||||||
try:
|
try:
|
||||||
get_machine_target_platform(opts.machine)
|
get_machine_target_platform(opts.machine)
|
||||||
|
|||||||
@@ -35,5 +35,7 @@ def copy_from_nixstore(src: Path, dest: Path) -> None:
|
|||||||
Uses `cp -r` to recursively copy the directory.
|
Uses `cp -r` to recursively copy the directory.
|
||||||
Ensures the destination directory is writable by the user.
|
Ensures the destination directory is writable by the user.
|
||||||
"""
|
"""
|
||||||
run(["cp", "-r", str(src), str(dest)])
|
run(["cp", "-r", str(src / "."), str(dest)]) # Copy contents of src to dest
|
||||||
run(["chmod", "-R", "u+w", str(dest)])
|
run(
|
||||||
|
["chmod", "-R", "u+w", str(dest)]
|
||||||
|
) # Ensure the destination is writable by the user
|
||||||
|
|||||||
@@ -23,7 +23,6 @@ COMMON_VERBS = {
|
|||||||
"create", # instantiate resource
|
"create", # instantiate resource
|
||||||
"set", # update or configure
|
"set", # update or configure
|
||||||
"delete", # remove resource
|
"delete", # remove resource
|
||||||
"open", # initiate session, shell, file, etc.
|
|
||||||
"check", # validate, probe, or assert
|
"check", # validate, probe, or assert
|
||||||
"run", # start imperative task or action; machine-deploy etc.
|
"run", # start imperative task or action; machine-deploy etc.
|
||||||
}
|
}
|
||||||
@@ -39,7 +38,6 @@ TOP_LEVEL_RESOURCES = {
|
|||||||
"clan", # clan management
|
"clan", # clan management
|
||||||
"machine", # machine management
|
"machine", # machine management
|
||||||
"task", # task management
|
"task", # task management
|
||||||
"file", # file operations
|
|
||||||
"secret", # sops & key operations
|
"secret", # sops & key operations
|
||||||
"log", # log operations
|
"log", # log operations
|
||||||
"generator", # vars generators operations
|
"generator", # vars generators operations
|
||||||
|
|||||||
@@ -35,10 +35,10 @@
|
|||||||
};
|
};
|
||||||
|
|
||||||
# Additional NixOS configuration can be added here.
|
# Additional NixOS configuration can be added here.
|
||||||
# machines/machine1/configuration.nix will be automatically imported.
|
# machines/jon/configuration.nix will be automatically imported.
|
||||||
# See: https://docs.clan.lol/guides/more-machines/#automatic-registration
|
# See: https://docs.clan.lol/guides/more-machines/#automatic-registration
|
||||||
machines = {
|
machines = {
|
||||||
# machine1 = { config, ... }: {
|
# jon = { config, ... }: {
|
||||||
# environment.systemPackages = [ pkgs.asciinema ];
|
# environment.systemPackages = [ pkgs.asciinema ];
|
||||||
# };
|
# };
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,19 +1,25 @@
|
|||||||
/*
|
|
||||||
This is an example of a simple nixos module:
|
|
||||||
|
|
||||||
Enables the GNOME desktop environment and the GDM display manager.
|
|
||||||
|
|
||||||
To use this module, import it in your machines NixOS configuration like this:
|
|
||||||
|
|
||||||
```nix
|
|
||||||
imports = [
|
|
||||||
modules/gnome.nix
|
|
||||||
];
|
|
||||||
```
|
|
||||||
*/
|
|
||||||
{ ... }:
|
{ ... }:
|
||||||
{
|
{
|
||||||
services.xserver.enable = true;
|
# Can be imported into machines to enable GNOME and GDM.
|
||||||
services.xserver.desktopManager.gnome.enable = true;
|
#
|
||||||
services.xserver.displayManager.gdm.enable = true;
|
# Copy this into a machine's configuration:
|
||||||
|
# `machines/<name>/configuration.nix`
|
||||||
|
# ```nix
|
||||||
|
# imports = [
|
||||||
|
# ../../modules/gnome.nix
|
||||||
|
# ];
|
||||||
|
# ```
|
||||||
|
|
||||||
|
# Uncomment one block to enable the
|
||||||
|
# GNOME desktop environment and the GDM display manager.
|
||||||
|
|
||||||
|
# Pre NixOS 25.11
|
||||||
|
# services.xserver.enable = true;
|
||||||
|
# services.xserver.displayManager.gdm.enable = true;
|
||||||
|
# services.xserver.desktopManager.gnome.enable = true;
|
||||||
|
|
||||||
|
# As of NixOS 25.11
|
||||||
|
# services.displayManager.gdm.enable = true;
|
||||||
|
# services.desktopManager.gnome.enable = true;
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
{
|
{
|
||||||
config,
|
|
||||||
clan-core,
|
clan-core,
|
||||||
# Optional, if you want to access other flakes:
|
# Optional, if you want to access other flakes:
|
||||||
# self,
|
# self,
|
||||||
@@ -7,14 +6,7 @@
|
|||||||
}:
|
}:
|
||||||
{
|
{
|
||||||
imports = [
|
imports = [
|
||||||
# Enables the OpenSSH server for remote access
|
|
||||||
clan-core.clanModules.sshd
|
|
||||||
# Set a root password
|
|
||||||
clan-core.clanModules.root-password
|
|
||||||
clan-core.clanModules.user-password
|
clan-core.clanModules.user-password
|
||||||
|
|
||||||
# You can access other flakes imported in your flake via `self` like this:
|
|
||||||
# self.inputs.nix-index-database.nixosModules.nix-index
|
|
||||||
];
|
];
|
||||||
|
|
||||||
# Locale service discovery and mDNS
|
# Locale service discovery and mDNS
|
||||||
@@ -31,7 +23,5 @@
|
|||||||
"video"
|
"video"
|
||||||
"input"
|
"input"
|
||||||
];
|
];
|
||||||
uid = 1000;
|
|
||||||
openssh.authorizedKeys.keys = config.users.users.root.openssh.authorizedKeys.keys;
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,81 +1,45 @@
|
|||||||
{ self }:
|
|
||||||
{
|
{
|
||||||
meta.name = "__CHANGE_ME__"; # Ensure this is unique among all clans you want to use.
|
# Ensure this is unique among all clans you want to use.
|
||||||
|
meta.name = "__CHANGE_ME__";
|
||||||
|
|
||||||
inherit self;
|
# Docs: See https://docs.clan.lol/reference/clanServices
|
||||||
|
inventory.instances = {
|
||||||
|
|
||||||
|
# Docs: https://docs.clan.lol/reference/clanServices/admin/
|
||||||
|
# Admin service for managing machines
|
||||||
|
# This service adds a root password and SSH access.
|
||||||
|
admin = {
|
||||||
|
roles.default.tags.all = { };
|
||||||
|
roles.default.settings.allowedKeys = {
|
||||||
|
# Insert the public key that you want to use for SSH access.
|
||||||
|
# All keys will have ssh access to all machines ("tags.all" means 'all machines').
|
||||||
|
# Alternatively set 'users.users.root.openssh.authorizedKeys.keys' in each machine
|
||||||
|
"admin-machine-1" = "__YOUR_PUBLIC_KEY__";
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
# Docs: https://docs.clan.lol/reference/clanServices/zerotier/
|
||||||
|
# The lines below will define a zerotier network and add all machines as 'peer' to it.
|
||||||
|
# !!! Manual steps required:
|
||||||
|
# - Define a controller machine for the zerotier network.
|
||||||
|
# - Deploy the controller machine first to initilize the network.
|
||||||
|
zerotier = {
|
||||||
|
# Replace with the name (string) of your machine that you will use as zerotier-controller
|
||||||
|
# See: https://docs.zerotier.com/controller/
|
||||||
|
# Deploy this machine first to create the network secrets
|
||||||
|
roles.controller.machines."__YOUR_CONTROLLER__" = { };
|
||||||
|
# Peers of the network
|
||||||
|
# tags.all means 'all machines' will joined
|
||||||
|
roles.peer.tags.all = { };
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
# Additional NixOS configuration can be added here.
|
||||||
|
# machines/jon/configuration.nix will be automatically imported.
|
||||||
|
# See: https://docs.clan.lol/guides/more-machines/#automatic-registration
|
||||||
machines = {
|
machines = {
|
||||||
# "jon" will be the hostname of the machine
|
# jon = { config, ... }: {
|
||||||
jon =
|
# environment.systemPackages = [ pkgs.asciinema ];
|
||||||
{ pkgs, ... }:
|
# };
|
||||||
{
|
|
||||||
imports = [
|
|
||||||
./modules/shared.nix
|
|
||||||
./modules/disko.nix
|
|
||||||
./machines/jon/configuration.nix
|
|
||||||
];
|
|
||||||
|
|
||||||
nixpkgs.hostPlatform = "x86_64-linux";
|
|
||||||
|
|
||||||
# Set this for clan commands use ssh i.e. `clan machines update`
|
|
||||||
# If you change the hostname, you need to update this line to root@<new-hostname>
|
|
||||||
# This only works however if you have avahi running on your admin machine else use IP
|
|
||||||
clan.core.networking.targetHost = pkgs.lib.mkDefault "root@jon";
|
|
||||||
|
|
||||||
# You can get your disk id by running the following command on the installer:
|
|
||||||
# Replace <IP> with the IP of the installer printed on the screen or by running the `ip addr` command.
|
|
||||||
# ssh root@<IP> lsblk --output NAME,ID-LINK,FSTYPE,SIZE,MOUNTPOINT
|
|
||||||
disko.devices.disk.main = {
|
|
||||||
device = "/dev/disk/by-id/__CHANGE_ME__";
|
|
||||||
};
|
|
||||||
|
|
||||||
# IMPORTANT! Add your SSH key here
|
|
||||||
# e.g. > cat ~/.ssh/id_ed25519.pub
|
|
||||||
users.users.root.openssh.authorizedKeys.keys = throw ''
|
|
||||||
Don't forget to add your SSH key here!
|
|
||||||
users.users.root.openssh.authorizedKeys.keys = [ "<YOUR SSH_KEY>" ]
|
|
||||||
'';
|
|
||||||
|
|
||||||
# Zerotier needs one controller to accept new nodes. Once accepted
|
|
||||||
# the controller can be offline and routing still works.
|
|
||||||
clan.core.networking.zerotier.controller.enable = true;
|
|
||||||
};
|
|
||||||
# "sara" will be the hostname of the machine
|
|
||||||
sara =
|
|
||||||
{ pkgs, ... }:
|
|
||||||
{
|
|
||||||
imports = [
|
|
||||||
./modules/shared.nix
|
|
||||||
./modules/disko.nix
|
|
||||||
./machines/sara/configuration.nix
|
|
||||||
];
|
|
||||||
|
|
||||||
nixpkgs.hostPlatform = "x86_64-linux";
|
|
||||||
|
|
||||||
# Set this for clan commands use ssh i.e. `clan machines update`
|
|
||||||
# If you change the hostname, you need to update this line to root@<new-hostname>
|
|
||||||
# This only works however if you have avahi running on your admin machine else use IP
|
|
||||||
clan.core.networking.targetHost = pkgs.lib.mkDefault "root@sara";
|
|
||||||
|
|
||||||
# You can get your disk id by running the following command on the installer:
|
|
||||||
# Replace <IP> with the IP of the installer printed on the screen or by running the `ip addr` command.
|
|
||||||
# ssh root@<IP> lsblk --output NAME,ID-LINK,FSTYPE,SIZE,MOUNTPOINT
|
|
||||||
disko.devices.disk.main = {
|
|
||||||
device = "/dev/disk/by-id/__CHANGE_ME__";
|
|
||||||
};
|
|
||||||
|
|
||||||
# IMPORTANT! Add your SSH key here
|
|
||||||
# e.g. > cat ~/.ssh/id_ed25519.pub
|
|
||||||
users.users.root.openssh.authorizedKeys.keys = throw ''
|
|
||||||
Don't forget to add your SSH key here!
|
|
||||||
users.users.root.openssh.authorizedKeys.keys = [ "<YOUR SSH_KEY>" ]
|
|
||||||
'';
|
|
||||||
|
|
||||||
/*
|
|
||||||
After jon is deployed, uncomment the following line
|
|
||||||
This will allow sara to share the VPN overlay network with jon
|
|
||||||
The networkId is generated by the first deployment of jon
|
|
||||||
*/
|
|
||||||
# clan.core.networking.zerotier.networkId = builtins.readFile ../../vars/per-machine/jon/zerotier/zerotier-network-id/value;
|
|
||||||
};
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,7 +6,6 @@
|
|||||||
|
|
||||||
outputs =
|
outputs =
|
||||||
inputs@{
|
inputs@{
|
||||||
self,
|
|
||||||
flake-parts,
|
flake-parts,
|
||||||
...
|
...
|
||||||
}:
|
}:
|
||||||
@@ -22,7 +21,9 @@
|
|||||||
];
|
];
|
||||||
|
|
||||||
# https://docs.clan.lol/guides/getting-started/flake-parts/
|
# https://docs.clan.lol/guides/getting-started/flake-parts/
|
||||||
clan = import ./clan.nix { inherit self; };
|
clan = {
|
||||||
|
imports = [ ./clan.nix ];
|
||||||
|
};
|
||||||
|
|
||||||
perSystem =
|
perSystem =
|
||||||
{ pkgs, inputs', ... }:
|
{ pkgs, inputs', ... }:
|
||||||
|
|||||||
23
templates/clan/flake-parts/modules/gnome.nix
Normal file
23
templates/clan/flake-parts/modules/gnome.nix
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
{ ... }:
|
||||||
|
{
|
||||||
|
# Can be imported into machines to enable GNOME and GDM.
|
||||||
|
#
|
||||||
|
# Copy this into a machine's configuration:
|
||||||
|
# `machines/<name>/configuration.nix`
|
||||||
|
# ```nix
|
||||||
|
# imports = [
|
||||||
|
# ../../modules/gnome.nix
|
||||||
|
# ];
|
||||||
|
# ```
|
||||||
|
|
||||||
|
# Enable the GNOME desktop environment and the GDM display manager.
|
||||||
|
# Pre NixOS: 25.11
|
||||||
|
# services.xserver.enable = true;
|
||||||
|
# services.xserver.displayManager.gdm.enable = true;
|
||||||
|
# services.xserver.desktopManager.gnome.enable = true;
|
||||||
|
|
||||||
|
# => 25.11
|
||||||
|
# services.displayManager.gdm.enable = true;
|
||||||
|
# services.desktopManager.gnome.enable = true;
|
||||||
|
|
||||||
|
}
|
||||||
@@ -1,14 +0,0 @@
|
|||||||
{
|
|
||||||
clan-core,
|
|
||||||
# Optional, if you want to access other flakes:
|
|
||||||
# self,
|
|
||||||
...
|
|
||||||
}:
|
|
||||||
{
|
|
||||||
imports = [
|
|
||||||
clan-core.clanModules.sshd
|
|
||||||
clan-core.clanModules.root-password
|
|
||||||
# You can access other flakes imported in your flake via `self` like this:
|
|
||||||
# self.inputs.nix-index-database.nixosModules.nix-index
|
|
||||||
];
|
|
||||||
}
|
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user