1
0
forked from clan/clan-core

Compare commits

...

100 Commits

Author SHA1 Message Date
0027c46313 Merge pull request 'zerotier-static-peers: use correct exclusion source' (#1548) from kenji/clan-core:modules/fix/static into main
Reviewed-on: clan/clan-core#1548
2024-06-03 21:24:29 +00:00
ca2001040b zerotier-static-peers: use correct exclusion source 2024-06-03 22:53:44 +02:00
d6725100ac Merge pull request 'zerotier-static-peers: add guard condition' (#1547) from kenji/clan-core:modules/add/zerotier-guard into main
Reviewed-on: clan/clan-core#1547
2024-06-03 20:47:03 +00:00
503ce29c84 zerotier-static-peers: add guard condition 2024-06-03 22:42:04 +02:00
87444cd2b8 Merge pull request 'clan: add dyncamic completions for secrets' (#1546) from kenji/clan-core:kenji-clan/secrets-dynamic/add-completion into main
Reviewed-on: clan/clan-core#1546
2024-06-03 19:55:12 +00:00
31eca9e8bc clan: add dyncamic completions for secrets 2024-06-03 21:47:14 +02:00
822afe08b5 Merge pull request 'clan: add dynamic machine completions to clan secrets subcommands' (#1545) from clan/secrets/add-completions into main
Reviewed-on: clan/clan-core#1545
2024-06-03 15:42:37 +00:00
cfb78b0edb clan: add dynamic machine completions to clan secrets subcommands 2024-06-03 17:32:33 +02:00
65fd7d3efe Merge pull request 'clan: add dynamic completion to clan machines show' (#1544) from kenji-clan/machine-show/add-commpletion into main
Reviewed-on: clan/clan-core#1544
2024-06-03 15:15:45 +00:00
e8241fb7c9 clan: add dynamic completion to clan machines show 2024-06-03 17:06:03 +02:00
259d51bdc8 Merge pull request 'clan.static-hosts: excludeHosts should be empty if topLevelDomain is defined.' (#1538) from mrvandalo/clan-core:feature/static-hosts-exclude-nothing-when-tld-is-given into main
Reviewed-on: clan/clan-core#1538
Reviewed-by: kenji <aks.kenji@protonmail.com>
2024-06-03 10:44:41 +00:00
f6fb52afbf clan.static-hosts: excludeHosts should be empty if topLevelDomain is defined. 2024-06-03 10:44:41 +00:00
8089b87bbb Merge pull request 'Revert "clan-cli: cmd.py uses pseudo terminal now. Remove tty.py. Refactor password_store.py to use cmd.py."' (#1543) from lassulus/clan-core:lassulus-HEAD into main 2024-06-03 10:30:50 +00:00
578162425d Revert "clan-cli: cmd.py uses pseudo terminal now. Remove tty.py. Refactor password_store.py to use cmd.py."
This reverts commit ba86b49952.
2024-06-03 12:25:20 +02:00
dbad63f155 Merge pull request 'clan_cli secrets_upload: fix permissions' (#1542) from lassulus/clan-core:lassulus-HEAD into main 2024-06-03 08:58:49 +00:00
da8a733899 clan_cli secrets_upload: fix permissions 2024-06-03 10:52:18 +02:00
8f58f1998d Merge pull request 'Automatic flake update - 2024-06-03T00:00+00:00' (#1540) from flake-update-2024-06-03 into main 2024-06-03 00:05:17 +00:00
Clan Merge Bot
c43fe5187f update flake lock - 2024-06-03T00:00+00:00
Flake lock file updates:

• Updated input 'disko':
    'github:nix-community/disko/10986091e47fb1180620b78438512b294b7e8f67' (2024-05-27)
  → 'github:nix-community/disko/0274af4c92531ebfba4a5bd493251a143bc51f3c' (2024-05-31)
• Updated input 'flake-parts':
    'github:hercules-ci/flake-parts/8dc45382d5206bd292f9c2768b8058a8fd8311d9' (2024-05-16)
  → 'github:hercules-ci/flake-parts/2a55567fcf15b1b1c7ed712a2c6fadaec7412ea8' (2024-06-01)
• Updated input 'nixos-images':
    'github:nix-community/nixos-images/2478833ef8cc6de3d9e331f53b6f3682e425f207' (2024-05-27)
  → 'github:nix-community/nixos-images/47bfb55316e105390dd761e0b6e8e0be09462b67' (2024-05-30)
• Updated input 'nixos-images/nixos-2311':
    'github:NixOS/nixpkgs/0c007b36981bdbd69ccf0c7df30a174e63660667' (2024-05-26)
  → 'github:NixOS/nixpkgs/64e468fd2652105710d86cd2ae3e65a5a6d58dec' (2024-05-29)
• Updated input 'nixpkgs':
    'github:NixOS/nixpkgs/4ae13643e7f2cd4bc6555fce074865d9d14e7c24' (2024-05-28)
  → 'github:NixOS/nixpkgs/6634a0509e9e81e980b129435fbbec518ab246d0' (2024-06-02)
• Updated input 'sops-nix':
    'github:Mic92/sops-nix/962797a8d7f15ed7033031731d0bb77244839960' (2024-05-26)
  → 'github:Mic92/sops-nix/ab2a43b0d21d1d37d4d5726a892f714eaeb4b075' (2024-06-02)
• Updated input 'treefmt-nix':
    'github:numtide/treefmt-nix/2fba33a182602b9d49f0b2440513e5ee091d838b' (2024-05-17)
  → 'github:numtide/treefmt-nix/3eb96ca1ae9edf792a8e0963cc92fddfa5a87706' (2024-06-01)
2024-06-03 00:00:22 +00:00
0993fe45f6 Merge pull request 'clan-cli: cmd.py uses pseudo terminal now. Remove tty.py. Refactor password_store.py to use cmd.py' (#1536) from Qubasa/clan-core:Qubasa-main into main 2024-06-02 14:56:41 +00:00
ba86b49952 clan-cli: cmd.py uses pseudo terminal now. Remove tty.py. Refactor password_store.py to use cmd.py. 2024-06-02 16:52:31 +02:00
0b34c340fc Merge pull request 'clan-cli: Refactor machines/update.py to cmd.run' (#1535) from Qubasa/clan-core:Qubasa-main into main 2024-06-02 08:04:18 +00:00
d513f66170 clan-cli: Refactor machines/update.py to cmd.run 2024-06-02 10:00:23 +02:00
320fb776ea Merge pull request 'clan-cli: Add input arg to cmd.run. Replace subprocess.run in password_store' (#1533) from Qubasa/clan-core:Qubasa-main into main 2024-06-02 07:57:18 +00:00
1a39957dbb clan-cli: Refactor subprocess.run to cmd.run. tea-create-pr: Fix missing fail-on-change for treefmt 2024-06-02 09:53:24 +02:00
b5abe4025a Merge pull request 'docs: Add meta tags for link preview and fix js loading issue.' (#1531) from Qubasa/clan-core:Qubasa-main into main 2024-06-01 20:23:30 +00:00
55f4dcc460 docs: Add meta tags for link preview and fix js loading issue. 2024-06-01 22:19:37 +02:00
ef4a83f739 Merge pull request 'clan-core: add clan meta for ui usage' (#1529) from hsjobeki-main into main
Reviewed-on: clan/clan-core#1529
2024-05-31 16:26:46 +00:00
133f2b705f clan-core: add template to impure tests 2024-05-31 16:26:46 +00:00
83fe58e003 clan-core: add clan meta for ui usage 2024-05-31 16:26:46 +00:00
481f926b17 Merge pull request 'split list machines into show machine command' (#1521) from machines-show into main 2024-05-31 15:00:03 +00:00
788eae432a split list machines into show machine command 2024-05-31 16:56:09 +02:00
b7936c4ed2 Merge pull request 'upgrade nix in development to latest' (#1528) from nix-latest into main 2024-05-31 14:41:21 +00:00
750c8df003 upgrade nix in development to latest
Better error messages!!
2024-05-31 16:37:07 +02:00
276c39aba4 Merge pull request 'Contributing.md: Fix incorrect formating.' (#1527) from Qubasa/clan-core:Qubasa-main into main 2024-05-31 14:02:18 +00:00
90e25eeb76 Contributing.md: Fix incorrect formating. 2024-05-31 15:58:29 +02:00
56676701ae Merge pull request 'clan: add dynamic completions for fact generation services' (#1525) from a-kenji-clan/complete-services into main 2024-05-31 13:25:15 +00:00
bcccf301f0 clan: add dynamic completions for fact generation services 2024-05-31 15:21:07 +02:00
e343ba5635 Merge pull request 'Contributing.md: Explain merge-after-ci for externals.' (#1524) from Qubasa/clan-core:Qubasa-main into main 2024-05-31 12:02:02 +00:00
66fe5ec4fd Contributing.md: Explain merge-after-ci for externals. 2024-05-31 13:58:13 +02:00
f2a884ec30 Merge pull request 'clan: add completion timeout as static' (#1523) from a-kenji-clan/completions into main 2024-05-31 11:10:52 +00:00
d31aa7cf88 clan: add completion timeout as static 2024-05-31 13:06:46 +02:00
9f19a8e605 Merge pull request 'clan: add dynamic completions' (#1522) from a-kenji-clan/cli/init-dynamic-completions into main 2024-05-31 11:00:50 +00:00
23ef39a2d9 clan: add dynamic completions
Add dynamic completion scaffolding to the clan `cli`.
Also add a dynamic completion mechanism for machines for commands that
have machines as their sole argument.

More intricate dynamic completions will be implemented in follow up
PR's.
2024-05-31 12:55:41 +02:00
dda82c49b0 Merge pull request 'tea-create-pr: Add automatic rebase and autostash' (#1518) from Qubasa/clan-core:Qubasa-main into main 2024-05-30 22:03:38 +00:00
c91c90a2a6 tea-create-pr: Add automatic rebase and autostash 2024-05-30 23:59:27 +02:00
5794cdf8fa Merge pull request 'docs: Fix installer wrong indentation' (#1516) from Qubasa/clan-core:Qubasa-main into main 2024-05-30 21:44:41 +00:00
01a4748d6b tea-create-pr: Fix non working assignees label 2024-05-30 23:37:53 +02:00
a8762522c8 tea-create-pr: Better username detection 2024-05-30 23:29:59 +02:00
adef52a938 docs: Fix installer wrong indentation 2024-05-30 22:41:30 +02:00
c8fbf87fc8 Merge pull request 'Change clan favicon to one without text' (#1506) from Qubasa/clan-core:Qubasa-main into main 2024-05-30 20:30:02 +00:00
f63e3618c2 tea-create-pr: Require fork and upstream branch 2024-05-30 22:25:25 +02:00
b18d7bfeac Change clan favicon to one without text 2024-05-30 21:59:48 +02:00
076b98ff00 Merge pull request 'Webview: css font and icon import transformation' (#1501) from hsjobeki-main into main 2024-05-30 16:28:14 +00:00
6999685bba
Webview: css font and icon import transformation 2024-05-30 18:23:49 +02:00
f1c02bbd46 Merge pull request 'Add top level domain option for zerotier machines.' (#1499) from mrvandalo/clan-core:feature/static-host-tld into main
Reviewed-on: clan/clan-core#1499
2024-05-29 18:40:15 +00:00
2caa837537 Add top level domain option for zerotier machines. 2024-05-29 18:40:15 +00:00
e1ddbf226a Merge pull request 'install.sh: improvements' (#1500) from DavHau-install-dev into main 2024-05-29 18:03:50 +00:00
7cb8c114c2 install.sh: improvements
- use either curl or wget
- add to PATH /nix/var/nix/profiles/default/bin
2024-05-29 18:51:34 +02:00
5945630870 Merge pull request 'gui-installer: depend on git + ignore flake config' (#1498) from DavHau-dave into main 2024-05-29 15:48:54 +00:00
ccadac4bb3 gui-installer: depend on git + ignore flake config 2024-05-29 17:42:44 +02:00
15b77f6b8a Merge pull request 'Webview: bootstrap layout' (#1497) from hsjobeki-main into main 2024-05-29 14:45:45 +00:00
9bf76037aa
Webview: bootstrap layout 2024-05-29 16:40:54 +02:00
d0d973b797 Merge pull request 'make config command read-only' (#1319) from config into main
Reviewed-on: clan/clan-core#1319
2024-05-29 11:25:27 +00:00
c1e2bc9ea9 make config command read-only 2024-05-29 13:17:55 +02:00
0eef21e2ef Merge pull request 'Update flakes' (#1492) from pass-nix-options into main 2024-05-29 10:58:19 +00:00
461aa579c2 fmt more stuff 2024-05-29 12:51:43 +02:00
da442c47f6 drop non-compiling wayland-proxy-virtwl 2024-05-29 12:51:18 +02:00
491d37ea67 update flake 2024-05-29 12:51:04 +02:00
7e087d18ee Merge pull request 'fix offline build of flash command' (#1491) from pass-nix-options into main 2024-05-29 10:49:15 +00:00
5c75a6490b fix offline build of flash command 2024-05-29 12:45:50 +02:00
750b6aec59 flash: make configuration more explicit
Injecting nixos configuration and potentially overriding settings a user
made and can cause surprises.
In most cases, users want to just make these option part of their NixOS
configuration and by having the rest in the command line
we make it more explicit what other configuration is being applied.
2024-05-29 12:45:50 +02:00
d138e29a53 Merge pull request 'Consistently pass nix options to underlying tools' (#1488) from pass-nix-options into main 2024-05-29 08:25:53 +00:00
a7febba9c8 Merge pull request 'clan: clarify default backend' (#1490) from a-kenji-cli/facts-clarify into main 2024-05-29 08:23:06 +00:00
f0f97baa65 drop global argparse flags
They get shadowed by subargparser options.
2024-05-29 10:21:35 +02:00
c2dc94507e clan: clarify default backend 2024-05-29 10:17:22 +02:00
7c0aaab463 Merge pull request 'clan: add epilog to facts subcommands' (#1489) from a-kenji-cli/expand-examples into main 2024-05-29 08:15:46 +00:00
5dcac604d1 backup cli: make sure we have a flake 2024-05-29 10:14:14 +02:00
96746b7c98 flash: add write-efi-boot-entries flag 2024-05-29 10:14:14 +02:00
2ae50b7398 allow to override nix options in update/install/flash commands 2024-05-29 10:14:14 +02:00
3c905c5072 clan: add epilog to facts subcommands 2024-05-29 10:10:23 +02:00
5b926f57cc cli: also register common flags in subcommands
When a user runs --help on a subcommand they don't see some options such
as --options or --flake. To fix this we now register all common flags
also in subcommands.
2024-05-29 09:29:49 +02:00
b9788a5dba Merge pull request 'clan/docs.py: remove epilog from the reference overview' (#1487) from a-kenji-cli/docs/reference-overview into main 2024-05-28 18:05:07 +00:00
7078f09872 clan/docs.py: remove epilog from the reference overview 2024-05-28 20:01:48 +02:00
1aa7808c02 Merge pull request 'Update Contributing guide to external developers' (#1484) from Qubasa/clan-core:main into main
Reviewed-on: clan/clan-core#1484
2024-05-28 16:12:11 +00:00
ba8a51101d Update Contributing guide to external developers 2024-05-28 18:06:31 +02:00
de69c970aa Merge pull request 'packaging: package clan gui for many distros' (#1485) from DavHau-dave into main 2024-05-28 15:54:08 +00:00
fe5fa6a85d packaging: package clan gui for many distros 2024-05-28 17:50:32 +02:00
de74febf64 Merge pull request 'packaging: package clan gui for many distros' (#1483) from DavHau-dave into main 2024-05-28 15:37:18 +00:00
3b6483e819 packaging: package clan gui for many distros 2024-05-28 17:33:55 +02:00
dcd6ad0983 Merge pull request 'Docs: fix relative links to git.clan.lol' (#1482) from hsjobeki-main into main 2024-05-28 15:18:45 +00:00
567d979243
Docs: fix relative links to git.clan.lol 2024-05-28 17:14:16 +02:00
c81a8681b0 Merge pull request 'clan/docs.py: add epilog to reference docs' (#1481) from a-kenji-docs/epilog into main 2024-05-28 15:13:57 +00:00
31cde90819 clan/docs.py: add epilog to reference docs
Fixes #1469
2024-05-28 17:08:46 +02:00
a77bf5bf21 Merge pull request 'Docs: use offline fonts' (#1480) from hsjobeki-main into main 2024-05-28 15:05:22 +00:00
4befa80eb8
Docs: use offline fonts 2024-05-28 16:58:59 +02:00
52584662a8 Merge pull request 'Fix typos' (#1477) from a-kenji-fix/typos into main 2024-05-28 13:02:19 +00:00
de147f63e9 Fix typos 2024-05-28 14:58:38 +02:00
96c33dec7a Merge pull request 'consistent rename cLAN -> Clan' (#1475) from rename into main 2024-05-28 11:38:57 +00:00
3c0b5f0867 drop deprecated mdDoc 2024-05-28 13:35:11 +02:00
e2d7e6e86c consistent rename cLAN -> Clan 2024-05-27 15:54:17 +02:00
93 changed files with 1884 additions and 765 deletions

View File

@ -145,14 +145,14 @@
machine.succeed("echo testing > /var/test-backups/somefile")
# create
machine.succeed("clan --debug --flake ${self} backups create test-backup")
machine.succeed("clan backups create --debug --flake ${self} test-backup")
machine.wait_until_succeeds("! systemctl is-active borgbackup-job-test-backup >&2")
machine.succeed("test -f /run/mount-external-disk")
machine.succeed("test -f /run/unmount-external-disk")
# list
backup_id = json.loads(machine.succeed("borg-job-test-backup list --json"))["archives"][0]["archive"]
out = machine.succeed("clan --debug --flake ${self} backups list test-backup").strip()
out = machine.succeed("clan backups list --debug --flake ${self} test-backup").strip()
print(out)
assert backup_id in out, f"backup {backup_id} not found in {out}"
localbackup_id = "hdd::/mnt/external-disk/snapshot.0"
@ -160,14 +160,14 @@
## borgbackup restore
machine.succeed("rm -f /var/test-backups/somefile")
machine.succeed(f"clan --debug --flake ${self} backups restore test-backup borgbackup 'test-backup::borg@machine:.::{backup_id}' >&2")
machine.succeed(f"clan backups restore --debug --flake ${self} test-backup borgbackup 'test-backup::borg@machine:.::{backup_id}' >&2")
assert machine.succeed("cat /var/test-backups/somefile").strip() == "testing", "restore failed"
machine.succeed("test -f /var/test-service/pre-restore-command")
machine.succeed("test -f /var/test-service/post-restore-command")
## localbackup restore
machine.succeed("rm -f /var/test-backups/somefile /var/test-service/{pre,post}-restore-command")
machine.succeed(f"clan --debug --flake ${self} backups restore test-backup localbackup '{localbackup_id}' >&2")
machine.succeed(f"clan backups restore --debug --flake ${self} test-backup localbackup '{localbackup_id}' >&2")
assert machine.succeed("cat /var/test-backups/somefile").strip() == "testing", "restore failed"
machine.succeed("test -f /var/test-service/pre-restore-command")
machine.succeed("test -f /var/test-service/post-restore-command")

View File

@ -1,33 +1,50 @@
{ ... }:
{ self, ... }:
{
perSystem =
{ ... }:
{
# checks = pkgs.lib.mkIf (pkgs.stdenv.isLinux) {
# flash = (import ../lib/test-base.nix) {
# name = "flash";
# nodes.target = {
# virtualisation.emptyDiskImages = [ 4096 ];
# virtualisation.memorySize = 3000;
# environment.systemPackages = [ self.packages.${pkgs.system}.clan-cli ];
# environment.etc."install-closure".source = "${closureInfo}/store-paths";
nodes,
pkgs,
lib,
...
}:
let
dependencies = [
pkgs.disko
self.clanInternals.machines.${pkgs.hostPlatform.system}.test_install_machine.config.system.build.toplevel
self.clanInternals.machines.${pkgs.hostPlatform.system}.test_install_machine.config.system.build.diskoScript
self.clanInternals.machines.${pkgs.hostPlatform.system}.test_install_machine.config.system.build.diskoScript.drvPath
self.clanInternals.machines.${pkgs.hostPlatform.system}.test_install_machine.config.system.clan.deployment.file
] ++ builtins.map (i: i.outPath) (builtins.attrValues self.inputs);
closureInfo = pkgs.closureInfo { rootPaths = dependencies; };
in
{
# Currently disabled...
checks = pkgs.lib.mkIf (pkgs.stdenv.isLinux) {
flash = (import ../lib/test-base.nix) {
name = "flash";
nodes.target = {
virtualisation.emptyDiskImages = [ 4096 ];
virtualisation.memorySize = 3000;
environment.systemPackages = [ self.packages.${pkgs.system}.clan-cli ];
environment.etc."install-closure".source = "${closureInfo}/store-paths";
# nix.settings = {
# substituters = lib.mkForce [ ];
# hashed-mirrors = null;
# connect-timeout = lib.mkForce 3;
# flake-registry = pkgs.writeText "flake-registry" ''{"flakes":[],"version":2}'';
# experimental-features = [
# "nix-command"
# "flakes"
# ];
# };
# };
# testScript = ''
# start_all()
# machine.succeed("clan --debug --flake ${../..} flash --yes --disk main /dev/vdb test_install_machine")
# '';
# } { inherit pkgs self; };
# };
nix.settings = {
substituters = lib.mkForce [ ];
hashed-mirrors = null;
connect-timeout = lib.mkForce 3;
flake-registry = pkgs.writeText "flake-registry" ''{"flakes":[],"version":2}'';
experimental-features = [
"nix-command"
"flakes"
];
};
};
testScript = ''
start_all()
machine.succeed("clan flash --debug --flake ${../..} --yes --disk main /dev/vdb test_install_machine")
'';
} { inherit pkgs self; };
};
};
}

View File

@ -2,8 +2,8 @@
{
clan.machines.test_install_machine = {
clan.networking.targetHost = "test_install_machine";
fileSystems."/".device = lib.mkDefault "/dev/null";
boot.loader.grub.device = lib.mkDefault "/dev/null";
fileSystems."/".device = lib.mkDefault "/dev/vdb";
boot.loader.grub.device = lib.mkDefault "/dev/vdb";
imports = [ self.nixosModules.test_install_machine ];
};
@ -98,7 +98,7 @@
client.succeed("${pkgs.coreutils}/bin/install -Dm 600 ${../lib/ssh/privkey} /root/.ssh/id_ed25519")
client.wait_until_succeeds("ssh -o StrictHostKeyChecking=accept-new -v root@target hostname")
client.succeed("clan --debug --flake ${../..} machines install --yes test_install_machine root@target >&2")
client.succeed("clan machines install --debug --flake ${../..} --yes test_install_machine root@target >&2")
try:
target.shutdown()
except BrokenPipeError:

View File

@ -10,6 +10,7 @@ in
hostPkgs = pkgs;
# speed-up evaluation
defaults = {
nix.package = pkgs.nixVersions.latest;
documentation.enable = lib.mkDefault false;
boot.isContainer = true;

View File

@ -10,6 +10,7 @@ in
defaults = {
documentation.enable = lib.mkDefault false;
nix.settings.min-free = 0;
nix.package = pkgs.nixVersions.latest;
};
# to accept external dependencies such as disko

View File

@ -9,7 +9,7 @@
# - cli frontend: https://github.com/localsend/localsend/issues/11
# - ipv6 support: https://github.com/localsend/localsend/issues/549
options.clan.localsend = {
enable = lib.mkEnableOption (lib.mdDoc "enable the localsend module");
enable = lib.mkEnableOption "enable the localsend module";
defaultLocation = lib.mkOption {
type = lib.types.str;
description = "The default download location";

View File

@ -3,9 +3,15 @@
options.clan.static-hosts = {
excludeHosts = lib.mkOption {
type = lib.types.listOf lib.types.str;
default = [ config.clanCore.machineName ];
default =
if config.clan.static-hosts.topLevelDomain != "" then [ ] else [ config.clanCore.machineName ];
description = "Hosts that should be excluded";
};
topLevelDomain = lib.mkOption {
type = lib.types.str;
default = "";
description = "Top level domain to reach hosts";
};
};
config.networking.hosts =
@ -24,7 +30,15 @@
let
path = zerotierIpMachinePath machine;
in
if builtins.pathExists path then lib.nameValuePair (builtins.readFile path) [ machine ] else null
if builtins.pathExists path then
lib.nameValuePair (builtins.readFile path) (
if (config.clan.static-hosts.topLevelDomain == "") then
[ machine ]
else
[ "${machine}.${config.clan.static-hosts.topLevelDomain}" ]
)
else
null
) filteredMachines
);
}

View File

@ -20,7 +20,7 @@ let
if builtins.pathExists fullPath then builtins.readFile fullPath else null
) machines;
networkIds = lib.filter (machine: machine != null) networkIdsUnchecked;
networkId = builtins.elemAt networkIds 0;
networkId = if builtins.length networkIds == 0 then null else builtins.elemAt networkIds 0;
in
#TODO:trace on multiple found network-ids
#TODO:trace on no single found networkId
@ -38,7 +38,7 @@ in
machines = builtins.readDir machineDir;
zerotierIpMachinePath = machines: machineDir + machines + "/facts/zerotier-ip";
filteredMachines = lib.filterAttrs (
name: _: !(lib.elem name config.clan.static-hosts.excludeHosts)
name: _: !(lib.elem name config.clan.zerotier-static-peers.excludeHosts)
) machines;
hosts = lib.mapAttrsToList (host: _: host) (
lib.mapAttrs' (

View File

@ -27,7 +27,8 @@
packages = [
select-shell
pkgs.tea
pkgs.nix
# Better error messages than nix 2.18
pkgs.nixVersions.latest
self'.packages.tea-create-pr
self'.packages.merge-after-ci
self'.packages.pending-reviews

4
docs/.gitignore vendored
View File

@ -1 +1,3 @@
/site/reference
/site/reference
/site/static/Roboto-Regular.ttf
/site/static/FiraCode-VF.ttf

View File

@ -15,92 +15,124 @@ Let's get your development environment up and running:
1. **Install Nix Package Manager**:
- You can install the Nix package manager by either [downloading the Nix installer](https://github.com/DeterminateSystems/nix-installer/releases) or running this command:
```bash
curl --proto '=https' --tlsv1.2 -sSf -L https://install.determinate.systems/nix | sh -s -- install
```
- You can install the Nix package manager by either [downloading the Nix installer](https://github.com/DeterminateSystems/nix-installer/releases) or running this command:
```bash
curl --proto '=https' --tlsv1.2 -sSf -L https://install.determinate.systems/nix | sh -s -- install
```
2. **Install direnv**:
- Download the direnv package from [here](https://direnv.net/docs/installation.html) or run the following command:
```bash
curl -sfL https://direnv.net/install.sh | bash
```
- To automatically setup a devshell on entering the directory
```bash
nix profile install nixpkgs#nix-direnv-flakes
```
3. **Add direnv to your shell**:
- Direnv needs to [hook into your shell](https://direnv.net/docs/hook.html) to work.
You can do this by executing following command. The example below will setup direnv for `zsh` and `bash`
- Direnv needs to [hook into your shell](https://direnv.net/docs/hook.html) to work.
You can do this by executing following command. The example below will setup direnv for `zsh` and `bash`
```bash
echo 'eval "$(direnv hook zsh)"' >> ~/.zshrc && echo 'eval "$(direnv hook bash)"' >> ~/.bashrc && eval "$SHELL"
```
4. **Clone the Repository and Navigate**:
- Clone this repository and navigate to it.
5. **Allow .envrc**:
- When you enter the directory, you'll receive an error message like this:
```bash
direnv: error .envrc is blocked. Run `direnv allow` to approve its content
```
- Execute `direnv allow` to automatically execute the shell script `.envrc` when entering the directory.
# Setting Up Your Git Workflow
Let's set up your Git workflow to collaborate effectively:
1. **Register Your Gitea Account Locally**:
- Execute the following command to add your Gitea account locally:
```bash
tea login add
```
- Fill out the prompt as follows:
- URL of Gitea instance: `https://git.clan.lol`
- Name of new Login [gitea.gchq.icu]: `gitea.gchq.icu:7171`
- Do you have an access token? No
- Username: YourUsername
- Password: YourPassword
- Set Optional settings: No
2. **Git Workflow**:
1. Add your changes to Git using `git add <file1> <file2>`.
2. Run `nix fmt` to lint your files.
3. Commit your changes with a descriptive message: `git commit -a -m "My descriptive commit message"`.
4. Make sure your branch has the latest changes from upstream by executing:
```bash
git fetch && git rebase origin/main --autostash
echo 'eval "$(direnv hook zsh)"' >> ~/.zshrc && echo 'eval "$(direnv hook bash)"' >> ~/.bashrc && eval "$SHELL"
```
5. Use `git status` to check for merge conflicts.
6. If conflicts exist, resolve them. Here's a tutorial for resolving conflicts in [VSCode](https://code.visualstudio.com/docs/sourcecontrol/overview#_merge-conflicts).
7. After resolving conflicts, execute `git merge --continue` and repeat step 5 until there are no conflicts.
3. **Create a Pull Request**:
4. **Create a Gitea Account**:
- Register an account on https://git.clan.lol
- Fork the [clan-core](https://git.clan.lol/clan/clan-core) repository
- Clone the repository and navigate to it
- Add a new remote called upstream:
```bash
git remote add upstream gitea@git.clan.lol:clan/clan-core.git
```
- To automatically open a pull request that gets merged if all tests pass, execute:
```bash
merge-after-ci
```
5. **Register Your Gitea Account Locally**:
4. **Review Your Pull Request**:
- Execute the following command to add your Gitea account locally:
```bash
tea login add
```
- Fill out the prompt as follows:
- URL of Gitea instance: `https://git.clan.lol`
- Name of new Login [git.clan.lol]:
- Do you have an access token? No
- Username: YourUsername
- Password: YourPassword
- Set Optional settings: No
- Visit https://git.clan.lol and go to the project page. Check under "Pull Requests" for any issues with your pull request.
5. **Push Your Changes**:
- If there are issues, fix them and redo step 2. Afterward, execute:
```bash
git push origin HEAD:YourUsername-main
```
- This will directly push to your open pull request.
6. **Allow .envrc**:
- When you enter the directory, you'll receive an error message like this:
```bash
direnv: error .envrc is blocked. Run `direnv allow` to approve its content
```
- Execute `direnv allow` to automatically execute the shell script `.envrc` when entering the directory.
7. **(Optional) Install Git Hooks**:
- To syntax check your code you can run:
```bash
nix fmt
```
- To make this automatic install the git hooks
```bash
./scripts/pre-commit
```
8. **Open a Pull Request**:
- To automatically open up a pull request you can use our tool called:
```
merge-after-ci --reviewers Mic92 Lassulus Qubasa
```
# Debugging
Here are some methods for debugging and testing the clan-cli:
## See all possible packages and tests
To quickly show all possible packages and tests execute:
```bash
nix flake show --system no-eval
```
Under `checks` you will find all tests that are executed in our CI. Under `packages` you find all our projects.
```
git+file:///home/lhebendanz/Projects/clan-core
├───apps
│ └───x86_64-linux
│ ├───install-vm: app
│ └───install-vm-nogui: app
├───checks
│ └───x86_64-linux
│ ├───borgbackup omitted (use '--all-systems' to show)
│ ├───check-for-breakpoints omitted (use '--all-systems' to show)
│ ├───clan-dep-age omitted (use '--all-systems' to show)
│ ├───clan-dep-bash omitted (use '--all-systems' to show)
│ ├───clan-dep-e2fsprogs omitted (use '--all-systems' to show)
│ ├───clan-dep-fakeroot omitted (use '--all-systems' to show)
│ ├───clan-dep-git omitted (use '--all-systems' to show)
│ ├───clan-dep-nix omitted (use '--all-systems' to show)
│ ├───clan-dep-openssh omitted (use '--all-systems' to show)
│ ├───"clan-dep-python3.11-mypy" omitted (use '--all-systems' to show)
├───packages
│ └───x86_64-linux
│ ├───clan-cli omitted (use '--all-systems' to show)
│ ├───clan-cli-docs omitted (use '--all-systems' to show)
│ ├───clan-ts-api omitted (use '--all-systems' to show)
│ ├───clan-vm-manager omitted (use '--all-systems' to show)
│ ├───default omitted (use '--all-systems' to show)
│ ├───deploy-docs omitted (use '--all-systems' to show)
│ ├───docs omitted (use '--all-systems' to show)
│ ├───editor omitted (use '--all-systems' to show)
└───templates
├───default: template: Initialize a new clan flake
└───new-clan: template: Initialize a new clan flake
```
You can execute every test separately by following the tree path `nix build .#checks.x86_64-linux.clan-pytest` for example.
## Test Locally in Devshell with Breakpoints
To test the cli locally in a development environment and set breakpoints for debugging, follow these steps:
@ -150,12 +182,14 @@ If you need to inspect the Nix sandbox while running tests, follow these steps:
2. Use `cntr` and `psgrep` to attach to the Nix sandbox. This allows you to interactively debug your code while it's paused. For example:
```bash
cntr exec -w your_sandbox_name
psgrep -a -x your_python_process_name
cntr attach <container id, container name or process id>
```
Or you can also use the [nix breakpoint hook](https://nixos.org/manual/nixpkgs/stable/#breakpointhook)
# Standards
Every new module name should be in kebab-case.
Every fact definition, where possible should be in kebab-case.
- Every new module name should be in kebab-case.
- Every fact definition, where possible should be in kebab-case.

View File

@ -16,15 +16,26 @@ def define_env(env: Any) -> None:
@env.macro
def asciinema(name: str) -> str:
return f"""<div id="{name}">
<script src="{asciinema_dir}/asciinema-player.min.js"></script>
<script>
AsciinemaPlayer.create('{video_dir + name}',
document.getElementById("{name}"), {{
loop: true,
autoPlay: true,
controls: false,
speed: 1.5,
theme: "solarized-light"
}});
// Function to load the script and then create the Asciinema player
function loadAsciinemaPlayer() {{
var script = document.createElement('script');
script.src = "{asciinema_dir}/asciinema-player.min.js";
script.onload = function() {{
AsciinemaPlayer.create('{video_dir + name}', document.getElementById("{name}"), {{
loop: true,
autoPlay: true,
controls: false,
speed: 1.5,
theme: "solarized-light"
}});
}};
document.head.appendChild(script);
}}
// Load the Asciinema player script
loadAsciinemaPlayer();
</script>
<link rel="stylesheet" type="text/css" href="{asciinema_dir}/asciinema-player.css" />
</div>"""

View File

@ -1,4 +1,4 @@
site_name: Clan Docs
site_name: Clan Documentation
site_url: https://docs.clan.lol
repo_url: https://git.clan.lol/clan/clan-core/
repo_name: clan-core
@ -39,7 +39,7 @@ exclude_docs: |
nav:
- Blog:
- blog/index.md
- blog/index.md
- Getting started:
- index.md
- Installer: getting-started/installer.md
@ -94,8 +94,9 @@ docs_dir: site
site_dir: out
theme:
font: false
logo: https://clan.lol/static/logo/clan-white.png
favicon: https://clan.lol/static/logo/clan-dark.png
favicon: https://clan.lol/static/dark-favicon/128x128.png
name: material
features:
- navigation.instant
@ -104,9 +105,8 @@ theme:
- content.code.copy
- content.tabs.link
icon:
repo: fontawesome/brands/git
font:
code: Roboto Mono
repo: fontawesome/brands/git-alt
custom_dir: overrides
palette:
# Palette toggle for light mode
@ -128,8 +128,7 @@ theme:
name: Switch to light mode
extra_css:
- static/asciinema-player/custom-theme.css
- static/asciinema-player/asciinema-player.css
- static/extra.css
extra:
social:
@ -142,7 +141,6 @@ extra:
- icon: fontawesome/solid/rss
link: /feed_rss_created.xml
plugins:
- search
- blog

View File

@ -4,6 +4,8 @@
clan-cli-docs,
asciinema-player-js,
asciinema-player-css,
roboto,
fira-code,
...
}:
let
@ -33,6 +35,10 @@ pkgs.stdenv.mkDerivation {
mkdir -p ./site/static/asciinema-player
ln -snf ${asciinema-player-js} ./site/static/asciinema-player/asciinema-player.min.js
ln -snf ${asciinema-player-css} ./site/static/asciinema-player/asciinema-player.css
# Link to fonts
ln -snf ${roboto}/share/fonts/truetype/Roboto-Regular.ttf ./site/static/
ln -snf ${fira-code}/share/fonts/truetype/FiraCode-VF.ttf ./site/static/
'';
buildPhase = ''

View File

@ -40,13 +40,14 @@ def sanitize(text: str) -> str:
return text.replace(">", "\\>")
def replace_store_path(text: str) -> Path:
def replace_store_path(text: str) -> tuple[str, str]:
res = text
if text.startswith("/nix/store/"):
res = "https://git.clan.lol/clan/clan-core/src/branch/main/" + str(
Path(*Path(text).parts[4:])
)
return Path(res)
name = Path(res).name
return (res, name)
def render_option_header(name: str) -> str:
@ -108,9 +109,10 @@ def render_option(name: str, option: dict[str, Any], level: int = 3) -> str:
"""
decls = option.get("declarations", [])
source_path = replace_store_path(decls[0])
source_path, name = replace_store_path(decls[0])
print(source_path, name)
res += f"""
:simple-git: [{source_path.name}]({source_path})
:simple-git: [{name}]({source_path})
"""
res += "\n"
@ -160,7 +162,7 @@ def produce_clan_core_docs() -> None:
for option_name, info in options.items():
outfile = f"{module_name}/index.md"
# Create seperate files for nested options
# Create separate files for nested options
if len(option_name.split(".")) <= 2:
# i.e. clan-core.clanDir
output = core_outputs.get(

View File

@ -5,6 +5,8 @@
clan-cli-docs,
asciinema-player-js,
asciinema-player-css,
roboto,
fira-code,
...
}:
pkgs.mkShell {
@ -18,7 +20,12 @@ pkgs.mkShell {
echo "Generated API documentation in './site/reference/' "
mkdir -p ./site/static/asciinema-player
ln -snf ${asciinema-player-js} ./site/static/asciinema-player/asciinema-player.min.js
ln -snf ${asciinema-player-css} ./site/static/asciinema-player/asciinema-player.css
# Link to fonts
ln -snf ${roboto}/share/fonts/truetype/Roboto-Regular.ttf ./site/static/
ln -snf ${fira-code}/share/fonts/truetype/FiraCode-VF.ttf ./site/static/
'';
}

12
docs/overrides/main.html Normal file
View File

@ -0,0 +1,12 @@
{% extends "base.html" %}
{% block extrahead %}
<meta property="og:title" content="Clan - Documentation, Blog & Getting Started Guide" />
<meta property="og:description" content="Documentation for Clan. The peer-to-peer machine deployment framework." />
<meta property="og:image" content="https://clan.lol/static/dark-favicon/128x128.png" />
<meta property="og:url" content="https://docs.clan.lol" />
<meta property="og:type" content="website" />
<meta property="og:site_name" content="Clan" />
<meta property="og:locale" content="en_US" />
{% endblock %}

View File

@ -42,13 +42,22 @@ sudo umount /dev/sdb1
=== "**Linux OS**"
### Step 2. Flash Custom Installer
Using clan flash enables the inclusion of ssh public keys and disables ssh password authentication.
It also includes the language and keymap currently used into the installer image.
Using clan flash enables the inclusion of ssh public keys.
It also allows to set language and keymap currently in the installer image.
```bash
clan --flake git+https://git.clan.lol/clan/clan-core flash flash-installer --disk main /dev/sd<X>
clan flash --flake git+https://git.clan.lol/clan/clan-core \
--ssh-pubkey $HOME/.ssh/id_ed25519.pub \
--keymap en \
--language en \
--disk main /dev/sd<X> \
flash-installer
```
The `--ssh-pubkey`, `--language` and `--keymap` are optional.
Replace `$HOME/.ssh/id_ed25519.pub` with a path to your SSH public key.
If you do not have an ssh key yet, you can generate one with `ssh-keygen -t ed25519` command.
!!! Danger "Specifying the wrong device can lead to unrecoverable data loss."
The `clan flash` utility will erase the disk. Make sure to specify the correct device

View File

@ -0,0 +1,13 @@
@font-face {
font-family: "Roboto";
src: url(./Roboto-Regular.ttf) format('truetype');
}
@font-face {
font-family: "Fira Code";
src: url(./FiraCode-VF.ttf) format('truetype');
}
:root {
--md-text-font: "Roboto";
--md-code-font: "Fira Code";
}

View File

@ -7,11 +7,11 @@
]
},
"locked": {
"lastModified": 1716394172,
"narHash": "sha256-B+pNhV8GFeCj9/MoH+qtGqKbgv6fU4hGaw2+NoYYtB0=",
"lastModified": 1717177033,
"narHash": "sha256-G3CZJafCO8WDy3dyA2EhpUJEmzd5gMJ2IdItAg0Hijw=",
"owner": "nix-community",
"repo": "disko",
"rev": "23c63fb09334c3e8958b57e2ddc3870b75b9111d",
"rev": "0274af4c92531ebfba4a5bd493251a143bc51f3c",
"type": "github"
},
"original": {
@ -27,11 +27,11 @@
]
},
"locked": {
"lastModified": 1715865404,
"narHash": "sha256-/GJvTdTpuDjNn84j82cU6bXztE0MSkdnTWClUCRub78=",
"lastModified": 1717285511,
"narHash": "sha256-iKzJcpdXih14qYVcZ9QC9XuZYnPc6T8YImb6dX166kw=",
"owner": "hercules-ci",
"repo": "flake-parts",
"rev": "8dc45382d5206bd292f9c2768b8058a8fd8311d9",
"rev": "2a55567fcf15b1b1c7ed712a2c6fadaec7412ea8",
"type": "github"
},
"original": {
@ -57,11 +57,11 @@
},
"nixos-2311": {
"locked": {
"lastModified": 1715818734,
"narHash": "sha256-WvAJWCwPj/6quKcsgsvQYyZRxV8ho/yUzj0HZQ34DVU=",
"lastModified": 1717017538,
"narHash": "sha256-S5kltvDDfNQM3xx9XcvzKEOyN2qk8Sa+aSOLqZ+1Ujc=",
"owner": "NixOS",
"repo": "nixpkgs",
"rev": "95742536dc6debb5a8b8b78b27001c38f369f1e7",
"rev": "64e468fd2652105710d86cd2ae3e65a5a6d58dec",
"type": "github"
},
"original": {
@ -79,11 +79,11 @@
]
},
"locked": {
"lastModified": 1716123454,
"narHash": "sha256-U2o4UPM/UsEyIX2p11+YEQgR9HY3PmjZ2mRl/x5e4xo=",
"lastModified": 1716210724,
"narHash": "sha256-iqQa3omRcHGpWb1ds75jS9ruA5R39FTmAkeR3J+ve1w=",
"owner": "nix-community",
"repo": "nixos-generators",
"rev": "a63e0c83dd83fe28cc571b97129e13373436bd82",
"rev": "d14b286322c7f4f897ca4b1726ce38cb68596c94",
"type": "github"
},
"original": {
@ -100,11 +100,11 @@
]
},
"locked": {
"lastModified": 1716132123,
"narHash": "sha256-rATSWbPaKQfZGaemu0tHL2xfCzVIVwpuTjk+KSBC+k4=",
"lastModified": 1717040312,
"narHash": "sha256-yI/en4IxuCEClIUpIs3QTyYCCtmSPLOhwLJclfNwdeg=",
"owner": "nix-community",
"repo": "nixos-images",
"rev": "8c9cab8c44434c12dafc465fbf61a710c5bceb08",
"rev": "47bfb55316e105390dd761e0b6e8e0be09462b67",
"type": "github"
},
"original": {
@ -115,11 +115,11 @@
},
"nixpkgs": {
"locked": {
"lastModified": 1716127062,
"narHash": "sha256-2rk8FqB/iQV2d0vQLs684/Tj5PUHaS1sFwG7fng5vXE=",
"lastModified": 1717298511,
"narHash": "sha256-9sXuJn/nL+9ImeYtlspTvjt83z1wIgU+9AwfNbnq+tI=",
"owner": "NixOS",
"repo": "nixpkgs",
"rev": "8a2555763c48e2410054de3f52f7310ce3241ec5",
"rev": "6634a0509e9e81e980b129435fbbec518ab246d0",
"type": "github"
},
"original": {
@ -148,11 +148,11 @@
"nixpkgs-stable": []
},
"locked": {
"lastModified": 1716087663,
"narHash": "sha256-zuSAGlx8Qk0OILGCC2GUyZ58/SJ5R3GZdeUNQ6IS0fQ=",
"lastModified": 1717297459,
"narHash": "sha256-cZC2f68w5UrJ1f+2NWGV9Gx0dEYmxwomWN2B0lx0QRA=",
"owner": "Mic92",
"repo": "sops-nix",
"rev": "0bf1808e70ce80046b0cff821c019df2b19aabf5",
"rev": "ab2a43b0d21d1d37d4d5726a892f714eaeb4b075",
"type": "github"
},
"original": {
@ -168,11 +168,11 @@
]
},
"locked": {
"lastModified": 1715940852,
"narHash": "sha256-wJqHMg/K6X3JGAE9YLM0LsuKrKb4XiBeVaoeMNlReZg=",
"lastModified": 1717278143,
"narHash": "sha256-u10aDdYrpiGOLoxzY/mJ9llST9yO8Q7K/UlROoNxzDw=",
"owner": "numtide",
"repo": "treefmt-nix",
"rev": "2fba33a182602b9d49f0b2440513e5ee091d838b",
"rev": "3eb96ca1ae9edf792a8e0963cc92fddfa5a87706",
"type": "github"
},
"original": {

View File

@ -24,10 +24,14 @@
};
outputs =
inputs@{ flake-parts, ... }:
inputs@{ flake-parts, self, ... }:
flake-parts.lib.mkFlake { inherit inputs; } (
{ ... }:
{
clan = {
# meta.name = "clan-core";
directory = self;
};
systems = [
"x86_64-linux"
"aarch64-linux"

View File

@ -17,6 +17,33 @@ let
cfg = config.clan;
in
{
imports = [
# TODO: figure out how to print the deprecation warning
# "${inputs.nixpkgs}/nixos/modules/misc/assertions.nix"
(lib.mkRenamedOptionModule
[
"clan"
"clanName"
]
[
"clan"
"meta"
"name"
]
)
(lib.mkRenamedOptionModule
[
"clan"
"clanIcon"
]
[
"clan"
"meta"
"icon"
]
)
];
options.clan = {
directory = mkOption {
type = types.path;
@ -33,15 +60,27 @@ in
default = { };
description = "Allows to include machine-specific modules i.e. machines.\${name} = { ... }";
};
clanName = mkOption {
type = types.str;
description = "Needs to be (globally) unique, as this determines the folder name where the flake gets downloaded to.";
};
clanIcon = mkOption {
type = types.nullOr types.path;
default = null;
description = "A path to an icon to be used for the clan, should be the same for all machines";
# Checks are performed in 'buildClan'
# Not everyone uses flake-parts
meta = {
name = lib.mkOption {
type = types.nullOr types.str;
default = null;
description = "Needs to be (globally) unique, as this determines the folder name where the flake gets downloaded to.";
};
icon = mkOption {
type = types.nullOr types.path;
default = null;
description = "A path to an icon to be used for the clan in the GUI";
};
description = mkOption {
type = types.nullOr types.str;
default = null;
description = "A short description of the clan";
};
};
pkgsForSystem = mkOption {
type = types.functionTo types.raw;
default = _system: null;
@ -52,6 +91,7 @@ in
clanInternals = lib.mkOption {
type = lib.types.submodule {
options = {
meta = lib.mkOption { type = lib.types.attrsOf lib.types.unspecified; };
all-machines-json = lib.mkOption { type = lib.types.attrsOf lib.types.unspecified; };
machines = lib.mkOption { type = lib.types.attrsOf (lib.types.attrsOf lib.types.unspecified); };
machinesFunc = lib.mkOption { type = lib.types.attrsOf (lib.types.attrsOf lib.types.unspecified); };
@ -65,9 +105,8 @@ in
directory
specialArgs
machines
clanName
clanIcon
pkgsForSystem
meta
;
};
};

View File

@ -7,16 +7,58 @@
directory, # The directory containing the machines subdirectory
specialArgs ? { }, # Extra arguments to pass to nixosSystem i.e. useful to make self available
machines ? { }, # allows to include machine-specific modules i.e. machines.${name} = { ... }
clanName, # Needs to be (globally) unique, as this determines the folder name where the flake gets downloaded to.
# DEPRECATED: use meta.name instead
clanName ? null, # Needs to be (globally) unique, as this determines the folder name where the flake gets downloaded to.
# DEPRECATED: use meta.icon instead
clanIcon ? null, # A path to an icon to be used for the clan, should be the same for all machines
meta ? { }, # A set containing clan meta: name :: string, icon :: string, description :: string
pkgsForSystem ? (_system: null), # A map from arch to pkgs, if specified this nixpkgs will be only imported once for each system.
# This improves performance, but all nipxkgs.* options will be ignored.
}:
let
deprecationWarnings = [
(lib.warnIf (
clanName != null
) "clanName is deprecated, please use meta.name instead. ${clanName}" null)
(lib.warnIf (clanIcon != null) "clanIcon is deprecated, please use meta.icon instead" null)
];
machinesDirs = lib.optionalAttrs (builtins.pathExists "${directory}/machines") (
builtins.readDir (directory + /machines)
);
mergedMeta =
let
metaFromFile =
if (builtins.pathExists "${directory}/clan/meta.json") then
let
settings = builtins.fromJSON (builtins.readFile "${directory}/clan/meta.json");
in
settings
else
{ };
legacyMeta = lib.filterAttrs (_: v: v != null) {
name = clanName;
icon = clanIcon;
};
optionsMeta = lib.filterAttrs (_: v: v != null) meta;
warnings =
builtins.map (
name:
if
metaFromFile.${name} or null != optionsMeta.${name} or null && optionsMeta.${name} or null != null
then
lib.warn "meta.${name} is set in different places. (exlicit option meta.${name} overrides ${directory}/clan/meta.json)" null
else
null
) (builtins.attrNames metaFromFile)
++ [ (if (res.name or null == null) then (throw "meta.name should be set") else null) ];
res = metaFromFile // legacyMeta // optionsMeta;
in
# Print out warnings before returning the merged result
builtins.deepSeq warnings res;
machineSettings =
machineName:
# CLAN_MACHINE_SETTINGS_FILE allows to override the settings file temporarily
@ -58,11 +100,15 @@ let
(machines.${name} or { })
(
{
networking.hostName = lib.mkDefault name;
clanCore.clanName = clanName;
clanCore.clanIcon = clanIcon;
# Settings
clanCore.clanDir = directory;
# Inherited from clan wide settings
clanCore.clanName = meta.name or clanName;
clanCore.clanIcon = meta.icon or clanIcon;
# Machine specific settings
clanCore.machineName = name;
networking.hostName = lib.mkDefault name;
nixpkgs.hostPlatform = lib.mkDefault system;
# speeds up nix commands by using the nixpkgs from the host system (especially useful in VMs)
@ -127,10 +173,15 @@ let
) supportedSystems
);
in
{
builtins.deepSeq deprecationWarnings {
inherit nixosConfigurations;
clanInternals = {
# Evaluated clan meta
# Merged /clan/meta.json with overrides from buildClan
meta = mergedMeta;
# machine specifics
machines = configsPerSystem;
machinesFunc = configsFuncPerSystem;
all-machines-json = lib.mapAttrs (

View File

@ -122,7 +122,7 @@ in
cores = lib.mkOption {
type = lib.types.ints.positive;
default = 1;
description = lib.mdDoc ''
description = ''
Specify the number of cores the guest is permitted to use.
The number can be higher than the available cores on the
host system.
@ -132,7 +132,7 @@ in
memorySize = lib.mkOption {
type = lib.types.ints.positive;
default = 1024;
description = lib.mdDoc ''
description = ''
The memory size in megabytes of the virtual machine.
'';
};
@ -140,7 +140,7 @@ in
graphics = lib.mkOption {
type = lib.types.bool;
default = true;
description = lib.mdDoc ''
description = ''
Whether to run QEMU with a graphics window, or in nographic mode.
Serial console will be enabled on both settings, but this will
change the preferred console.
@ -150,7 +150,7 @@ in
waypipe = lib.mkOption {
type = lib.types.bool;
default = false;
description = lib.mdDoc ''
description = ''
Whether to use waypipe for native wayland passthrough, or not.
'';
};

View File

@ -81,7 +81,7 @@ in
};
};
settings = lib.mkOption {
description = lib.mdDoc "override the network config in /var/lib/zerotier/bla/$network.json";
description = "override the network config in /var/lib/zerotier/bla/$network.json";
type = lib.types.submodule { freeformType = (pkgs.formats.json { }).type; };
};
};

View File

@ -51,19 +51,14 @@ class AppendOptionAction(argparse.Action):
lst.append(values[1])
def create_parser(prog: str | None = None) -> argparse.ArgumentParser:
parser = argparse.ArgumentParser(
prog=prog,
description="The clan cli tool.",
epilog=(
"""
Online reference for the clan cli tool: https://docs.clan.lol/reference/cli/
For more detailed information, visit: https://docs.clan.lol
"""
),
formatter_class=argparse.RawTextHelpFormatter,
)
def flake_path(arg: str) -> str | Path:
flake_dir = Path(arg).resolve()
if flake_dir.exists() and flake_dir.is_dir():
return flake_dir
return arg
def add_common_flags(parser: argparse.ArgumentParser) -> None:
parser.add_argument(
"--debug",
help="Enable debug logging",
@ -80,12 +75,6 @@ For more detailed information, visit: https://docs.clan.lol
default=[],
)
def flake_path(arg: str) -> str | Path:
flake_dir = Path(arg).resolve()
if flake_dir.exists() and flake_dir.is_dir():
return flake_dir
return arg
parser.add_argument(
"--flake",
help="path to the flake where the clan resides in, can be a remote flake or local, can be set through the [CLAN_DIR] environment variable",
@ -94,6 +83,30 @@ For more detailed information, visit: https://docs.clan.lol
type=flake_path,
)
def register_common_flags(parser: argparse.ArgumentParser) -> None:
has_subparsers = False
for action in parser._actions:
if isinstance(action, argparse._SubParsersAction):
for choice, child_parser in action.choices.items():
has_subparsers = True
register_common_flags(child_parser)
if not has_subparsers:
add_common_flags(parser)
def create_parser(prog: str | None = None) -> argparse.ArgumentParser:
parser = argparse.ArgumentParser(
prog=prog,
description="The clan cli tool.",
epilog=(
"""
Online reference for the clan cli tool: https://docs.clan.lol/reference/cli/
For more detailed information, visit: https://docs.clan.lol
"""
),
formatter_class=argparse.RawTextHelpFormatter,
)
subparsers = parser.add_subparsers()
parser_backups = subparsers.add_parser(
@ -139,12 +152,13 @@ For more detailed information, visit: https://docs.clan.lol/getting-started
),
formatter_class=argparse.RawTextHelpFormatter,
)
flakes.register_parser(parser_flake)
parser_config = subparsers.add_parser(
"config",
help="set nixos configuration",
description="set nixos configuration",
help="read a nixos configuration option",
description="read a nixos configuration option",
epilog=(
"""
"""
@ -208,7 +222,7 @@ For more detailed information, visit: https://docs.clan.lol/getting-started/secr
This subcommand provides an interface to facts of clan machines.
Facts are artifacts that a service can generate.
There are public and secret facts.
There are public and secret facts.
Public facts can be referenced by other machines directly.
Public facts can include: ip addresses, public keys.
Secret facts can include: passwords, private keys.
@ -223,7 +237,7 @@ Examples:
$ clan facts generate
Will generate facts for all machines.
$ clan facts generate --service [SERVICE] --regenerate
Will regenerate facts, if they are already generated for a specific service.
This is especially useful for resetting certain passwords while leaving the rest
@ -250,7 +264,7 @@ Examples:
List all the machines managed by clan.
$ clan machines update [MACHINES]
Will update the specified machine [MACHINE], if [MACHINE] is omitted, the command
Will update the specified machine [MACHINE], if [MACHINE] is omitted, the command
will attempt to update every configured machine.
$ clan machines install [MACHINES] [TARGET_HOST]
@ -285,6 +299,8 @@ For more detailed information, visit: https://docs.clan.lol/getting-started/depl
if argcomplete:
argcomplete.autocomplete(parser)
register_common_flags(parser)
return parser

View File

@ -2,6 +2,7 @@ import argparse
import json
import logging
from ..completions import add_dynamic_completer, complete_machines
from ..errors import ClanError
from ..machines.machines import Machine
@ -33,13 +34,17 @@ def create_backup(machine: Machine, provider: str | None = None) -> None:
def create_command(args: argparse.Namespace) -> None:
if args.flake is None:
raise ClanError("Could not find clan flake toplevel directory")
machine = Machine(name=args.machine, flake=args.flake)
create_backup(machine=machine, provider=args.provider)
def register_create_parser(parser: argparse.ArgumentParser) -> None:
parser.add_argument(
machines_parser = parser.add_argument(
"machine", type=str, help="machine in the flake to create backups of"
)
add_dynamic_completer(machines_parser, complete_machines)
parser.add_argument("--provider", type=str, help="backup provider to use")
parser.set_defaults(func=create_command)

View File

@ -3,6 +3,7 @@ import json
import subprocess
from dataclasses import dataclass
from ..completions import add_dynamic_completer, complete_machines
from ..errors import ClanError
from ..machines.machines import Machine
@ -48,6 +49,8 @@ def list_backups(machine: Machine, provider: str | None = None) -> list[Backup]:
def list_command(args: argparse.Namespace) -> None:
if args.flake is None:
raise ClanError("Could not find clan flake toplevel directory")
machine = Machine(name=args.machine, flake=args.flake)
backups = list_backups(machine=machine, provider=args.provider)
for backup in backups:
@ -55,8 +58,9 @@ def list_command(args: argparse.Namespace) -> None:
def register_list_parser(parser: argparse.ArgumentParser) -> None:
parser.add_argument(
machines_parser = parser.add_argument(
"machine", type=str, help="machine in the flake to show backups of"
)
add_dynamic_completer(machines_parser, complete_machines)
parser.add_argument("--provider", type=str, help="backup provider to filter by")
parser.set_defaults(func=list_command)

View File

@ -62,6 +62,8 @@ def restore_backup(
def restore_command(args: argparse.Namespace) -> None:
if args.flake is None:
raise ClanError("Could not find clan flake toplevel directory")
machine = Machine(name=args.machine, flake=args.flake)
restore_backup(
machine=machine,

View File

@ -56,7 +56,7 @@ def handle_output(process: subprocess.Popen, log: Log) -> tuple[str, str]:
sys.stderr.buffer.write(ret)
sys.stderr.flush()
stderr_buf += ret
return stdout_buf.decode("utf-8"), stderr_buf.decode("utf-8")
return stdout_buf.decode("utf-8", "replace"), stderr_buf.decode("utf-8", "replace")
class TimeTable:
@ -101,13 +101,19 @@ TIME_TABLE = TimeTable()
def run(
cmd: list[str],
*,
input: bytes | None = None, # noqa: A002
env: dict[str, str] | None = None,
cwd: Path = Path.cwd(),
log: Log = Log.STDERR,
check: bool = True,
error_msg: str | None = None,
) -> CmdOut:
glog.debug(f"$: {shlex.join(cmd)} \nCaller: {get_caller()}")
if input:
glog.debug(
f"""$: echo "{input.decode('utf-8', 'replace')}" | {shlex.join(cmd)} \nCaller: {get_caller()}"""
)
else:
glog.debug(f"$: {shlex.join(cmd)} \nCaller: {get_caller()}")
tstart = datetime.now()
# Start the subprocess
@ -120,7 +126,10 @@ def run(
)
stdout_buf, stderr_buf = handle_output(process, log)
rc = process.wait()
if input:
process.communicate(input)
else:
process.wait()
tend = datetime.now()
global TIME_TABLE
@ -136,7 +145,7 @@ def run(
msg=error_msg,
)
if check and rc != 0:
if check and process.returncode != 0:
raise ClanCmdError(cmd_out)
return cmd_out

View File

@ -0,0 +1,157 @@
import argparse
import json
import subprocess
import threading
from collections.abc import Callable, Iterable
from types import ModuleType
from typing import Any
from .cmd import run
from .nix import nix_eval
"""
This module provides dynamic completions.
The completions should feel fast.
We target a maximum of 1second on our average machine.
"""
argcomplete: ModuleType | None = None
try:
import argcomplete # type: ignore[no-redef]
except ImportError:
pass
# The default completion timeout for commands
COMPLETION_TIMEOUT: int = 3
def clan_dir(flake: str | None) -> str | None:
from .dirs import get_clan_flake_toplevel_or_env
path_result = get_clan_flake_toplevel_or_env()
return str(path_result) if path_result is not None else None
def complete_machines(
prefix: str, parsed_args: argparse.Namespace, **kwargs: Any
) -> Iterable[str]:
"""
Provides completion functionality for machine names configured in the clan.
"""
machines: list[str] = []
def run_cmd() -> None:
try:
# In my tests this was consistently faster than:
# nix eval .#nixosConfigurations --apply builtins.attrNames
cmd = ["nix", "flake", "show", "--system", "no-eval", "--json"]
if (clan_dir_result := clan_dir(None)) is not None:
cmd.append(clan_dir_result)
result = subprocess.run(
cmd,
check=True,
capture_output=True,
text=True,
)
data = json.loads(result.stdout)
try:
machines.extend(data.get("nixosConfigurations").keys())
except KeyError:
pass
except subprocess.CalledProcessError:
pass
thread = threading.Thread(target=run_cmd)
thread.start()
thread.join(timeout=COMPLETION_TIMEOUT)
if thread.is_alive():
return iter([])
machines_dict = {name: "machine" for name in machines}
return machines_dict
def complete_services_for_machine(
prefix: str, parsed_args: argparse.Namespace, **kwargs: Any
) -> Iterable[str]:
"""
Provides completion functionality for machine facts generation services.
"""
services: list[str] = []
# TODO: consolidate, if multiple machines are used
machines: list[str] = parsed_args.machines
def run_cmd() -> None:
try:
if (clan_dir_result := clan_dir(None)) is not None:
flake = clan_dir_result
else:
flake = "."
services_result = json.loads(
run(
nix_eval(
flags=[
f"{flake}#nixosConfigurations.{machines[0]}.config.clanCore.facts.services",
"--apply",
"builtins.attrNames",
],
),
).stdout.strip()
)
services.extend(services_result)
except subprocess.CalledProcessError:
pass
thread = threading.Thread(target=run_cmd)
thread.start()
thread.join(timeout=COMPLETION_TIMEOUT)
if thread.is_alive():
return iter([])
services_dict = {name: "service" for name in services}
return services_dict
def complete_secrets(
prefix: str, parsed_args: argparse.Namespace, **kwargs: Any
) -> Iterable[str]:
"""
Provides completion functionality for clan secrets
"""
from pathlib import Path
from .secrets.secrets import ListSecretsOptions, list_secrets
if (clan_dir_result := clan_dir(None)) is not None:
flake = clan_dir_result
else:
flake = "."
options = ListSecretsOptions(
flake=Path(flake),
pattern=None,
)
secrets = list_secrets(options.flake, options.pattern)
secrets_dict = {name: "secret" for name in secrets}
return secrets_dict
def add_dynamic_completer(
action: argparse.Action,
completer: Callable[..., Iterable[str]],
) -> None:
"""
Add a completion function to an argparse action, this will only be added,
if the argcomplete module is loaded.
"""
if argcomplete:
# mypy doesn't check this correctly, so we ignore it
action.completer = completer # type: ignore[attr-defined]

View File

@ -150,6 +150,15 @@ def read_machine_option_value(
return out
def get_option(args: argparse.Namespace) -> None:
print(
read_machine_option_value(
args.flake, args.machine, args.option, args.show_trace
)
)
# Currently writing is disabled
def get_or_set_option(args: argparse.Namespace) -> None:
if args.value == []:
print(
@ -307,7 +316,7 @@ def register_parser(
)
# inject callback function to process the input later
parser.set_defaults(func=get_or_set_option)
parser.set_defaults(func=get_option)
parser.add_argument(
"--machine",
"-m",
@ -345,13 +354,6 @@ def register_parser(
type=str,
)
parser.add_argument(
"value",
# force this arg to be set
nargs="*",
help="option value to set (if omitted, the current value is printed)",
)
def main(argv: list[str] | None = None) -> None:
if argv is None:

View File

@ -16,10 +16,54 @@ def register_parser(parser: argparse.ArgumentParser) -> None:
required=True,
)
check_parser = subparser.add_parser("check", help="check if facts are up to date")
check_parser = subparser.add_parser(
"check",
help="check if facts are up to date",
epilog=(
"""
This subcommand allows checking if all facts are up to date.
Examples:
$ clan facts check [MACHINE]
Will check facts for the specified machine.
For more detailed information, visit: https://docs.clan.lol/getting-started/secrets/
"""
),
formatter_class=argparse.RawTextHelpFormatter,
)
register_check_parser(check_parser)
list_parser = subparser.add_parser("list", help="list all facts")
list_parser = subparser.add_parser(
"list",
help="list all facts",
epilog=(
"""
This subcommand allows listing all public facts for a specific machine.
The resulting list will be a json string with the name of the fact as its key
and the fact itself as it's value.
This is how an example output might look like:
```
{
"[FACT_NAME]": "[FACT]"
}
```
Examples:
$ clan facts list [MACHINE]
Will list facts for the specified machine.
For more detailed information, visit: https://docs.clan.lol/getting-started/secrets/
"""
),
formatter_class=argparse.RawTextHelpFormatter,
)
register_list_parser(list_parser)
parser_generate = subparser.add_parser(
@ -62,5 +106,26 @@ For more detailed information, visit: https://docs.clan.lol/getting-started/secr
)
register_generate_parser(parser_generate)
parser_upload = subparser.add_parser("upload", help="upload secrets for machines")
parser_upload = subparser.add_parser(
"upload",
help="upload secrets for machines",
epilog=(
"""
This subcommand allows uploading secrets to remote machines.
If using sops as a secret backend it will upload the private key to the machine.
If using password store it uploads all the secrets you manage to the machine.
The default backend is sops.
Examples:
$ clan facts upload [MACHINE]
Will upload secrets to a specific machine.
For more detailed information, visit: https://docs.clan.lol/getting-started/secrets/
"""
),
formatter_class=argparse.RawTextHelpFormatter,
)
register_upload_parser(parser_upload)

View File

@ -2,6 +2,7 @@ import argparse
import importlib
import logging
from ..completions import add_dynamic_completer, complete_machines
from ..machines.machines import Machine
log = logging.getLogger(__name__)
@ -54,10 +55,12 @@ def check_command(args: argparse.Namespace) -> None:
def register_check_parser(parser: argparse.ArgumentParser) -> None:
parser.add_argument(
machines_parser = parser.add_argument(
"machine",
help="The machine to check secrets for",
)
add_dynamic_completer(machines_parser, complete_machines)
parser.add_argument(
"--service",
help="the service to check",

View File

@ -9,6 +9,11 @@ from tempfile import TemporaryDirectory
from clan_cli.cmd import run
from ..completions import (
add_dynamic_completer,
complete_machines,
complete_services_for_machine,
)
from ..errors import ClanError
from ..git import commit_files
from ..machines.inventory import get_all_machines, get_selected_machines
@ -27,6 +32,7 @@ def read_multiline_input(prompt: str = "Finish with Ctrl-D") -> str:
"""
print(prompt, flush=True)
proc = subprocess.run(["cat"], stdout=subprocess.PIPE, text=True)
log.info("Input received. Processing...")
return proc.stdout
@ -209,26 +215,30 @@ def generate_facts(
def generate_command(args: argparse.Namespace) -> None:
if len(args.machines) == 0:
machines = get_all_machines(args.flake)
machines = get_all_machines(args.flake, args.option)
else:
machines = get_selected_machines(args.flake, args.machines)
machines = get_selected_machines(args.flake, args.option, args.machines)
generate_facts(machines, args.service, args.regenerate)
def register_generate_parser(parser: argparse.ArgumentParser) -> None:
parser.add_argument(
machines_parser = parser.add_argument(
"machines",
type=str,
help="machine to generate facts for. if empty, generate facts for all machines",
nargs="*",
default=[],
)
parser.add_argument(
add_dynamic_completer(machines_parser, complete_machines)
service_parser = parser.add_argument(
"--service",
type=str,
help="service to generate facts for, if empty, generate facts for every service",
default=None,
)
add_dynamic_completer(service_parser, complete_services_for_machine)
parser.add_argument(
"--regenerate",
type=bool,

View File

@ -3,6 +3,7 @@ import importlib
import json
import logging
from ..completions import add_dynamic_completer, complete_machines
from ..machines.machines import Machine
log = logging.getLogger(__name__)
@ -37,8 +38,10 @@ def get_command(args: argparse.Namespace) -> None:
def register_list_parser(parser: argparse.ArgumentParser) -> None:
parser.add_argument(
machines_parser = parser.add_argument(
"machine",
help="The machine to print facts for",
)
add_dynamic_completer(machines_parser, complete_machines)
parser.set_defaults(func=get_command)

View File

@ -2,6 +2,7 @@ import os
import subprocess
from pathlib import Path
from clan_cli.cmd import Log, run
from clan_cli.machines.machines import Machine
from clan_cli.nix import nix_shell
@ -15,25 +16,25 @@ class SecretStore(SecretStoreBase):
def set(
self, service: str, name: str, value: bytes, groups: list[str]
) -> Path | None:
subprocess.run(
run(
nix_shell(
["nixpkgs#pass"],
["pass", "insert", "-m", f"machines/{self.machine.name}/{name}"],
),
input=value,
check=True,
log=Log.BOTH,
error_msg=f"Failed to insert secret {name}",
)
return None # we manage the files outside of the git repo
def get(self, service: str, name: str) -> bytes:
return subprocess.run(
return run(
nix_shell(
["nixpkgs#pass"],
["pass", "show", f"machines/{self.machine.name}/{name}"],
),
check=True,
stdout=subprocess.PIPE,
).stdout
error_msg=f"Failed to get secret {name}",
).stdout.encode("utf-8")
def exists(self, service: str, name: str) -> bool:
password_store = os.environ.get(
@ -48,7 +49,7 @@ class SecretStore(SecretStoreBase):
)
hashes = []
hashes.append(
subprocess.run(
run(
nix_shell(
["nixpkgs#git"],
[
@ -61,13 +62,15 @@ class SecretStore(SecretStoreBase):
f"machines/{self.machine.name}",
],
),
stdout=subprocess.PIPE,
).stdout.strip()
check=False,
)
.stdout.encode("utf-8")
.strip()
)
for symlink in Path(password_store).glob(f"machines/{self.machine.name}/**/*"):
if symlink.is_symlink():
hashes.append(
subprocess.run(
run(
nix_shell(
["nixpkgs#git"],
[
@ -80,8 +83,10 @@ class SecretStore(SecretStoreBase):
str(symlink),
],
),
stdout=subprocess.PIPE,
).stdout.strip()
check=False,
)
.stdout.encode("utf-8")
.strip()
)
# we sort the hashes to make sure that the order is always the same

View File

@ -5,6 +5,7 @@ from pathlib import Path
from tempfile import TemporaryDirectory
from ..cmd import Log, run
from ..completions import add_dynamic_completer, complete_machines
from ..machines.machines import Machine
from ..nix import nix_shell
@ -32,6 +33,8 @@ def upload_secrets(machine: Machine) -> None:
" ".join(["ssh"] + ssh_cmd[2:]),
"-az",
"--delete",
"--chown=root:root",
"--chmod=D700,F600",
f"{tempdir!s}/",
f"{host.user}@{host.host}:{machine.secrets_upload_directory}/",
],
@ -46,8 +49,10 @@ def upload_command(args: argparse.Namespace) -> None:
def register_upload_parser(parser: argparse.ArgumentParser) -> None:
parser.add_argument(
machines_parser = parser.add_argument(
"machine",
help="The machine to upload secrets to",
)
add_dynamic_completer(machines_parser, complete_machines)
parser.set_defaults(func=upload_command)

View File

@ -3,7 +3,6 @@ import importlib
import json
import logging
import os
import re
import shutil
import textwrap
from collections.abc import Sequence
@ -13,6 +12,7 @@ from tempfile import TemporaryDirectory
from typing import Any
from .cmd import Log, run
from .completions import add_dynamic_completer, complete_machines
from .errors import ClanError
from .facts.secret_modules import SecretStoreBase
from .machines.machines import Machine
@ -21,63 +21,6 @@ from .nix import nix_shell
log = logging.getLogger(__name__)
def list_available_ssh_keys(ssh_dir: Path = Path("~/.ssh").expanduser()) -> list[Path]:
"""
Function to list all available SSH public keys in the default .ssh directory.
Returns a list of paths to available public key files.
"""
public_key_patterns = ["*.pub"]
available_keys: list[Path] = []
# Check for public key files
for pattern in public_key_patterns:
for key_path in ssh_dir.glob(pattern):
if key_path.is_file():
available_keys.append(key_path)
return available_keys
def read_public_key_contents(public_keys: list[Path]) -> list[str]:
"""
Function to read and return the contents of available SSH public keys.
Returns a list containing the contents of each public key.
"""
public_key_contents = []
for key_path in public_keys:
try:
with open(key_path.expanduser()) as key_file:
public_key_contents.append(key_file.read().strip())
except FileNotFoundError:
log.error(f"Public key file not found: {key_path}")
return public_key_contents
def get_keymap_and_locale() -> dict[str, str]:
locale = "en_US.UTF-8"
keymap = "en"
# Execute the `localectl status` command
result = run(["localectl", "status"])
if result.returncode == 0:
output = result.stdout
# Extract the Keymap (X11 Layout)
keymap_match = re.search(r"X11 Layout:\s+(.*)", output)
if keymap_match:
keymap = keymap_match.group(1)
# Extract the System Locale (LANG only)
locale_match = re.search(r"System Locale:\s+LANG=(.*)", output)
if locale_match:
locale = locale_match.group(1)
return {"keymap": keymap, "locale": locale}
def flash_machine(
machine: Machine,
*,
@ -85,7 +28,9 @@ def flash_machine(
disks: dict[str, str],
system_config: dict[str, Any],
dry_run: bool,
write_efi_boot_entries: bool,
debug: bool,
extra_args: list[str] = [],
) -> None:
secret_facts_module = importlib.import_module(machine.secret_facts_module)
secret_facts_store: SecretStoreBase = secret_facts_module.SecretStore(
@ -112,6 +57,8 @@ def flash_machine(
disko_install.append("sudo")
disko_install.append("disko-install")
if write_efi_boot_entries:
disko_install.append("--write-efi-boot-entries")
if dry_run:
disko_install.append("--dry-run")
if debug:
@ -128,6 +75,8 @@ def flash_machine(
json.dumps(system_config),
]
)
disko_install.extend(["--option", "dry-run", "true"])
disko_install.extend(extra_args)
cmd = nix_shell(
["nixpkgs#disko"],
@ -148,6 +97,8 @@ class FlashOptions:
mode: str
language: str
keymap: str
write_efi_boot_entries: bool
nix_options: list[str]
class AppendDiskAction(argparse.Action):
@ -176,8 +127,10 @@ def flash_command(args: argparse.Namespace) -> None:
confirm=not args.yes,
debug=args.debug,
mode=args.mode,
language=args.lang,
language=args.language,
keymap=args.keymap,
write_efi_boot_entries=args.write_efi_boot_entries,
nix_options=args.option,
)
machine = Machine(opts.machine, flake=opts.flake)
@ -191,40 +144,22 @@ def flash_command(args: argparse.Namespace) -> None:
if ask != "y":
return
root_keys = read_public_key_contents(opts.ssh_keys_path)
if opts.confirm and not root_keys:
msg = "Should we add your SSH public keys to the root user? [y/N] "
ask = input(msg)
if ask == "y":
pubkeys = list_available_ssh_keys()
root_keys.extend(read_public_key_contents(pubkeys))
else:
raise ClanError(
"No SSH public keys provided. Use --ssh-pubkey to add keys."
)
elif not opts.confirm and not root_keys:
pubkeys = list_available_ssh_keys()
root_keys.extend(read_public_key_contents(pubkeys))
# If ssh-pubkeys set, we don't need to ask for confirmation
elif opts.confirm and root_keys:
pass
elif not opts.confirm and root_keys:
pass
else:
raise ClanError("Invalid state")
localectl = get_keymap_and_locale()
extra_config = {
"users": {
extra_config: dict[str, Any] = {}
if opts.ssh_keys_path:
root_keys = []
for key_path in opts.ssh_keys_path:
try:
root_keys.append(key_path.read_text())
except OSError as e:
raise ClanError(f"Cannot read SSH public key file: {key_path}: {e}")
extra_config["users"] = {
"users": {"root": {"openssh": {"authorizedKeys": {"keys": root_keys}}}}
},
"console": {
"keyMap": opts.keymap if opts.keymap else localectl["keymap"],
},
"i18n": {
"defaultLocale": opts.language if opts.language else localectl["locale"],
},
}
}
if opts.keymap:
extra_config["console"] = {"keyMap": opts.keymap}
if opts.language:
extra_config["i18n"] = {"defaultLocale": opts.language}
flash_machine(
machine,
@ -233,15 +168,19 @@ def flash_command(args: argparse.Namespace) -> None:
system_config=extra_config,
dry_run=opts.dry_run,
debug=opts.debug,
write_efi_boot_entries=opts.write_efi_boot_entries,
extra_args=opts.nix_options,
)
def register_parser(parser: argparse.ArgumentParser) -> None:
parser.add_argument(
machines_parser = parser.add_argument(
"machine",
type=str,
help="machine to install",
)
add_dynamic_completer(machines_parser, complete_machines)
parser.add_argument(
"--disk",
type=str,
@ -251,12 +190,14 @@ def register_parser(parser: argparse.ArgumentParser) -> None:
help="device to flash to",
default={},
)
mode_help = textwrap.dedent("""\
mode_help = textwrap.dedent(
"""\
Specify the mode of operation. Valid modes are: format, mount."
Format will format the disk before installing.
Mount will mount the disk before installing.
Mount is useful for updating an existing system without losing data.
""")
"""
)
parser.add_argument(
"--mode",
type=str,
@ -272,7 +213,7 @@ def register_parser(parser: argparse.ArgumentParser) -> None:
help="ssh pubkey file to add to the root user. Can be used multiple times",
)
parser.add_argument(
"--lang",
"--language",
type=str,
help="system language",
)
@ -293,4 +234,16 @@ def register_parser(parser: argparse.ArgumentParser) -> None:
default=False,
action="store_true",
)
parser.add_argument(
"--write-efi-boot-entries",
help=textwrap.dedent(
"""
Write EFI boot entries to the NVRAM of the system for the installed system.
Specify this option if you plan to boot from this disk on the current machine,
but not if you plan to move the disk to another machine.
"""
).strip(),
default=False,
action="store_true",
)
parser.set_defaults(func=flash_command)

View File

@ -5,6 +5,7 @@ from .create import register_create_parser
from .delete import register_delete_parser
from .install import register_install_parser
from .list import register_list_parser
from .show import register_show_parser
from .update import register_update_parser
@ -62,6 +63,17 @@ Examples:
)
register_list_parser(list_parser)
show_parser = subparser.add_parser(
"show",
help="Show a machine",
epilog=(
"""
This subcommand shows the details of a machine managed by this clan like icon, description, etc
"""
),
)
register_show_parser(show_parser)
install_parser = subparser.add_parser(
"install",
help="Install a machine",

View File

@ -1,6 +1,7 @@
import argparse
import shutil
from ..completions import add_dynamic_completer, complete_machines
from ..dirs import specific_machine_dir
from ..errors import ClanError
@ -14,5 +15,7 @@ def delete_command(args: argparse.Namespace) -> None:
def register_delete_parser(parser: argparse.ArgumentParser) -> None:
parser.add_argument("host", type=str)
machines_parser = parser.add_argument("host", type=str)
add_dynamic_completer(machines_parser, complete_machines)
parser.set_defaults(func=delete_command)

View File

@ -8,6 +8,7 @@ from pathlib import Path
from tempfile import TemporaryDirectory
from ..cmd import Log, run
from ..completions import add_dynamic_completer, complete_machines
from ..facts.generate import generate_facts
from ..machines.machines import Machine
from ..nix import nix_shell
@ -26,6 +27,7 @@ def install_nixos(
debug: bool = False,
password: str | None = None,
no_reboot: bool = False,
extra_args: list[str] = [],
) -> None:
secret_facts_module = importlib.import_module(machine.secret_facts_module)
log.info(f"installing {machine.name}")
@ -56,6 +58,7 @@ def install_nixos(
f"{machine.flake}#{machine.name}",
"--extra-files",
str(tmpdir),
*extra_args,
]
if no_reboot:
@ -95,6 +98,7 @@ class InstallOptions:
debug: bool
no_reboot: bool
json_ssh_deploy: dict[str, str] | None
nix_options: list[str]
def install_command(args: argparse.Namespace) -> None:
@ -127,6 +131,7 @@ def install_command(args: argparse.Namespace) -> None:
debug=args.debug,
no_reboot=args.no_reboot,
json_ssh_deploy=json_ssh_deploy,
nix_options=args.option,
)
machine = Machine(opts.machine, flake=opts.flake)
machine.target_host_address = opts.target_host
@ -142,6 +147,7 @@ def install_command(args: argparse.Namespace) -> None:
debug=opts.debug,
password=password,
no_reboot=opts.no_reboot,
extra_args=opts.nix_options,
)
@ -183,11 +189,14 @@ def register_install_parser(parser: argparse.ArgumentParser) -> None:
help="do not ask for confirmation",
default=False,
)
parser.add_argument(
machines_parser = parser.add_argument(
"machine",
type=str,
help="machine to install",
)
add_dynamic_completer(machines_parser, complete_machines)
parser.add_argument(
"target_host",
type=str,

View File

@ -7,7 +7,7 @@ from .machines import Machine
# function to speedup eval if we want to evauluate all machines
def get_all_machines(flake_dir: Path) -> list[Machine]:
def get_all_machines(flake_dir: Path, nix_options: list[str]) -> list[Machine]:
config = nix_config()
system = config["system"]
json_path = run(
@ -19,13 +19,20 @@ def get_all_machines(flake_dir: Path) -> list[Machine]:
machines = []
for name, machine_data in machines_json.items():
machines.append(
Machine(name=name, flake=flake_dir, deployment_info=machine_data)
Machine(
name=name,
flake=flake_dir,
deployment_info=machine_data,
nix_options=nix_options,
)
)
return machines
def get_selected_machines(flake_dir: Path, machine_names: list[str]) -> list[Machine]:
def get_selected_machines(
flake_dir: Path, nix_options: list[str], machine_names: list[str]
) -> list[Machine]:
machines = []
for name in machine_names:
machines.append(Machine(name=name, flake=flake_dir))
machines.append(Machine(name=name, flake=flake_dir, nix_options=nix_options))
return machines

View File

@ -1,5 +1,4 @@
import argparse
import dataclasses
import json
import logging
from pathlib import Path
@ -12,24 +11,15 @@ from ..nix import nix_config, nix_eval
log = logging.getLogger(__name__)
@dataclasses.dataclass
class MachineInfo:
machine_name: str
machine_description: str | None
machine_icon: str | None
@API.register
def list_machines(flake_url: str | Path, debug: bool) -> dict[str, MachineInfo]:
def list_machines(flake_url: str | Path, debug: bool) -> list[str]:
config = nix_config()
system = config["system"]
cmd = nix_eval(
[
f"{flake_url}#clanInternals.machines.{system}",
"--apply",
"""builtins.mapAttrs (name: attrs: {
inherit (attrs.config.clanCore) machineDescription machineIcon machineName;
})""",
"builtins.attrNames",
"--json",
]
)
@ -37,27 +27,13 @@ def list_machines(flake_url: str | Path, debug: bool) -> dict[str, MachineInfo]:
proc = run_no_stdout(cmd)
res = proc.stdout.strip()
machines_dict = json.loads(res)
return {
k: MachineInfo(
machine_name=v.get("machineName"),
machine_description=v.get("machineDescription", None),
machine_icon=v.get("machineIcon", None),
)
for k, v in machines_dict.items()
}
return json.loads(res)
def list_command(args: argparse.Namespace) -> None:
flake_path = Path(args.flake).resolve()
print("Listing all machines:\n")
print("Source: ", flake_path)
print("-" * 40)
for name, machine in list_machines(flake_path, args.debug).items():
description = machine.machine_description or "[no description]"
print(f"{name}\n: {description}\n")
print("-" * 40)
for name in list_machines(flake_path, args.debug):
print(name)
def register_list_parser(parser: argparse.ArgumentParser) -> None:

View File

@ -41,9 +41,10 @@ class QMPWrapper:
class Machine:
flake: str | Path
name: str
flake: str | Path
data: MachineData
nix_options: list[str]
eval_cache: dict[str, str]
build_cache: dict[str, Path]
_flake_path: Path | None
@ -55,6 +56,7 @@ class Machine:
name: str,
flake: Path | str,
deployment_info: dict | None = None,
nix_options: list[str] = [],
machine: MachineData | None = None,
) -> None:
"""
@ -76,6 +78,7 @@ class Machine:
self.build_cache: dict[str, Path] = {}
self._flake_path: Path | None = None
self._deployment_info: None | dict = deployment_info
self.nix_options = nix_options
state_dir = vm_state_dir(flake_url=str(self.flake), vm_name=self.data.name)
@ -242,9 +245,9 @@ class Machine:
flake = f"path:{self.flake_dir}"
args += [
f'{flake}#clanInternals.machines."{system}".{self.data.name}.{attr}',
*nix_options,
f'{flake}#clanInternals.machines."{system}".{self.data.name}.{attr}'
]
args += nix_options + self.nix_options
if method == "eval":
output = run_no_stdout(nix_eval(args)).stdout.strip()

View File

@ -0,0 +1,60 @@
import argparse
import dataclasses
import json
import logging
from pathlib import Path
from clan_cli.api import API
from ..cmd import run_no_stdout
from ..completions import add_dynamic_completer, complete_machines
from ..nix import nix_config, nix_eval
from .types import machine_name_type
log = logging.getLogger(__name__)
@dataclasses.dataclass
class MachineInfo:
machine_name: str
machine_description: str | None
machine_icon: str | None
@API.register
def show_machine(flake_url: str | Path, machine_name: str, debug: bool) -> MachineInfo:
config = nix_config()
system = config["system"]
cmd = nix_eval(
[
f"{flake_url}#clanInternals.machines.{system}.{machine_name}",
"--apply",
"machine: { inherit (machine.config.clanCore) machineDescription machineIcon machineName; }",
"--json",
]
)
proc = run_no_stdout(cmd)
res = proc.stdout.strip()
machine = json.loads(res)
return MachineInfo(
machine_name=machine.get("machineName"),
machine_description=machine.get("machineDescription", None),
machine_icon=machine.get("machineIcon", None),
)
def show_command(args: argparse.Namespace) -> None:
flake_path = Path(args.flake).resolve()
machine = show_machine(flake_path, args.machine, args.debug)
print(f"Name: {machine.machine_name}")
print(f"Description: {machine.machine_description or ''}")
print(f"Icon: {machine.machine_icon or ''}")
def register_show_parser(parser: argparse.ArgumentParser) -> None:
parser.set_defaults(func=show_command)
machine_parser = parser.add_argument(
"machine", help="the name of the machine", type=machine_name_type
)
add_dynamic_completer(machine_parser, complete_machines)

View File

@ -3,9 +3,10 @@ import json
import logging
import os
import shlex
import subprocess
import sys
from ..cmd import run
from ..completions import add_dynamic_completer, complete_machines
from ..errors import ClanError
from ..facts.generate import generate_facts
from ..facts.upload import upload_secrets
@ -53,11 +54,7 @@ def upload_sources(
path,
]
)
proc = subprocess.run(cmd, stdout=subprocess.PIPE, env=env, check=False)
if proc.returncode != 0:
raise ClanError(
f"failed to upload sources: {shlex.join(cmd)} failed with {proc.returncode}"
)
run(cmd, env=env, error_msg="failed to upload sources")
return path
# Slow path: we need to upload all sources to the remote machine
@ -73,16 +70,13 @@ def upload_sources(
]
)
log.info("run %s", shlex.join(cmd))
proc = subprocess.run(cmd, stdout=subprocess.PIPE, check=False)
if proc.returncode != 0:
raise ClanError(
f"failed to upload sources: {shlex.join(cmd)} failed with {proc.returncode}"
)
proc = run(cmd, error_msg="failed to upload sources")
try:
return json.loads(proc.stdout)["path"]
except (json.JSONDecodeError, OSError) as e:
raise ClanError(
f"failed to parse output of {shlex.join(cmd)}: {e}\nGot: {proc.stdout.decode('utf-8', 'replace')}"
f"failed to parse output of {shlex.join(cmd)}: {e}\nGot: {proc.stdout}"
)
@ -110,11 +104,9 @@ def deploy_nixos(machines: MachineGroup) -> None:
ssh_arg += " -i " + host.key if host.key else ""
extra_args = host.meta.get("extra_args", [])
cmd = [
"nixos-rebuild",
"switch",
*extra_args,
"--fast",
"--option",
"keep-going",
@ -124,6 +116,7 @@ def deploy_nixos(machines: MachineGroup) -> None:
"true",
"--build-host",
"",
*machine.nix_options,
"--flake",
f"{path}#{machine.name}",
]
@ -143,7 +136,9 @@ def update(args: argparse.Namespace) -> None:
raise ClanError("Could not find clan flake toplevel directory")
machines = []
if len(args.machines) == 1 and args.target_host is not None:
machine = Machine(name=args.machines[0], flake=args.flake)
machine = Machine(
name=args.machines[0], flake=args.flake, nix_options=args.option
)
machine.target_host_address = args.target_host
machines.append(machine)
@ -153,7 +148,7 @@ def update(args: argparse.Namespace) -> None:
else:
if len(args.machines) == 0:
ignored_machines = []
for machine in get_all_machines(args.flake):
for machine in get_all_machines(args.flake, args.option):
if machine.deployment_info.get("requireExplicitUpdate", False):
continue
try:
@ -173,13 +168,13 @@ def update(args: argparse.Namespace) -> None:
print(machine, file=sys.stderr)
else:
machines = get_selected_machines(args.flake, args.machines)
machines = get_selected_machines(args.flake, args.option, args.machines)
deploy_nixos(MachineGroup(machines))
def register_update_parser(parser: argparse.ArgumentParser) -> None:
parser.add_argument(
machines_parser = parser.add_argument(
"machines",
type=str,
nargs="*",
@ -187,6 +182,9 @@ def register_update_parser(parser: argparse.ArgumentParser) -> None:
metavar="MACHINE",
help="machine to update. If no machine is specified, all machines will be updated.",
)
add_dynamic_completer(machines_parser, complete_machines)
parser.add_argument(
"--target-host",
type=str,

View File

@ -1,6 +1,7 @@
import argparse
from pathlib import Path
from ..completions import add_dynamic_completer, complete_machines
from ..errors import ClanError
from ..git import commit_files
from ..machines.types import machine_name_type, validate_hostname
@ -147,25 +148,28 @@ def register_machines_parser(parser: argparse.ArgumentParser) -> None:
# Parser
get_parser = subparser.add_parser("get", help="get a machine public key")
get_parser.add_argument(
get_machine_parser = get_parser.add_argument(
"machine", help="the name of the machine", type=machine_name_type
)
add_dynamic_completer(get_machine_parser, complete_machines)
get_parser.set_defaults(func=get_command)
# Parser
remove_parser = subparser.add_parser("remove", help="remove a machine")
remove_parser.add_argument(
remove_machine_parser = remove_parser.add_argument(
"machine", help="the name of the machine", type=machine_name_type
)
add_dynamic_completer(remove_machine_parser, complete_machines)
remove_parser.set_defaults(func=remove_command)
# Parser
add_secret_parser = subparser.add_parser(
"add-secret", help="allow a machine to access a secret"
)
add_secret_parser.add_argument(
machine_add_secret_parser = add_secret_parser.add_argument(
"machine", help="the name of the machine", type=machine_name_type
)
add_dynamic_completer(machine_add_secret_parser, complete_machines)
add_secret_parser.add_argument(
"secret", help="the name of the secret", type=secret_name_type
)
@ -175,9 +179,10 @@ def register_machines_parser(parser: argparse.ArgumentParser) -> None:
remove_secret_parser = subparser.add_parser(
"remove-secret", help="remove a group's access to a secret"
)
remove_secret_parser.add_argument(
"machine", help="the name of the group", type=machine_name_type
machine_remove_parser = remove_secret_parser.add_argument(
"machine", help="the name of the machine", type=machine_name_type
)
add_dynamic_completer(machine_remove_parser, complete_machines)
remove_secret_parser.add_argument(
"secret", help="the name of the secret", type=secret_name_type
)

View File

@ -9,6 +9,7 @@ from pathlib import Path
from typing import IO
from .. import tty
from ..completions import add_dynamic_completer, complete_secrets
from ..errors import ClanError
from ..git import commit_files
from .folders import (
@ -153,8 +154,12 @@ def remove_command(args: argparse.Namespace) -> None:
remove_secret(Path(args.flake), args.secret)
def add_secret_argument(parser: argparse.ArgumentParser) -> None:
parser.add_argument("secret", help="the name of the secret", type=secret_name_type)
def add_secret_argument(parser: argparse.ArgumentParser, autocomplete: bool) -> None:
secrets_parser = parser.add_argument(
"secret", help="the name of the secret", type=secret_name_type
)
if autocomplete:
add_dynamic_completer(secrets_parser, complete_secrets)
def machines_folder(flake_dir: Path, group: str) -> Path:
@ -323,11 +328,11 @@ def register_secrets_parser(subparser: argparse._SubParsersAction) -> None:
parser_list.set_defaults(func=list_command)
parser_get = subparser.add_parser("get", help="get a secret")
add_secret_argument(parser_get)
add_secret_argument(parser_get, True)
parser_get.set_defaults(func=get_command)
parser_set = subparser.add_parser("set", help="set a secret")
add_secret_argument(parser_set)
add_secret_argument(parser_set, False)
parser_set.add_argument(
"--group",
type=str,
@ -359,10 +364,10 @@ def register_secrets_parser(subparser: argparse._SubParsersAction) -> None:
parser_set.set_defaults(func=set_command)
parser_rename = subparser.add_parser("rename", help="rename a secret")
add_secret_argument(parser_rename)
add_secret_argument(parser_rename, True)
parser_rename.add_argument("new_name", type=str, help="the new name of the secret")
parser_rename.set_defaults(func=rename_command)
parser_remove = subparser.add_parser("remove", help="remove a secret")
add_secret_argument(parser_remove)
add_secret_argument(parser_remove, True)
parser_remove.set_defaults(func=remove_command)

View File

@ -29,6 +29,7 @@
mypy,
nixpkgs,
clan-core-path,
gitMinimal,
}:
let
# Dependencies that are directly used in the project
@ -113,7 +114,13 @@ python3.pkgs.buildPythonApplication {
format = "pyproject";
# Arguments for the wrapper to unset LD_LIBRARY_PATH to avoid glibc version issues
makeWrapperArgs = [ "--unset LD_LIBRARY_PATH" ];
makeWrapperArgs = [
"--unset LD_LIBRARY_PATH"
"--suffix"
"PATH"
":"
"${gitMinimal}/bin/git"
];
# Build-time dependencies.
nativeBuildInputs = [

View File

@ -22,7 +22,7 @@ class Option:
md_li += indent_next(
f"\n{self.description.strip()}" if self.description else ""
)
md_li += indent_next(f"\n{self.epilog.strip()}" if self.epilog else "")
# md_li += indent_next(f"\n{self.epilog.strip()}" if self.epilog else "")
return md_li
@ -82,13 +82,54 @@ class Category:
md_li += indent_all(
f"{self.description.strip()}\n" if self.description else "", 4
)
md_li += "\n"
md_li += indent_all(f"{self.epilog.strip()}\n" if self.epilog else "", 4)
md_li += "\n"
return md_li
def epilog_to_md(text: str) -> str:
"""
Convert the epilog to md
"""
after_examples = False
md = ""
# md += text
for line in text.split("\n"):
if line.strip() == "Examples:":
after_examples = True
md += "### Examples"
md += "\n"
else:
if after_examples:
if line.strip().startswith("$"):
md += f"`{line}`"
md += "\n"
md += "\n"
else:
if contains_https_link(line):
line = convert_to_markdown_link(line)
md += line
md += "\n"
else:
md += line
md += "\n"
return md
import re
def contains_https_link(line: str) -> bool:
pattern = r"https://\S+"
return re.search(pattern, line) is not None
def convert_to_markdown_link(line: str) -> str:
pattern = r"(https://\S+)"
# Replacement pattern to convert it to a Markdown link
return re.sub(pattern, r"[\1](\1)", line)
def indent_next(text: str, indent_size: int = 4) -> str:
"""
Indent all lines in a string except the first line.
@ -136,7 +177,7 @@ def get_subcommands(
continue
if isinstance(action, argparse._SubParsersAction):
continue # Subparsers handled sperately
continue # Subparsers handled separately
option_strings = ", ".join(action.option_strings)
if option_strings:
@ -178,7 +219,7 @@ def get_subcommands(
Category(
title=f"{parent} {name}",
description=subparser.description,
epilog=subparser.epilog,
# epilog=subparser.epilog,
level=level,
options=_options,
positionals=_positionals,
@ -222,6 +263,7 @@ def collect_commands() -> list[Category]:
options=_options,
positionals=_positionals,
subcommands=_subcommands,
epilog=subparser.epilog,
level=1,
)
)
@ -280,6 +322,7 @@ def build_command_reference() -> None:
markdown = files.get(folder / f"{filename}.md", "")
markdown += f"{'#'*(cmd.level)} {cmd.title.capitalize()}\n\n"
markdown += f"{cmd.description}\n\n" if cmd.description else ""
# usage: clan vms run [-h] machine
@ -320,6 +363,8 @@ def build_command_reference() -> None:
markdown += indent_all(commands_fmt)
markdown += "\n"
markdown += f"{epilog_to_md(cmd.epilog)}\n\n" if cmd.epilog else ""
files[folder / f"{filename}.md"] = markdown
for fname, content in files.items():

View File

@ -11,10 +11,10 @@ def test_backups(
cli.run(
[
"--flake",
str(test_flake_with_core.path),
"backups",
"list",
"--flake",
str(test_flake_with_core.path),
"vm1",
]
)

View File

@ -1,7 +1,4 @@
import json
import tempfile
from pathlib import Path
from typing import Any
import pytest
from cli import Cli
@ -14,46 +11,6 @@ from clan_cli.errors import ClanError
example_options = f"{Path(config.__file__).parent}/jsonschema/options.json"
# use pytest.parametrize
@pytest.mark.parametrize(
"args,expected",
[
(["name", "DavHau"], {"name": "DavHau"}),
(
["kernelModules", "foo", "bar", "baz"],
{"kernelModules": ["foo", "bar", "baz"]},
),
(["services.opt", "test"], {"services": {"opt": "test"}}),
(["userIds.DavHau", "42"], {"userIds": {"DavHau": 42}}),
],
)
def test_set_some_option(
args: list[str],
expected: dict[str, Any],
test_flake: FlakeForTest,
) -> None:
# create temporary file for out_file
with tempfile.NamedTemporaryFile() as out_file:
with open(out_file.name, "w") as f:
json.dump({}, f)
cli = Cli()
cli.run(
[
"--flake",
str(test_flake.path),
"config",
"--quiet",
"--options-file",
example_options,
"--settings-file",
out_file.name,
*args,
]
)
json_out = json.loads(open(out_file.name).read())
assert json_out == expected
def test_configure_machine(
test_flake: FlakeForTest,
temporary_home: Path,
@ -62,25 +19,14 @@ def test_configure_machine(
) -> None:
cli = Cli()
cli.run(
[
"--flake",
str(test_flake.path),
"config",
"-m",
"machine1",
"clan.jitsi.enable",
"true",
]
)
# clear the output buffer
capsys.readouterr()
# read a option value
cli.run(
[
"config",
"--flake",
str(test_flake.path),
"config",
"-m",
"machine1",
"clan.jitsi.enable",
@ -88,7 +34,7 @@ def test_configure_machine(
)
# read the output
assert capsys.readouterr().out == "true\n"
assert capsys.readouterr().out == "false\n"
def test_walk_jsonschema_all_types() -> None:

View File

@ -17,10 +17,12 @@ def test_create_flake(
capsys: pytest.CaptureFixture,
temporary_home: Path,
cli: Cli,
clan_core: Path,
) -> None:
flake_dir = temporary_home / "test-flake"
cli.run(["flakes", "create", str(flake_dir)])
url = f"{clan_core}#default"
cli.run(["flakes", "create", str(flake_dir), f"--url={url}"])
assert (flake_dir / ".clan-flake").exists()
monkeypatch.chdir(flake_dir)
cli.run(["machines", "create", "machine1"])
@ -47,16 +49,34 @@ def test_create_flake(
flake_outputs["nixosConfigurations"]["machine1"]
except KeyError:
pytest.fail("nixosConfigurations.machine1 not found in flake outputs")
# configure machine1
capsys.readouterr()
cli.run(["config", "--machine", "machine1", "services.openssh.enable", ""])
capsys.readouterr()
cli.run(
[
"config",
"--machine",
"machine1",
"services.openssh.enable",
"true",
]
@pytest.mark.impure
def test_ui_template(
monkeypatch: pytest.MonkeyPatch,
capsys: pytest.CaptureFixture,
temporary_home: Path,
cli: Cli,
clan_core: Path,
) -> None:
flake_dir = temporary_home / "test-flake"
url = f"{clan_core}#empty"
cli.run(["flakes", "create", str(flake_dir), f"--url={url}"])
assert (flake_dir / ".clan-flake").exists()
monkeypatch.chdir(flake_dir)
cli.run(["machines", "create", "machine1"])
capsys.readouterr() # flush cache
cli.run(["machines", "list"])
assert "machine1" in capsys.readouterr().out
flake_show = subprocess.run(
["nix", "flake", "show", "--json"],
check=True,
capture_output=True,
text=True,
)
flake_outputs = json.loads(flake_show.stdout)
try:
flake_outputs["nixosConfigurations"]["machine1"]
except KeyError:
pytest.fail("nixosConfigurations.machine1 not found in flake outputs")

View File

@ -15,10 +15,10 @@ def test_flakes_inspect(
cli = Cli()
cli.run(
[
"--flake",
str(test_flake_with_core.path),
"flakes",
"inspect",
"--flake",
str(test_flake_with_core.path),
"--machine",
"vm1",
]

View File

@ -21,55 +21,55 @@ def test_import_sops(
monkeypatch.setenv("SOPS_AGE_KEY", age_keys[1].privkey)
cli.run(
[
"--flake",
str(test_flake.path),
"secrets",
"machines",
"add",
"--flake",
str(test_flake.path),
"machine1",
age_keys[0].pubkey,
]
)
cli.run(
[
"--flake",
str(test_flake.path),
"secrets",
"users",
"add",
"--flake",
str(test_flake.path),
"user1",
age_keys[1].pubkey,
]
)
cli.run(
[
"--flake",
str(test_flake.path),
"secrets",
"users",
"add",
"--flake",
str(test_flake.path),
"user2",
age_keys[2].pubkey,
]
)
cli.run(
[
"--flake",
str(test_flake.path),
"secrets",
"groups",
"add-user",
"--flake",
str(test_flake.path),
"group1",
"user1",
]
)
cli.run(
[
"--flake",
str(test_flake.path),
"secrets",
"groups",
"add-user",
"--flake",
str(test_flake.path),
"group1",
"user2",
]
@ -78,10 +78,10 @@ def test_import_sops(
# To edit:
# SOPS_AGE_KEY=AGE-SECRET-KEY-1U5ENXZQAY62NC78Y2WC0SEGRRMAEEKH79EYY5TH4GPFWJKEAY0USZ6X7YQ sops --age age14tva0txcrl0zes05x7gkx56qd6wd9q3nwecjac74xxzz4l47r44sv3fz62 ./data/secrets.yaml
cmd = [
"--flake",
str(test_flake.path),
"secrets",
"import-sops",
"--flake",
str(test_flake.path),
"--group",
"group1",
"--machine",
@ -91,10 +91,10 @@ def test_import_sops(
cli.run(cmd)
capsys.readouterr()
cli.run(["--flake", str(test_flake.path), "secrets", "users", "list"])
cli.run(["secrets", "users", "list", "--flake", str(test_flake.path)])
users = sorted(capsys.readouterr().out.rstrip().split())
assert users == ["user1", "user2"]
capsys.readouterr()
cli.run(["--flake", str(test_flake.path), "secrets", "get", "secret-key"])
cli.run(["secrets", "get", "--flake", str(test_flake.path), "secret-key"])
assert capsys.readouterr().out == "secret-value"

View File

@ -9,11 +9,11 @@ def test_machine_subcommands(
) -> None:
cli = Cli()
cli.run(
["--flake", str(test_flake_with_core.path), "machines", "create", "machine1"]
["machines", "create", "--flake", str(test_flake_with_core.path), "machine1"]
)
capsys.readouterr()
cli.run(["--flake", str(test_flake_with_core.path), "machines", "list"])
cli.run(["machines", "list", "--flake", str(test_flake_with_core.path)])
out = capsys.readouterr()
@ -21,12 +21,19 @@ def test_machine_subcommands(
assert "vm1" in out.out
assert "vm2" in out.out
capsys.readouterr()
cli.run(["machines", "show", "--flake", str(test_flake_with_core.path), "machine1"])
out = capsys.readouterr()
assert "machine1" in out.out
assert "Description" in out.out
print(out)
cli.run(
["--flake", str(test_flake_with_core.path), "machines", "delete", "machine1"]
["machines", "delete", "--flake", str(test_flake_with_core.path), "machine1"]
)
capsys.readouterr()
cli.run(["--flake", str(test_flake_with_core.path), "machines", "list"])
cli.run(["machines", "list", "--flake", str(test_flake_with_core.path)])
out = capsys.readouterr()
assert "machine1" not in out.out

View File

@ -27,11 +27,11 @@ def _test_identities(
cli.run(
[
"--flake",
str(test_flake.path),
"secrets",
what,
"add",
"--flake",
str(test_flake.path),
"foo",
age_keys[0].pubkey,
]
@ -41,11 +41,11 @@ def _test_identities(
with pytest.raises(ClanError): # raises "foo already exists"
cli.run(
[
"--flake",
str(test_flake.path),
"secrets",
what,
"add",
"--flake",
str(test_flake.path),
"foo",
age_keys[0].pubkey,
]
@ -54,11 +54,11 @@ def _test_identities(
# rotate the key
cli.run(
[
"--flake",
str(test_flake.path),
"secrets",
what,
"add",
"--flake",
str(test_flake.path),
"-f",
"foo",
age_keys[1].privkey,
@ -68,11 +68,11 @@ def _test_identities(
capsys.readouterr() # empty the buffer
cli.run(
[
"--flake",
str(test_flake.path),
"secrets",
what,
"get",
"--flake",
str(test_flake.path),
"foo",
]
)
@ -80,18 +80,18 @@ def _test_identities(
assert age_keys[1].pubkey in out.out
capsys.readouterr() # empty the buffer
cli.run(["--flake", str(test_flake.path), "secrets", what, "list"])
cli.run(["secrets", what, "list", "--flake", str(test_flake.path)])
out = capsys.readouterr() # empty the buffer
assert "foo" in out.out
cli.run(["--flake", str(test_flake.path), "secrets", what, "remove", "foo"])
cli.run(["secrets", what, "remove", "--flake", str(test_flake.path), "foo"])
assert not (sops_folder / what / "foo" / "key.json").exists()
with pytest.raises(ClanError): # already removed
cli.run(["--flake", str(test_flake.path), "secrets", what, "remove", "foo"])
cli.run(["secrets", what, "remove", "--flake", str(test_flake.path), "foo"])
capsys.readouterr()
cli.run(["--flake", str(test_flake.path), "secrets", what, "list"])
cli.run(["secrets", what, "list", "--flake", str(test_flake.path)])
out = capsys.readouterr()
assert "foo" not in out.out
@ -113,17 +113,17 @@ def test_groups(
) -> None:
cli = Cli()
capsys.readouterr() # empty the buffer
cli.run(["--flake", str(test_flake.path), "secrets", "groups", "list"])
cli.run(["secrets", "groups", "list", "--flake", str(test_flake.path)])
assert capsys.readouterr().out == ""
with pytest.raises(ClanError): # machine does not exist yet
cli.run(
[
"--flake",
str(test_flake.path),
"secrets",
"groups",
"add-machine",
"--flake",
str(test_flake.path),
"group1",
"machine1",
]
@ -131,33 +131,33 @@ def test_groups(
with pytest.raises(ClanError): # user does not exist yet
cli.run(
[
"--flake",
str(test_flake.path),
"secrets",
"groups",
"add-user",
"--flake",
str(test_flake.path),
"groupb1",
"user1",
]
)
cli.run(
[
"--flake",
str(test_flake.path),
"secrets",
"machines",
"add",
"--flake",
str(test_flake.path),
"machine1",
age_keys[0].pubkey,
]
)
cli.run(
[
"--flake",
str(test_flake.path),
"secrets",
"groups",
"add-machine",
"--flake",
str(test_flake.path),
"group1",
"machine1",
]
@ -166,11 +166,11 @@ def test_groups(
# Should this fail?
cli.run(
[
"--flake",
str(test_flake.path),
"secrets",
"groups",
"add-machine",
"--flake",
str(test_flake.path),
"group1",
"machine1",
]
@ -178,51 +178,51 @@ def test_groups(
cli.run(
[
"--flake",
str(test_flake.path),
"secrets",
"users",
"add",
"--flake",
str(test_flake.path),
"user1",
age_keys[0].pubkey,
]
)
cli.run(
[
"--flake",
str(test_flake.path),
"secrets",
"groups",
"add-user",
"--flake",
str(test_flake.path),
"group1",
"user1",
]
)
capsys.readouterr() # empty the buffer
cli.run(["--flake", str(test_flake.path), "secrets", "groups", "list"])
cli.run(["secrets", "groups", "list", "--flake", str(test_flake.path)])
out = capsys.readouterr().out
assert "user1" in out
assert "machine1" in out
cli.run(
[
"--flake",
str(test_flake.path),
"secrets",
"groups",
"remove-user",
"--flake",
str(test_flake.path),
"group1",
"user1",
]
)
cli.run(
[
"--flake",
str(test_flake.path),
"secrets",
"groups",
"remove-machine",
"--flake",
str(test_flake.path),
"group1",
"machine1",
]
@ -251,90 +251,90 @@ def test_secrets(
) -> None:
cli = Cli()
capsys.readouterr() # empty the buffer
cli.run(["--flake", str(test_flake.path), "secrets", "list"])
cli.run(["secrets", "list", "--flake", str(test_flake.path)])
assert capsys.readouterr().out == ""
monkeypatch.setenv("SOPS_NIX_SECRET", "foo")
monkeypatch.setenv("SOPS_AGE_KEY_FILE", str(test_flake.path / ".." / "age.key"))
cli.run(["--flake", str(test_flake.path), "secrets", "key", "generate"])
cli.run(["secrets", "key", "generate", "--flake", str(test_flake.path)])
capsys.readouterr() # empty the buffer
cli.run(["--flake", str(test_flake.path), "secrets", "key", "show"])
cli.run(["secrets", "key", "show", "--flake", str(test_flake.path)])
key = capsys.readouterr().out
assert key.startswith("age1")
cli.run(
["--flake", str(test_flake.path), "secrets", "users", "add", "testuser", key]
["secrets", "users", "add", "--flake", str(test_flake.path), "testuser", key]
)
with pytest.raises(ClanError): # does not exist yet
cli.run(["--flake", str(test_flake.path), "secrets", "get", "nonexisting"])
cli.run(["--flake", str(test_flake.path), "secrets", "set", "initialkey"])
cli.run(["secrets", "get", "--flake", str(test_flake.path), "nonexisting"])
cli.run(["secrets", "set", "--flake", str(test_flake.path), "initialkey"])
capsys.readouterr()
cli.run(["--flake", str(test_flake.path), "secrets", "get", "initialkey"])
cli.run(["secrets", "get", "--flake", str(test_flake.path), "initialkey"])
assert capsys.readouterr().out == "foo"
capsys.readouterr()
cli.run(["--flake", str(test_flake.path), "secrets", "users", "list"])
cli.run(["secrets", "users", "list", "--flake", str(test_flake.path)])
users = capsys.readouterr().out.rstrip().split("\n")
assert len(users) == 1, f"users: {users}"
owner = users[0]
monkeypatch.setenv("EDITOR", "cat")
cli.run(["--flake", str(test_flake.path), "secrets", "set", "--edit", "initialkey"])
cli.run(["secrets", "set", "--edit", "--flake", str(test_flake.path), "initialkey"])
monkeypatch.delenv("EDITOR")
cli.run(["--flake", str(test_flake.path), "secrets", "rename", "initialkey", "key"])
cli.run(["secrets", "rename", "--flake", str(test_flake.path), "initialkey", "key"])
capsys.readouterr() # empty the buffer
cli.run(["--flake", str(test_flake.path), "secrets", "list"])
cli.run(["secrets", "list", "--flake", str(test_flake.path)])
assert capsys.readouterr().out == "key\n"
capsys.readouterr() # empty the buffer
cli.run(["--flake", str(test_flake.path), "secrets", "list", "nonexisting"])
cli.run(["secrets", "list", "--flake", str(test_flake.path), "nonexisting"])
assert capsys.readouterr().out == ""
capsys.readouterr() # empty the buffer
cli.run(["--flake", str(test_flake.path), "secrets", "list", "key"])
cli.run(["secrets", "list", "--flake", str(test_flake.path), "key"])
assert capsys.readouterr().out == "key\n"
cli.run(
[
"--flake",
str(test_flake.path),
"secrets",
"machines",
"add",
"--flake",
str(test_flake.path),
"machine1",
age_keys[1].pubkey,
]
)
cli.run(
[
"--flake",
str(test_flake.path),
"secrets",
"machines",
"add-secret",
"--flake",
str(test_flake.path),
"machine1",
"key",
]
)
capsys.readouterr()
cli.run(["--flake", str(test_flake.path), "secrets", "machines", "list"])
cli.run(["secrets", "machines", "list", "--flake", str(test_flake.path)])
assert capsys.readouterr().out == "machine1\n"
with use_key(age_keys[1].privkey, monkeypatch):
capsys.readouterr()
cli.run(["--flake", str(test_flake.path), "secrets", "get", "key"])
cli.run(["secrets", "get", "--flake", str(test_flake.path), "key"])
assert capsys.readouterr().out == "foo"
# rotate machines key
cli.run(
[
"--flake",
str(test_flake.path),
"secrets",
"machines",
"add",
"--flake",
str(test_flake.path),
"-f",
"machine1",
age_keys[0].privkey,
@ -344,17 +344,17 @@ def test_secrets(
# should also rotate the encrypted secret
with use_key(age_keys[0].privkey, monkeypatch):
capsys.readouterr()
cli.run(["--flake", str(test_flake.path), "secrets", "get", "key"])
cli.run(["secrets", "get", "--flake", str(test_flake.path), "key"])
assert capsys.readouterr().out == "foo"
cli.run(
[
"--flake",
str(test_flake.path),
"secrets",
"machines",
"remove-secret",
"--flake",
str(test_flake.path),
"machine1",
"key",
]
@ -362,37 +362,37 @@ def test_secrets(
cli.run(
[
"--flake",
str(test_flake.path),
"secrets",
"users",
"add",
"--flake",
str(test_flake.path),
"user1",
age_keys[1].pubkey,
]
)
cli.run(
[
"--flake",
str(test_flake.path),
"secrets",
"users",
"add-secret",
"--flake",
str(test_flake.path),
"user1",
"key",
]
)
capsys.readouterr()
with use_key(age_keys[1].privkey, monkeypatch):
cli.run(["--flake", str(test_flake.path), "secrets", "get", "key"])
cli.run(["secrets", "get", "--flake", str(test_flake.path), "key"])
assert capsys.readouterr().out == "foo"
cli.run(
[
"--flake",
str(test_flake.path),
"secrets",
"users",
"remove-secret",
"--flake",
str(test_flake.path),
"user1",
"key",
]
@ -401,44 +401,44 @@ def test_secrets(
with pytest.raises(ClanError): # does not exist yet
cli.run(
[
"--flake",
str(test_flake.path),
"secrets",
"groups",
"add-secret",
"--flake",
str(test_flake.path),
"admin-group",
"key",
]
)
cli.run(
[
"--flake",
str(test_flake.path),
"secrets",
"groups",
"add-user",
"--flake",
str(test_flake.path),
"admin-group",
"user1",
]
)
cli.run(
[
"--flake",
str(test_flake.path),
"secrets",
"groups",
"add-user",
"--flake",
str(test_flake.path),
"admin-group",
owner,
]
)
cli.run(
[
"--flake",
str(test_flake.path),
"secrets",
"groups",
"add-secret",
"--flake",
str(test_flake.path),
"admin-group",
"key",
]
@ -447,10 +447,10 @@ def test_secrets(
capsys.readouterr() # empty the buffer
cli.run(
[
"--flake",
str(test_flake.path),
"secrets",
"set",
"--flake",
str(test_flake.path),
"--group",
"admin-group",
"key2",
@ -459,28 +459,28 @@ def test_secrets(
with use_key(age_keys[1].privkey, monkeypatch):
capsys.readouterr()
cli.run(["--flake", str(test_flake.path), "secrets", "get", "key"])
cli.run(["secrets", "get", "--flake", str(test_flake.path), "key"])
assert capsys.readouterr().out == "foo"
# extend group will update secrets
cli.run(
[
"--flake",
str(test_flake.path),
"secrets",
"users",
"add",
"--flake",
str(test_flake.path),
"user2",
age_keys[2].pubkey,
]
)
cli.run(
[
"--flake",
str(test_flake.path),
"secrets",
"groups",
"add-user",
"--flake",
str(test_flake.path),
"admin-group",
"user2",
]
@ -488,16 +488,16 @@ def test_secrets(
with use_key(age_keys[2].privkey, monkeypatch): # user2
capsys.readouterr()
cli.run(["--flake", str(test_flake.path), "secrets", "get", "key"])
cli.run(["secrets", "get", "--flake", str(test_flake.path), "key"])
assert capsys.readouterr().out == "foo"
cli.run(
[
"--flake",
str(test_flake.path),
"secrets",
"groups",
"remove-user",
"--flake",
str(test_flake.path),
"admin-group",
"user2",
]
@ -505,24 +505,24 @@ def test_secrets(
with pytest.raises(ClanError), use_key(age_keys[2].privkey, monkeypatch):
# user2 is not in the group anymore
capsys.readouterr()
cli.run(["--flake", str(test_flake.path), "secrets", "get", "key"])
cli.run(["secrets", "get", "--flake", str(test_flake.path), "key"])
print(capsys.readouterr().out)
cli.run(
[
"--flake",
str(test_flake.path),
"secrets",
"groups",
"remove-secret",
"--flake",
str(test_flake.path),
"admin-group",
"key",
]
)
cli.run(["--flake", str(test_flake.path), "secrets", "remove", "key"])
cli.run(["--flake", str(test_flake.path), "secrets", "remove", "key2"])
cli.run(["secrets", "remove", "--flake", str(test_flake.path), "key"])
cli.run(["secrets", "remove", "--flake", str(test_flake.path), "key2"])
capsys.readouterr() # empty the buffer
cli.run(["--flake", str(test_flake.path), "secrets", "list"])
cli.run(["secrets", "list", "--flake", str(test_flake.path)])
assert capsys.readouterr().out == ""

View File

@ -24,27 +24,27 @@ def test_generate_secret(
cli = Cli()
cli.run(
[
"--flake",
str(test_flake_with_core.path),
"secrets",
"users",
"add",
"--flake",
str(test_flake_with_core.path),
"user1",
age_keys[0].pubkey,
]
)
cli.run(
[
"--flake",
str(test_flake_with_core.path),
"secrets",
"groups",
"add-user",
"--flake",
str(test_flake_with_core.path),
"admins",
"user1",
]
)
cmd = ["--flake", str(test_flake_with_core.path), "facts", "generate", "vm1"]
cmd = ["facts", "generate", "--flake", str(test_flake_with_core.path), "vm1"]
cli.run(cmd)
has_secret(test_flake_with_core.path, "vm1-age.key")
has_secret(test_flake_with_core.path, "vm1-zerotier-identity-secret")
@ -60,7 +60,7 @@ def test_generate_secret(
secret1_mtime = identity_secret.lstat().st_mtime_ns
# test idempotency for vm1 and also generate for vm2
cli.run(["facts", "generate"])
cli.run(["facts", "generate", "--flake", str(test_flake_with_core.path)])
assert age_key.lstat().st_mtime_ns == age_key_mtime
assert identity_secret.lstat().st_mtime_ns == secret1_mtime

View File

@ -23,11 +23,11 @@ def test_secrets_upload(
cli = Cli()
cli.run(
[
"--flake",
str(test_flake_with_core.path),
"secrets",
"users",
"add",
"--flake",
str(test_flake_with_core.path),
"user1",
age_keys[0].pubkey,
]
@ -35,18 +35,18 @@ def test_secrets_upload(
cli.run(
[
"--flake",
str(test_flake_with_core.path),
"secrets",
"machines",
"add",
"--flake",
str(test_flake_with_core.path),
"vm1",
age_keys[1].pubkey,
]
)
monkeypatch.setenv("SOPS_NIX_SECRET", age_keys[0].privkey)
cli.run(
["--flake", str(test_flake_with_core.path), "secrets", "set", "vm1-age.key"]
["secrets", "set", "--flake", str(test_flake_with_core.path), "vm1-age.key"]
)
flake = test_flake_with_core.path.joinpath("flake.nix")
@ -55,7 +55,7 @@ def test_secrets_upload(
new_text = flake.read_text().replace("__CLAN_TARGET_ADDRESS__", addr)
flake.write_text(new_text)
cli.run(["--flake", str(test_flake_with_core.path), "facts", "upload", "vm1"])
cli.run(["facts", "upload", "--flake", str(test_flake_with_core.path), "vm1"])
# the flake defines this path as the location where the sops key should be installed
sops_key = test_flake_with_core.path.joinpath("key.txt")

View File

@ -86,7 +86,7 @@ def test_inspect(
test_flake_with_core: FlakeForTest, capsys: pytest.CaptureFixture
) -> None:
cli = Cli()
cli.run(["--flake", str(test_flake_with_core.path), "vms", "inspect", "vm1"])
cli.run(["vms", "inspect", "--flake", str(test_flake_with_core.path), "vm1"])
out = capsys.readouterr() # empty the buffer
assert "Cores" in out.out

View File

@ -47,7 +47,7 @@ class MainApplication(Adw.Application):
None,
)
self.window: "MainWindow" | None = None
self.window: MainWindow | None = None
self.connect("activate", self.on_activate)
self.connect("shutdown", self.on_shutdown)

View File

@ -29,7 +29,7 @@ class GKVStore(GObject.GObject, Gio.ListModel, Generic[K, V]):
self.gtype = gtype
self.key_gen = key_gen
# From Python 3.7 onwards dictionaries are ordered by default
self._items: "dict[K, V]" = dict()
self._items: dict[K, V] = dict()
##################################
# #

View File

@ -46,6 +46,12 @@ class WebView:
self.method_registry: dict[str, Callable] = methods
self.webview = WebKit.WebView()
settings = self.webview.get_settings()
# settings.
settings.set_property("enable-developer-extras", True)
self.webview.set_settings(settings)
self.manager = self.webview.get_user_content_manager()
# Can be called with: window.webkit.messageHandlers.gtk.postMessage("...")
# Important: it seems postMessage must be given some payload, otherwise it won't trigger the event

View File

@ -7,6 +7,7 @@
./installer/flake-module.nix
./schemas/flake-module.nix
./webview-ui/flake-module.nix
./gui-installer/flake-module.nix
];
perSystem =
@ -28,7 +29,6 @@
editor = pkgs.callPackage ./editor/clan-edit-codium.nix { };
}
// lib.optionalAttrs pkgs.stdenv.isLinux {
wayland-proxy-virtwl = pkgs.callPackage ./wayland-proxy-virtwl { };
# halalify zerotierone
zerotierone = pkgs.zerotierone.overrideAttrs (_old: {
meta = _old.meta // {

View File

@ -0,0 +1,33 @@
{
perSystem =
{ pkgs, ... }:
let
nfpmConfig = pkgs.writeText "clan-nfpm-config.yaml" (
builtins.toJSON {
name = "clan-gui-installer";
contents = [
{
src = "${./gui-installer.sh}";
dst = "/usr/bin/clan-vm-manager";
}
];
}
);
installerFor =
packager:
pkgs.runCommand "gui-installer" { } ''
mkdir build
cd build
${pkgs.nfpm}/bin/nfpm package --config ${nfpmConfig} --packager ${packager}
mkdir $out
mv * $out/
'';
in
{
packages.gui-installer-apk = installerFor "apk";
packages.gui-installer-archlinux = installerFor "archlinux";
packages.gui-installer-deb = installerFor "deb";
packages.gui-installer-rpm = installerFor "rpm";
};
}

View File

@ -0,0 +1,66 @@
#! /bin/sh
# create temp dir and ensure it is always cleaned
trap 'clean_temp_dir' EXIT
temp_dir=$(mktemp -d)
clean_temp_dir() {
rm -rf "$temp_dir"
}
is_installed() {
name=$1
if [ -n "$(command -v "$name")" ]; then
return 0
else
return 1
fi
}
install_nix() {
if is_installed curl; then
curl --proto '=https' --tlsv1.2 -sSf -L \
https://install.determinate.systems/nix \
> "$temp_dir"/install_nix.sh
elif is_installed wget; then
wget -qO- \
https://install.determinate.systems/nix \
> "$temp_dir"/install_nix.sh
else
echo "Either curl or wget is required to install Nix. Exiting."
exit 1
fi
NIX_INSTALLER_DIAGNOSTIC_ENDPOINT="" sh "$temp_dir"/install_nix.sh install
}
ask_then_install_nix() {
echo "Clan requires Nix to be installed. Would you like to install it now? (y/n)"
read -r response
if [ "$response" = "y" ]; then
install_nix
else
echo "Clan cannot run without Nix. Exiting."
exit 1
fi
}
ensure_nix_installed() {
if ! is_installed nix; then
ask_then_install_nix
fi
}
start_clan_gui() {
PATH="${PATH:+$PATH:}/nix/var/nix/profiles/default/bin" \
exec nix run \
https://git.clan.lol/clan/clan-core/archive/main.tar.gz#clan-vm-manager \
--no-accept-flake-config \
--extra-experimental-features flakes nix-command -- "$@"
}
main() {
ensure_nix_installed
start_clan_gui "$@"
}
main "$@"

View File

@ -98,9 +98,6 @@ let
in
{
clan = {
clanName = "clan-core";
directory = self;
# To build a generic installer image (without ssh pubkeys),
# use the following command:
# $ nix build .#iso-installer

26
pkgs/merge-after-ci/merge-after-ci.py Normal file → Executable file
View File

@ -1,4 +1,5 @@
import argparse
import shlex
import subprocess
parser = argparse.ArgumentParser()
@ -11,14 +12,17 @@ args = parser.parse_args()
if not args.reviewers and not args.no_review:
parser.error("either --reviewers or --no-review must be given")
subprocess.run(
[
"tea-create-pr",
"origin",
"main",
"--assignees",
",".join(["clan-bot", *args.reviewers]),
*(["--labels", "needs-review"] if not args.no_review else []),
*args.args,
]
)
cmd = [
"tea-create-pr",
"origin",
"upstream",
"main",
"--assignees",
",".join(["clan-bot", *args.reviewers]),
*(["--labels", "needs-review"] if not args.no_review else []),
*args.args,
]
print("Running:", shlex.join(cmd))
subprocess.run(cmd)

49
pkgs/tea-create-pr/script.sh Normal file → Executable file
View File

@ -1,17 +1,46 @@
#!/usr/bin/env bash
set -euo pipefail
remoteName="${1:-origin}"
targetBranch="${2:-main}"
shift && shift
remoteFork="${1:-origin}"
remoteUpstream="${2:-upstream}"
targetBranch="${3:-main}"
shift && shift && shift
TMPDIR="$(mktemp -d)"
currentBranch="$(git rev-parse --abbrev-ref HEAD)"
user="$(tea login list -o simple | cut -d" " -f4 | head -n1)"
user_unparsed="$(tea whoami)"
user="$(echo "$user_unparsed" | tr -d '\n' | cut -f4 -d' ')"
tempRemoteBranch="$user-$currentBranch"
root_dir=$(git rev-parse --show-toplevel)
nix fmt -- --fail-on-change
git log --reverse --pretty="format:%s%n%n%b%n%n" "$remoteName/$targetBranch..HEAD" > "$TMPDIR"/commit-msg
# Function to check if a remote exists
check_remote() {
if git remote get-url "$1" > /dev/null 2>&1; then
return 0
else
return 1
fi
}
# Check if the remote 'upstream' is defined
if ! check_remote "$remoteUpstream"; then
echo "Error: $remoteUpstream remote is not defined."
echo "Please fork the repository and add the $remoteUpstream remote."
echo "$ git remote add $remoteUpstream <upstream-url>"
exit 1
fi
treefmt --fail-on-change -C "$root_dir"
upstream_url=$(git remote get-url "$remoteUpstream")
set -x
git fetch "$remoteUpstream" && git rebase "$remoteUpstream"/main --autostash
set +x
repo=$(echo "$upstream_url" | sed -E 's#.*:([^/]+/[^.]+)\.git#\1#')
git log --reverse --pretty="format:%s%n%n%b%n%n" "$remoteUpstream/$targetBranch..HEAD" > "$TMPDIR"/commit-msg
$EDITOR "$TMPDIR"/commit-msg
@ -23,11 +52,13 @@ rest=$(echo "$COMMIT_MSG" | tail -n+2)
if [[ "$firstLine" == "$rest" ]]; then
rest=""
fi
git push --force -u "$remoteName" HEAD:refs/heads/"$tempRemoteBranch"
git push --force -u "$remoteFork" HEAD:refs/heads/"$tempRemoteBranch"
tea pr create \
--repo "$repo" \
--head "$user:$tempRemoteBranch" \
--title "$firstLine" \
--description "$rest" \
--head "$tempRemoteBranch" \
--base "$targetBranch" \
"$@"

View File

@ -1,35 +0,0 @@
{
wayland-proxy-virtwl,
fetchFromGitHub,
libdrm,
ocaml-ng,
}:
let
ocaml-wayland = ocaml-ng.ocamlPackages_5_0.wayland.overrideAttrs (_old: {
src = fetchFromGitHub {
owner = "Mic92";
repo = "ocaml-wayland";
rev = "f6910aa5b626fa582cc000d4fe7b50182d11b439";
hash = "sha256-cg3HLezWTxWoYWSrirOV12gv1CRz1gMIOT7j3j3v5EA=";
};
});
in
wayland-proxy-virtwl.overrideAttrs (_old: {
src = fetchFromGitHub {
owner = "Mic92";
repo = "wayland-proxy-virtwl";
rev = "652fca9d4e006a2bdeba920dfaf53190c5373a7d";
hash = "sha256-VgpqxjHgueK9eQSX987PF0KvscpzkScOzFkW3haYCOw=";
};
buildInputs =
[ libdrm ]
++ (with ocaml-ng.ocamlPackages_5_0; [
ocaml-wayland
dune-configurator
eio_main
ppx_cstruct
cmdliner
logs
ppx_cstruct
]);
})

View File

@ -19,6 +19,7 @@ export default tseslint.config(
"error",
{
callees: ["cx"],
whitelist: ["material-icons"],
},
],
},

View File

@ -5,6 +5,9 @@ const distPath = path.resolve(__dirname, "dist");
const manifestPath = path.join(distPath, ".vite/manifest.json");
const outputPath = path.join(distPath, "index.html");
const postcss = require("postcss");
const css_url = require("postcss-url");
fs.readFile(manifestPath, { encoding: "utf8" }, (err, data) => {
if (err) {
return console.error("Failed to read manifest:", err);
@ -25,19 +28,48 @@ fs.readFile(manifestPath, { encoding: "utf8" }, (err, data) => {
// Add linked stylesheets
assets.forEach((asset) => {
asset.css.forEach((cssEntry) => {
htmlContent += `\n <link rel="stylesheet" href="${cssEntry}">`;
});
// console.log(asset);
if (asset.src === "index.html") {
asset.css.forEach((cssEntry) => {
// css to be processed
const css = fs.readFileSync(`dist/${cssEntry}`, "utf8");
// process css
postcss()
.use(
css_url({
url: (asset, dir) => {
const res = path.basename(asset.url);
console.log(`Rewriting CSS url(): ${asset.url} to ${res}`);
return res;
},
})
)
.process(css, {
from: `dist/${cssEntry}`,
to: `dist/${cssEntry}`,
})
.then((result) => {
fs.writeFileSync(`dist/${cssEntry}`, result.css, "utf8");
});
// Extend the HTML content with the linked stylesheet
console.log(`Relinking html css stylesheet: ${cssEntry}`);
htmlContent += `\n <link rel="stylesheet" href="${cssEntry}">`;
});
}
});
htmlContent += `
</head>
<body>
<div id="app"></div>
`;
</head>
<body>
<div id="app"></div>
`;
// Add scripts
assets.forEach((asset) => {
if (asset.file.endsWith(".js")) {
console.log(`Relinking js script: ${asset.file}`);
htmlContent += `\n <script src="${asset.file}"></script>`;
}
});

View File

@ -0,0 +1,16 @@
import { JSONSchemaFaker } from "json-schema-faker";
import { schema } from "./api/index";
import { OperationNames } from "./src/message";
const faker = JSONSchemaFaker;
faker.option({
alwaysFakeOptionals: true,
});
const getFakeResponse = (method: OperationNames, data: any) => {
const fakeData = faker.generate(schema.properties[method].properties.return);
return fakeData;
};
export { getFakeResponse };

View File

@ -9,10 +9,12 @@
"version": "0.0.1",
"license": "MIT",
"dependencies": {
"material-icons": "^1.13.12",
"solid-js": "^1.8.11"
},
"devDependencies": {
"@eslint/js": "^9.3.0",
"@tailwindcss/typography": "^0.5.13",
"@types/node": "^20.12.12",
"@typescript-eslint/parser": "^7.10.0",
"autoprefixer": "^10.4.19",
@ -20,8 +22,10 @@
"daisyui": "^4.11.1",
"eslint": "^8.57.0",
"eslint-plugin-tailwindcss": "^3.17.0",
"json-schema-faker": "^0.5.6",
"json-schema-to-ts": "^3.1.0",
"postcss": "^8.4.38",
"postcss-url": "^10.1.3",
"prettier": "^3.2.5",
"solid-devtools": "^0.29.2",
"tailwindcss": "^3.4.3",
@ -1482,6 +1486,34 @@
"solid-js": "^1.6.12"
}
},
"node_modules/@tailwindcss/typography": {
"version": "0.5.13",
"resolved": "https://registry.npmjs.org/@tailwindcss/typography/-/typography-0.5.13.tgz",
"integrity": "sha512-ADGcJ8dX21dVVHIwTRgzrcunY6YY9uSlAHHGVKvkA+vLc5qLwEszvKts40lx7z0qc4clpjclwLeK5rVCV2P/uw==",
"dev": true,
"dependencies": {
"lodash.castarray": "^4.4.0",
"lodash.isplainobject": "^4.0.6",
"lodash.merge": "^4.6.2",
"postcss-selector-parser": "6.0.10"
},
"peerDependencies": {
"tailwindcss": ">=3.0.0 || insiders"
}
},
"node_modules/@tailwindcss/typography/node_modules/postcss-selector-parser": {
"version": "6.0.10",
"resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-6.0.10.tgz",
"integrity": "sha512-IQ7TZdoaqbT+LCpShg46jnZVlhWD2w6iQYAcYXfHARZ7X1t/UGhhceQDs5X0cGqKvYlHNOuv7Oa1xmb0oQuA3w==",
"dev": true,
"dependencies": {
"cssesc": "^3.0.0",
"util-deprecate": "^1.0.2"
},
"engines": {
"node": ">=4"
}
},
"node_modules/@types/babel__core": {
"version": "7.20.5",
"resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz",
@ -1990,6 +2022,12 @@
"node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7"
}
},
"node_modules/call-me-maybe": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/call-me-maybe/-/call-me-maybe-1.0.2.tgz",
"integrity": "sha512-HpX65o1Hnr9HH25ojC1YGs7HCQLq0GCOibSaWER0eNpgJ/Z1MZv2mTc7+xh6WOPxbRVcmgbv4hGU+uSQ/2xFZQ==",
"dev": true
},
"node_modules/callsites": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz",
@ -2183,6 +2221,12 @@
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.1.3.tgz",
"integrity": "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw=="
},
"node_modules/cuint": {
"version": "0.2.2",
"resolved": "https://registry.npmjs.org/cuint/-/cuint-0.2.2.tgz",
"integrity": "sha512-d4ZVpCW31eWwCMe1YT3ur7mUDnTXbgwyzaL320DrcRT45rfjYxkt5QWLrmOJ+/UEAI2+fQgKe/fCjR8l4TpRgw==",
"dev": true
},
"node_modules/culori": {
"version": "3.3.0",
"resolved": "https://registry.npmjs.org/culori/-/culori-3.3.0.tgz",
@ -2623,6 +2667,19 @@
"url": "https://opencollective.com/eslint"
}
},
"node_modules/esprima": {
"version": "4.0.1",
"resolved": "https://registry.npmjs.org/esprima/-/esprima-4.0.1.tgz",
"integrity": "sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A==",
"dev": true,
"bin": {
"esparse": "bin/esparse.js",
"esvalidate": "bin/esvalidate.js"
},
"engines": {
"node": ">=4"
}
},
"node_modules/esquery": {
"version": "1.5.0",
"resolved": "https://registry.npmjs.org/esquery/-/esquery-1.5.0.tgz",
@ -2802,6 +2859,12 @@
"url": "https://github.com/sponsors/isaacs"
}
},
"node_modules/format-util": {
"version": "1.0.5",
"resolved": "https://registry.npmjs.org/format-util/-/format-util-1.0.5.tgz",
"integrity": "sha512-varLbTj0e0yVyRpqQhuWV+8hlePAgaoFRhNFj50BNjEIrw1/DphHSObtqwskVCPWNgzwPoQrZAbfa/SBiicNeg==",
"dev": true
},
"node_modules/fraction.js": {
"version": "4.3.7",
"resolved": "https://registry.npmjs.org/fraction.js/-/fraction.js-4.3.7.tgz",
@ -3197,6 +3260,53 @@
"integrity": "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==",
"dev": true
},
"node_modules/json-schema-faker": {
"version": "0.5.6",
"resolved": "https://registry.npmjs.org/json-schema-faker/-/json-schema-faker-0.5.6.tgz",
"integrity": "sha512-u/cFC26/GDxh2vPiAC8B8xVvpXAW+QYtG2mijEbKrimCk8IHtiwQBjCE8TwvowdhALWq9IcdIWZ+/8ocXvdL3Q==",
"dev": true,
"dependencies": {
"json-schema-ref-parser": "^6.1.0",
"jsonpath-plus": "^7.2.0"
},
"bin": {
"jsf": "bin/gen.cjs"
}
},
"node_modules/json-schema-ref-parser": {
"version": "6.1.0",
"resolved": "https://registry.npmjs.org/json-schema-ref-parser/-/json-schema-ref-parser-6.1.0.tgz",
"integrity": "sha512-pXe9H1m6IgIpXmE5JSb8epilNTGsmTb2iPohAXpOdhqGFbQjNeHHsZxU+C8w6T81GZxSPFLeUoqDJmzxx5IGuw==",
"deprecated": "Please switch to @apidevtools/json-schema-ref-parser",
"dev": true,
"dependencies": {
"call-me-maybe": "^1.0.1",
"js-yaml": "^3.12.1",
"ono": "^4.0.11"
}
},
"node_modules/json-schema-ref-parser/node_modules/argparse": {
"version": "1.0.10",
"resolved": "https://registry.npmjs.org/argparse/-/argparse-1.0.10.tgz",
"integrity": "sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg==",
"dev": true,
"dependencies": {
"sprintf-js": "~1.0.2"
}
},
"node_modules/json-schema-ref-parser/node_modules/js-yaml": {
"version": "3.14.1",
"resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-3.14.1.tgz",
"integrity": "sha512-okMH7OXXJ7YrN9Ok3/SXrnu4iX9yOk+25nqX4imS2npuvTYDmo/QEZoqwZkYaIDk3jVvBOTOIEgEhaLOynBS9g==",
"dev": true,
"dependencies": {
"argparse": "^1.0.7",
"esprima": "^4.0.0"
},
"bin": {
"js-yaml": "bin/js-yaml.js"
}
},
"node_modules/json-schema-to-ts": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/json-schema-to-ts/-/json-schema-to-ts-3.1.0.tgz",
@ -3234,6 +3344,15 @@
"node": ">=6"
}
},
"node_modules/jsonpath-plus": {
"version": "7.2.0",
"resolved": "https://registry.npmjs.org/jsonpath-plus/-/jsonpath-plus-7.2.0.tgz",
"integrity": "sha512-zBfiUPM5nD0YZSBT/o/fbCUlCcepMIdP0CJZxM1+KgA4f2T206f6VAg9e7mX35+KlMaIc5qXW34f3BnwJ3w+RA==",
"dev": true,
"engines": {
"node": ">=12.0.0"
}
},
"node_modules/keyv": {
"version": "4.5.4",
"resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz",
@ -3314,6 +3433,18 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/lodash.castarray": {
"version": "4.4.0",
"resolved": "https://registry.npmjs.org/lodash.castarray/-/lodash.castarray-4.4.0.tgz",
"integrity": "sha512-aVx8ztPv7/2ULbArGJ2Y42bG1mEQ5mGjpdvrbJcJFU3TbYybe+QlLS4pst9zV52ymy2in1KpFPiZnAOATxD4+Q==",
"dev": true
},
"node_modules/lodash.isplainobject": {
"version": "4.0.6",
"resolved": "https://registry.npmjs.org/lodash.isplainobject/-/lodash.isplainobject-4.0.6.tgz",
"integrity": "sha512-oSXzaWypCMHkPC3NvBEaPHf0KsA5mvPrOPgQWDsbg8n7orZ290M0BmC/jgRZ4vcJ6DTAhjrsSYgdsW/F+MFOBA==",
"dev": true
},
"node_modules/lodash.merge": {
"version": "4.6.2",
"resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz",
@ -3366,6 +3497,11 @@
"semver": "bin/semver"
}
},
"node_modules/material-icons": {
"version": "1.13.12",
"resolved": "https://registry.npmjs.org/material-icons/-/material-icons-1.13.12.tgz",
"integrity": "sha512-/2YoaB79IjUK2B2JB+vIXXYGtBfHb/XG66LvoKVM5ykHW7yfrV5SP6d7KLX6iijY6/G9GqwgtPQ/sbhFnOURVA=="
},
"node_modules/merge-anything": {
"version": "5.1.7",
"resolved": "https://registry.npmjs.org/merge-anything/-/merge-anything-5.1.7.tgz",
@ -3551,6 +3687,15 @@
"wrappy": "1"
}
},
"node_modules/ono": {
"version": "4.0.11",
"resolved": "https://registry.npmjs.org/ono/-/ono-4.0.11.tgz",
"integrity": "sha512-jQ31cORBFE6td25deYeD80wxKBMj+zBmHTrVxnc6CKhx8gho6ipmWM5zj/oeoqioZ99yqBls9Z/9Nss7J26G2g==",
"dev": true,
"dependencies": {
"format-util": "^1.0.3"
}
},
"node_modules/optionator": {
"version": "0.9.4",
"resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz",
@ -3867,6 +4012,73 @@
"node": ">=4"
}
},
"node_modules/postcss-url": {
"version": "10.1.3",
"resolved": "https://registry.npmjs.org/postcss-url/-/postcss-url-10.1.3.tgz",
"integrity": "sha512-FUzyxfI5l2tKmXdYc6VTu3TWZsInayEKPbiyW+P6vmmIrrb4I6CGX0BFoewgYHLK+oIL5FECEK02REYRpBvUCw==",
"dev": true,
"dependencies": {
"make-dir": "~3.1.0",
"mime": "~2.5.2",
"minimatch": "~3.0.4",
"xxhashjs": "~0.2.2"
},
"engines": {
"node": ">=10"
},
"peerDependencies": {
"postcss": "^8.0.0"
}
},
"node_modules/postcss-url/node_modules/brace-expansion": {
"version": "1.1.11",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz",
"integrity": "sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==",
"dev": true,
"dependencies": {
"balanced-match": "^1.0.0",
"concat-map": "0.0.1"
}
},
"node_modules/postcss-url/node_modules/make-dir": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/make-dir/-/make-dir-3.1.0.tgz",
"integrity": "sha512-g3FeP20LNwhALb/6Cz6Dd4F2ngze0jz7tbzrD2wAV+o9FeNHe4rL+yK2md0J/fiSf1sa1ADhXqi5+oVwOM/eGw==",
"dev": true,
"dependencies": {
"semver": "^6.0.0"
},
"engines": {
"node": ">=8"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/postcss-url/node_modules/mime": {
"version": "2.5.2",
"resolved": "https://registry.npmjs.org/mime/-/mime-2.5.2.tgz",
"integrity": "sha512-tqkh47FzKeCPD2PUiPB6pkbMzsCasjxAfC62/Wap5qrUWcb+sFasXUC5I3gYM5iBM8v/Qpn4UK0x+j0iHyFPDg==",
"dev": true,
"bin": {
"mime": "cli.js"
},
"engines": {
"node": ">=4.0.0"
}
},
"node_modules/postcss-url/node_modules/minimatch": {
"version": "3.0.8",
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.0.8.tgz",
"integrity": "sha512-6FsRAQsxQ61mw+qP1ZzbL9Bc78x2p5OqNgNpnoAFLTrX8n5Kxph0CsnhmKKNXTWjXqU5L0pGPR7hYk+XWZr60Q==",
"dev": true,
"dependencies": {
"brace-expansion": "^1.1.7"
},
"engines": {
"node": "*"
}
},
"node_modules/postcss-value-parser": {
"version": "4.2.0",
"resolved": "https://registry.npmjs.org/postcss-value-parser/-/postcss-value-parser-4.2.0.tgz",
@ -4289,6 +4501,12 @@
"node": ">=0.10.0"
}
},
"node_modules/sprintf-js": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/sprintf-js/-/sprintf-js-1.0.3.tgz",
"integrity": "sha512-D9cPgkvLlV3t3IzL0D0YLvGA9Ahk4PcvVwUbN0dSGr1aP0Nrt4AEnTUbuGvquEC0mA64Gqt1fzirlRs5ibXx8g==",
"dev": true
},
"node_modules/string-width": {
"version": "5.1.2",
"resolved": "https://registry.npmjs.org/string-width/-/string-width-5.1.2.tgz",
@ -5008,6 +5226,15 @@
"integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==",
"dev": true
},
"node_modules/xxhashjs": {
"version": "0.2.2",
"resolved": "https://registry.npmjs.org/xxhashjs/-/xxhashjs-0.2.2.tgz",
"integrity": "sha512-AkTuIuVTET12tpsVIQo+ZU6f/qDmKuRUcjaqR+OIvm+aCBsZ95i7UVY5WJ9TMsSaZ0DA2WxoZ4acu0sPH+OKAw==",
"dev": true,
"dependencies": {
"cuint": "^0.2.2"
}
},
"node_modules/yallist": {
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz",

View File

@ -13,24 +13,28 @@
"license": "MIT",
"devDependencies": {
"@eslint/js": "^9.3.0",
"@tailwindcss/typography": "^0.5.13",
"@types/node": "^20.12.12",
"@typescript-eslint/parser": "^7.10.0",
"autoprefixer": "^10.4.19",
"classnames": "^2.5.1",
"daisyui": "^4.11.1",
"eslint": "^8.57.0",
"eslint-plugin-tailwindcss": "^3.17.0",
"json-schema-faker": "^0.5.6",
"json-schema-to-ts": "^3.1.0",
"postcss": "^8.4.38",
"postcss-url": "^10.1.3",
"prettier": "^3.2.5",
"solid-devtools": "^0.29.2",
"tailwindcss": "^3.4.3",
"typescript": "^5.4.5",
"typescript-eslint": "^7.10.0",
"vite": "^5.0.11",
"vite-plugin-solid": "^2.8.2",
"eslint-plugin-tailwindcss": "^3.17.0"
"vite-plugin-solid": "^2.8.2"
},
"dependencies": {
"material-icons": "^1.13.12",
"solid-js": "^1.8.11"
}
}

View File

@ -1,59 +1,18 @@
import { Match, Switch, createSignal, type Component } from "solid-js";
import { createSignal, type Component } from "solid-js";
import { CountProvider } from "./Config";
// import { Nested } from "./nested";
import { Layout } from "./layout/layout";
import cx from "classnames";
import { Nested } from "./nested";
import { Route, Router } from "./Routes";
type Route = "home" | "machines";
// Global state
const [route, setRoute] = createSignal<Route>("machines");
export { route, setRoute };
const App: Component = () => {
const [route, setRoute] = createSignal<Route>("home");
return (
<CountProvider>
<Layout>
<div class="col-span-1">
<div class={cx("text-zinc-500")}>Navigation</div>
<ul>
<li>
<button
onClick={() => setRoute("home")}
classList={{ "bg-blue-500": route() === "home" }}
>
Home
</button>
</li>
<li>
{" "}
<button
onClick={() => setRoute("machines")}
classList={{ "bg-blue-500": route() === "machines" }}
>
Machines
</button>
</li>
</ul>
</div>
<div class="col-span-7">
<div>{route()}</div>
<Switch fallback={<p>{route()} not found</p>}>
<Match when={route() == "home"}>
<Nested />
</Match>
<Match when={route() == "machines"}>
<div class="grid grid-cols-3 gap-2">
<div class="h-10 w-20 bg-red-500">red</div>
<div class="h-10 w-20 bg-green-500">green</div>
<div class="h-10 w-20 bg-blue-500">blue</div>
<div class="h-10 w-20 bg-yellow-500">yellow</div>
<div class="h-10 w-20 bg-purple-500">purple</div>
<div class="h-10 w-20 bg-cyan-500">cyan</div>
<div class="h-10 w-20 bg-pink-500">pink</div>
</div>
</Match>
</Switch>
</div>
<Router route={route} />
</Layout>
</CountProvider>
);

View File

@ -10,7 +10,7 @@ import { OperationResponse, pyApi } from "./message";
export const makeCountContext = () => {
const [machines, setMachines] = createSignal<
OperationResponse<"list_machines">
>({});
>([]);
const [loading, setLoading] = createSignal(false);
pyApi.list_machines.receive((machines) => {
@ -41,7 +41,7 @@ export const CountContext = createContext<CountContextType>([
loading: () => false,
// eslint-disable-next-line
machines: () => ({}),
machines: () => ([]),
},
{
// eslint-disable-next-line

View File

@ -0,0 +1,32 @@
import { Accessor, For, Match, Switch } from "solid-js";
import { MachineListView } from "./routes/machines/view";
import { colors } from "./routes/colors/view";
export type Route = keyof typeof routes;
export const routes = {
machines: {
child: MachineListView,
label: "Machines",
icon: "devices_other",
},
colors: {
child: colors,
label: "Colors",
icon: "color_lens",
},
};
interface RouterProps {
route: Accessor<Route>;
}
export const Router = (props: RouterProps) => {
const { route } = props;
return (
<Switch fallback={<p>route {route()} not found</p>}>
<For each={Object.entries(routes)}>
{([key, { child }]) => <Match when={route() === key}>{child}</Match>}
</For>
</Switch>
);
};

View File

@ -0,0 +1,33 @@
import { Accessor, For, Setter } from "solid-js";
import { Route, routes } from "./Routes";
interface SidebarProps {
route: Accessor<Route>;
setRoute: Setter<Route>;
}
export const Sidebar = (props: SidebarProps) => {
const { route, setRoute } = props;
return (
<aside class="min-h-screen w-80 bg-base-100">
<div class="sticky top-0 z-20 hidden items-center gap-2 bg-base-100/90 px-4 py-2 shadow-sm backdrop-blur lg:flex">
Icon
</div>
<ul class="menu px-4 py-0">
<For each={Object.entries(routes)}>
{([key, { label, icon }]) => (
<li>
<button
onClick={() => setRoute(key as Route)}
class="group"
classList={{ "bg-blue-500": route() === key }}
>
<span class="material-icons">{icon}</span>
{label}
</button>
</li>
)}
</For>
</ul>
</aside>
);
};

View File

@ -1,3 +1,5 @@
@import 'material-icons/iconfont/filled.css';
/* List of icons: https://marella.me/material-icons/demo/ */
@tailwind base;
@tailwind components;
@tailwind utilities;

View File

@ -3,7 +3,7 @@ import { render } from "solid-js/web";
import "./index.css";
import App from "./App";
import { getFakeResponse } from "../mock";
const root = document.getElementById("app");
window.clan = window.clan || {};
@ -14,5 +14,26 @@ if (import.meta.env.DEV && !(root instanceof HTMLElement)) {
);
}
console.log(import.meta.env);
if (import.meta.env.DEV) {
console.log("Development mode");
window.webkit = window.webkit || {
messageHandlers: {
gtk: {
postMessage: (postMessage) => {
const { method, data } = postMessage;
console.debug("Python API call", { method, data });
setTimeout(() => {
const mock = getFakeResponse(method, data);
console.log("mock", { mock });
window.clan[method](JSON.stringify(mock));
}, 1000);
},
},
},
};
}
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
render(() => <App />, root!);

View File

@ -0,0 +1,19 @@
export const Header = () => {
return (
<div class="navbar bg-base-100">
<div class="flex-none">
<button class="btn btn-square btn-ghost">
<span class="material-icons">home</span>
</button>
</div>
<div class="flex-1">
<a class="btn btn-ghost text-xl">Clan</a>
</div>
<div class="flex-none">
<button class="btn btn-square btn-ghost">
<span class="material-icons">menu</span>
</button>
</div>
</div>
);
};

View File

@ -1,9 +1,35 @@
import { Component, JSXElement } from "solid-js";
import { Header } from "./header";
import { Sidebar } from "../Sidebar";
import { route, setRoute } from "../App";
interface LayoutProps {
children: JSXElement;
}
export const Layout: Component<LayoutProps> = (props) => {
return <div class="grid grid-cols-8">{props.children}</div>;
return (
<>
<div class="drawer bg-base-100 lg:drawer-open">
<input
id="toplevel-drawer"
type="checkbox"
class="drawer-toggle hidden"
/>
<div class="drawer-content">
<Header />
{props.children}
</div>
<div class="drawer-side z-40">
<label
for="toplevel-drawer"
aria-label="close sidebar"
class="drawer-overlay"
></label>
<Sidebar route={route} setRoute={setRoute} />
</div>
</div>
</>
);
};

View File

@ -1,39 +0,0 @@
import { For, Match, Switch, createEffect, type Component } from "solid-js";
import { useCountContext } from "./Config";
export const Nested: Component = () => {
const [{ machines, loading }, { getMachines }] = useCountContext();
const list = () => Object.values(machines());
createEffect(() => {
console.log("1", list());
});
createEffect(() => {
console.log("2", machines());
});
return (
<div>
<button onClick={() => getMachines()} class="btn btn-primary">
Get machines
</button>
<div></div>
<Switch>
<Match when={loading()}>Loading...</Match>
<Match when={!loading() && Object.entries(machines()).length === 0}>
No machines found
</Match>
<Match when={!loading()}>
<For each={list()}>
{(entry, i) => (
<li>
{i() + 1}: {entry.machine_name}{" "}
{entry.machine_description || "No description"}
</li>
)}
</For>
</Match>
</Switch>
</div>
);
};

View File

@ -0,0 +1,13 @@
export const colors = () => {
return (
<div class="grid grid-cols-3 gap-2">
<div class="h-10 w-20 bg-red-500">red</div>
<div class="h-10 w-20 bg-green-500">green</div>
<div class="h-10 w-20 bg-blue-500">blue</div>
<div class="h-10 w-20 bg-yellow-500">yellow</div>
<div class="h-10 w-20 bg-purple-500">purple</div>
<div class="h-10 w-20 bg-cyan-500">cyan</div>
<div class="h-10 w-20 bg-pink-500">pink</div>
</div>
);
};

View File

@ -0,0 +1,78 @@
import { For, Match, Switch, createEffect, type Component } from "solid-js";
import { useCountContext } from "../../Config";
import { route } from "@/src/App";
export const MachineListView: Component = () => {
const [{ machines, loading }, { getMachines }] = useCountContext();
createEffect(() => {
if (route() === "machines") getMachines();
});
return (
<div class="max-w-screen-lg">
<div class="tooltip" data-tip="Refresh ">
<button class="btn btn-ghost" onClick={() => getMachines()}>
<span class="material-icons ">refresh</span>
</button>
</div>
<Switch>
<Match when={loading()}>
{/* Loading skeleton */}
<div>
<div class="card card-side m-2 bg-base-100 shadow-lg">
<figure class="pl-2">
<div class="skeleton size-12"></div>
</figure>
<div class="card-body">
<h2 class="card-title">
<div class="skeleton h-12 w-80"></div>
</h2>
<div class="skeleton h-8 w-72"></div>
</div>
</div>
</div>
</Match>
<Match when={!loading() && machines().length === 0}>
No machines found
</Match>
<Match when={!loading()}>
<ul>
<For each={machines()}>
{(entry) => (
<li>
<div class="card card-side m-2 bg-base-100 shadow-lg">
<figure class="pl-2">
<span class="material-icons content-center text-5xl">
devices_other
</span>
</figure>
<div class="card-body flex-row justify-between">
<div class="flex flex-col">
<h2 class="card-title">{entry}</h2>
{/*
<p
classList={{
"text-gray-400": !entry.machine_description,
"text-gray-600": !!entry.machine_description,
}}
>
{entry.machine_description || "No description"}
</p>
*/}
</div>
<div>
<button class="btn btn-ghost">
<span class="material-icons">more_vert</span>
</button>
</div>
</div>
</div>
</li>
)}
</For>
</ul>
</Match>
</Switch>
</div>
);
};

View File

@ -1,9 +1,11 @@
const typography = require("@tailwindcss/typography");
const daisyui = require("daisyui");
/** @type {import('tailwindcss').Config} */
module.exports = {
content: ["./src/**/*.{js,jsx,ts,tsx}"],
theme: {
extend: {},
},
plugins: [daisyui],
plugins: [typography, daisyui],
};

View File

@ -11,7 +11,7 @@
npmDeps = pkgs.fetchNpmDeps {
src = ./app;
hash = "sha256-E0++hupVKnDqmLk7ljoMcqcI4w+DIMlfRYRPbKUsT2c=";
hash = "sha256-EadzSkIsV/cJtdxpIUvvpQhu5h3VyF8bLMpwfksNmWQ=";
};
# The prepack script runs the build script, which we'd rather do in the build phase.
npmPackFlags = [ "--ignore-scripts" ];

View File

@ -0,0 +1,2 @@
# DO NOT DELETE
# This file is used by the clan cli to discover a clan flake

View File

@ -0,0 +1,5 @@
{
"name": "My Empty Clan",
"description": "some nice description",
"icon": "A path to the png"
}

16
templates/empty/flake.nix Normal file
View File

@ -0,0 +1,16 @@
# Clan configuration file
# TODO: This file is used as a template for the simple GUI workflow
{
inputs.clan-core.url = "git+https://git.clan.lol/clan/clan-core";
outputs =
{ self, clan-core, ... }:
let
clan = clan-core.lib.buildClan {
# This clan builds all its configuration out of the current directory
directory = self;
};
in
{
inherit (clan) nixosConfigurations clanInternals;
};
}

View File

@ -5,6 +5,10 @@
description = "Initialize a new clan flake";
path = ./new-clan;
};
empty = {
description = "A empty clan template. Primarily for usage with the clan ui";
path = ./empty;
};
default = self.templates.new-clan;
};
}