Compare commits

..

124 Commits

Author SHA1 Message Date
pinpox
3ac9167f18 expose metrics as json 2025-08-20 11:04:01 +02:00
DavHau
de0b1b2d70 vars: fix regenerating a specific generator
This was broken after re-designing the API -> added a test
2025-08-20 14:49:27 +07:00
clan-bot
6996a6340a Merge pull request 'Update clan-core-for-checks in devFlake' (#4824) from update-devFlake-clan-core-for-checks into main 2025-08-20 05:25:25 +00:00
clan-bot
3c433da8f5 Update clan-core-for-checks in devFlake 2025-08-20 05:01:28 +00:00
DavHau
ef2a2bdb67 vars: improve tests for --regenerate
Ensures that all generators values actually change after running with --regenerate
2025-08-20 11:59:18 +07:00
DavHau
7b61a668e9 vars: refactor: use Machine objects instead of base_dir strings
Replace base_dir string parameters with Machine objects throughout the vars
module for better type safety and consistency.
2025-08-20 11:59:18 +07:00
clan-bot
bdab3e23af Merge pull request 'Update clan-core-for-checks in devFlake' (#4822) from update-devFlake-clan-core-for-checks into main 2025-08-20 00:18:32 +00:00
clan-bot
2b068928a2 Merge pull request 'Update nixpkgs-dev in devFlake' (#4823) from update-devFlake-nixpkgs-dev into main 2025-08-20 00:10:20 +00:00
clan-bot
ec798f89fd Update nixpkgs-dev in devFlake 2025-08-20 00:01:49 +00:00
clan-bot
9efee40477 Update clan-core-for-checks in devFlake 2025-08-20 00:01:30 +00:00
lassulus
6c6e30ae60 Merge pull request 'Add type to group and owner vars options' (#4819) from fix-4814 into main
Reviewed-on: https://git.clan.lol/clan/clan-core/pulls/4819
Reviewed-by: lassulus <clanlol@lassul.us>
2025-08-19 21:13:51 +00:00
pinpox
b27ff67a14 Add type to group and owner vars options 2025-08-19 22:46:30 +02:00
clan-bot
c0ffb17e00 Merge pull request 'Update nixpkgs' (#4818) from update-nixpkgs into main 2025-08-19 20:21:34 +00:00
Mic92
e9ccf157b6 Merge pull request 'Update clan-core-for-checks in devFlake' (#4744) from update-devFlake-clan-core-for-checks into main
Reviewed-on: https://git.clan.lol/clan/clan-core/pulls/4744
2025-08-19 20:21:18 +00:00
clan-bot
451f2427fe Merge pull request 'Update nixos-facter-modules' (#4724) from update-nixos-facter-modules into main 2025-08-19 20:15:55 +00:00
clan-bot
1676cdd9a4 Update clan-core-for-checks in devFlake 2025-08-19 20:01:30 +00:00
clan-bot
109e6473ab Update nixpkgs 2025-08-19 20:01:23 +00:00
clan-bot
55acff50d0 Update nixos-facter-modules 2025-08-19 20:00:54 +00:00
hsjobeki
eee1bd1ae0 Merge pull request 'ui/select: display no options placeholder' (#4817) from install-story into main
Reviewed-on: https://git.clan.lol/clan/clan-core/pulls/4817
2025-08-19 19:50:56 +00:00
Johannes Kirschbauer
e46d5870ff ui/select: display no options placeholder 2025-08-19 21:46:26 +02:00
hsjobeki
f6ec32a5d1 Merge pull request 'ui/modal/select: fix z-index stacking' (#4816) from render-2 into main
Reviewed-on: https://git.clan.lol/clan/clan-core/pulls/4816
2025-08-19 17:19:18 +00:00
Johannes Kirschbauer
e336d1b19c ui/modal/select: fix z-index stacking 2025-08-19 19:15:40 +02:00
brianmcgee
7399f59652 Merge pull request 'fix(ui): reload machine list in sidebar after adding a machine' (#4815) from ui/invalidate-list-query-on-add into main
Reviewed-on: https://git.clan.lol/clan/clan-core/pulls/4815
2025-08-19 16:41:31 +00:00
Brian McGee
088abe396e fix(ui): reload machine list in sidebar after adding a machine 2025-08-19 17:37:53 +01:00
Mic92
26b31e24a3 Merge pull request 'Make most vm tests pure.' (#4796) from no-impure into main
Reviewed-on: https://git.clan.lol/clan/clan-core/pulls/4796
2025-08-19 16:10:08 +00:00
brianmcgee
099f4c2b8b Merge pull request 'feat(api): define list machine options as data class' (#4811) from api/list-machine-data-class into main
Reviewed-on: https://git.clan.lol/clan/clan-core/pulls/4811
2025-08-19 16:07:13 +00:00
brianmcgee
b43605c168 Merge pull request 'ui/filter-usb-devices' (#4813) from ui/filter-usb-devices into main
Reviewed-on: https://git.clan.lol/clan/clan-core/pulls/4813
2025-08-19 15:58:27 +00:00
Jörg Thalheim
899dba5a08 tests/vms: add chroot-realpath (needed on aarch64) 2025-08-19 15:53:46 +00:00
Brian McGee
d2b94ced5a feat(api): define list machine options as data class 2025-08-19 16:51:30 +01:00
Jörg Thalheim
cdf9fa1753 move vm configuration into a stand-alone module and include it in our test vms
This hasn't reduced the extra deps we have to pass to our nixos build
unfortunally, but maybe at least it can safe us a few in the future.
2025-08-19 15:45:57 +00:00
Brian McGee
d1e7e2993d feat(ui): filter block devices in flash installer
Only display usb or mmc (SD card) drives.
2025-08-19 16:45:47 +01:00
Brian McGee
e05d85c759 feat(ui): darken modal overlay 2025-08-19 16:13:19 +01:00
clan-bot
53873411a6 Merge pull request 'Update disko' (#4793) from update-disko into main 2025-08-19 14:42:47 +00:00
clan-bot
39e0ab21bd Merge pull request 'Update nixpkgs-dev in devFlake' (#4794) from update-devFlake-nixpkgs-dev into main 2025-08-19 14:28:48 +00:00
clan-bot
8269d869c3 Update disko 2025-08-19 14:24:27 +00:00
clan-bot
e19d1c8122 Update nixpkgs-dev in devFlake 2025-08-19 14:24:17 +00:00
brianmcgee
0cd4ff1b12 Merge pull request 'tracking machine install state' (#4803) from feat/machine-install-state into main
Reviewed-on: https://git.clan.lol/clan/clan-core/pulls/4803
2025-08-19 14:23:35 +00:00
Brian McGee
9aebf02f05 feat(ui): display machine install state and install button 2025-08-19 15:09:34 +01:00
Jörg Thalheim
ffb7b91da7 drop impure checks from ci 2025-08-19 15:28:25 +02:00
Jörg Thalheim
2d264a8e5e mark vm tests as pure 2025-08-19 15:28:25 +02:00
Mic92
abf6893714 Merge pull request 'Fix aarch64-linux vm support' (#4810) from various-fixes into main
Reviewed-on: https://git.clan.lol/clan/clan-core/pulls/4810
2025-08-19 13:21:28 +00:00
Jörg Thalheim
699c56c721 qemu: enable usb tablet option only on x86_64-linux
at least on aarch64-linux this locks up the hypervisor
2025-08-19 15:16:56 +02:00
Jörg Thalheim
2ce5388a75 qemu: fix machine types for various platforms 2025-08-19 15:16:56 +02:00
Jörg Thalheim
3e664255d6 speed up tests by doing reflink copies 2025-08-19 15:16:56 +02:00
Jörg Thalheim
5b1a9d6848 vms: also prebuild for aarch64 2025-08-19 14:49:52 +02:00
Jörg Thalheim
1850abdd0d clan-cli/vms/run: generate secret before inspect_vm
inspect_vm does some caching, which lead to secrets not beeing found.
2025-08-19 14:49:52 +02:00
Jörg Thalheim
ed503f64da vms/run: move python import to the top. 2025-08-19 14:49:52 +02:00
Jörg Thalheim
4074a184b2 make vm test pure 2025-08-19 14:47:12 +02:00
Jörg Thalheim
6fe2b06f09 qemu: fix nix chroot store support 2025-08-19 14:47:12 +02:00
Jörg Thalheim
8fe7cb1b3d virtiofsd: fix nix chroot store support 2025-08-19 14:47:12 +02:00
DavHau
815c6c9438 vars: move generation functions to clan_lib 2025-08-19 18:05:53 +07:00
DavHau
9ce563aa08 vars: log var updates under specific machine
This makes it easier in the logs to identify which machine a var update belongs to
2025-08-19 11:03:36 +00:00
hsjobeki
c25844dd07 Merge pull request 'ui/modal: refactor mounting and controlled state' (#4807) from render-2 into main
Reviewed-on: https://git.clan.lol/clan/clan-core/pulls/4807
2025-08-19 10:55:43 +00:00
Johannes Kirschbauer
a167e70e63 ui/modal: refactor mounting and controlled state 2025-08-19 12:52:20 +02:00
hsjobeki
dd96fe6b73 Merge pull request 'ui/routing: re-route on changes not only on page load' (#4805) from render-2 into main
Reviewed-on: https://git.clan.lol/clan/clan-core/pulls/4805
2025-08-19 10:15:59 +00:00
Johannes Kirschbauer
40d35d37e2 ui/routing: re-route on changes not only on page load 2025-08-19 12:10:04 +02:00
Luis Hebendanz
071f0f8034 Merge pull request 'codeowners: init team code owners' (#4786) from codeowners-2 into main
Reviewed-on: https://git.clan.lol/clan/clan-core/pulls/4786
Reviewed-by: lassulus <clanlol@lassul.us>
Reviewed-by: pinpox <clan@pablo.tools>
Reviewed-by: DavHau <d.hauer.it@gmail.com>
Reviewed-by: brianmcgee <brian@bmcgee.ie>
2025-08-19 09:54:33 +00:00
Johannes Kirschbauer
81d88fe253 codeowners: init team code owners 2025-08-19 11:35:10 +02:00
DavHau
ab274ce932 vars: refactor - remove generate_vars() in favor of run_generators()
The motivation is to have one shared entry point for the CLI as well as API/GUI
2025-08-19 16:26:53 +07:00
hsjobeki
ba1e598a76 Merge pull request 'ui/alert: migrate to css modules' (#4802) from css-modules into main
Reviewed-on: https://git.clan.lol/clan/clan-core/pulls/4802
2025-08-19 08:58:22 +00:00
Johannes Kirschbauer
b5d29bd301 ui/alert: migrate to css modules 2025-08-19 10:27:55 +02:00
Johannes Kirschbauer
e174e8e029 css-modules: add typechecking for css module classes 2025-08-19 10:20:50 +02:00
Kenji Berthold
453d2b4a0a Merge pull request 'pkgs/remove-moonlight-sunshine-accept: drop' (#4798) from remove-moonlight-sunshine-accept into main
Reviewed-on: https://git.clan.lol/clan/clan-core/pulls/4798
Reviewed-by: Kenji Berthold <aks.kenji@protonmail.com>
2025-08-19 07:50:41 +00:00
DavHau
aadc8a1d63 vars: refactor - remove _generate_vars_for_machine function
This became unnecessary by now
2025-08-19 07:41:31 +00:00
DavHau
aaca8f4763 vars: refactor - move generator specific code to Generator class
Several functions in generate.py were specific to generator instances. Let's move them into the Generator class
2025-08-19 07:41:31 +00:00
DavHau
0a1a63dfdd Merge pull request 'vars: refactor - remove create_machine_vars_interactive in favor of run_generators' (#4795) from vars into main
Reviewed-on: https://git.clan.lol/clan/clan-core/pulls/4795
2025-08-19 06:41:12 +00:00
DavHau
ee87f20471 vars: refactor - remove create_machine_vars_interactive in favor of run_generators
The motivation is to create one powerful entrypoint shared by the GUI as well as the CLI in order to not having to maintain too much separate code paths.

As a next step, generate_vars can probably also be removed.
2025-08-19 13:26:38 +07:00
hsjobeki
43febe5f33 Merge pull request 'Typography and contrast improvements for the UI' (#4797) from ui/typography-size-increases into main
Reviewed-on: https://git.clan.lol/clan/clan-core/pulls/4797
2025-08-19 06:25:37 +00:00
clan-bot
c63bbabceb Merge pull request 'Update nuschtos in devFlake' (#4800) from update-devFlake-nuschtos into main 2025-08-19 00:10:33 +00:00
clan-bot
8f1b270b59 Update nuschtos in devFlake 2025-08-19 00:01:53 +00:00
hsjobeki
da0af8bd53 Merge pull request 'Api/schema: improve types top schema conversion' (#4799) from api-types into main
Reviewed-on: https://git.clan.lol/clan/clan-core/pulls/4799
2025-08-18 17:48:36 +00:00
Johannes Kirschbauer
f82d18d649 API: rename util file to 'type_to_jsonschema' 2025-08-18 19:30:43 +02:00
Johannes Kirschbauer
287a303484 API/schema: make type conversion more strict in terms of undefined fields 2025-08-18 19:29:54 +02:00
Johannes Kirschbauer
1213608f30 API: init support for narrowing union types
This allows to relax constraints on functions using overloaded interfaces
I.e. for unifying logic this allows passing 'callable | dict'
Conretely useful for prompt values that are asked on demand in the cli, vs upfront in the ui
2025-08-18 19:28:47 +02:00
pinpox
fa1693e8c0 pkgs/remove-moonlight-sunshine-accept: drop
Removes this package as the module has already be deprecated and removed
2025-08-18 14:39:08 +02:00
Brian McGee
ed3ed7cb2a chore(ui): lint 2025-08-18 12:52:33 +01:00
Brian McGee
b2e88fb3fa chore(ui): fmt 2025-08-18 12:52:33 +01:00
Brian McGee
d6ca50218a feat(ui): increase fg/def/4 from 500 to 600 2025-08-18 12:52:32 +01:00
Brian McGee
7d1f0956d6 feat(ui): refine Tag and line-height for labels 2025-08-18 12:52:32 +01:00
Brian McGee
d150c80854 feat(ui): move sidebar section header outside content 2025-08-18 12:52:31 +01:00
Brian McGee
2d1828d088 feat(ui): better contrast in sidebar 2025-08-18 12:52:31 +01:00
Brian McGee
f7f897a311 feat(ui): add xs button type 2025-08-18 12:52:30 +01:00
Brian McGee
683ffbdc76 feat(ui): refine Select with new typography sizes 2025-08-18 12:52:30 +01:00
Brian McGee
480ad3a5f1 feat(ui): increase label font sizes 2025-08-18 12:52:29 +01:00
Brian McGee
16361f03e9 feat(ui): typography size increases 2025-08-18 12:52:27 +01:00
clan-bot
3fb8b6587d Merge pull request 'Update nixpkgs-dev in devFlake' (#4791) from update-devFlake-nixpkgs-dev into main 2025-08-17 00:08:28 +00:00
clan-bot
6aee353b43 Update nixpkgs-dev in devFlake 2025-08-17 00:01:48 +00:00
hsjobeki
e109361e81 Merge pull request 'clanModules: remove unused code' (#4785) from clean-dead-code into main
Reviewed-on: https://git.clan.lol/clan/clan-core/pulls/4785
2025-08-16 11:03:16 +00:00
Johannes Kirschbauer
3c34f81a44 inventory/tests: remove unused tests 2025-08-16 12:56:30 +02:00
Johannes Kirschbauer
72e7c2e9b9 clanModules: cleanup some more unused code 2025-08-16 12:56:30 +02:00
Johannes Kirschbauer
03968d8fbc api/inventory: remove leaked schemas 2025-08-16 12:56:30 +02:00
Johannes Kirschbauer
2f27b3941e lib/inventory: limit access to defined keys 2025-08-16 12:56:30 +02:00
clan-bot
e9dc5b9ba6 Merge pull request 'Update nixpkgs-dev in devFlake' (#4787) from update-devFlake-nixpkgs-dev into main 2025-08-16 10:07:46 +00:00
clan-bot
e4ef885cd5 Update nixpkgs-dev in devFlake 2025-08-16 10:01:45 +00:00
Johannes Kirschbauer
9fe457ebd5 lib/clanModules: update nix_models 2025-08-16 11:59:16 +02:00
Johannes Kirschbauer
4a51aa9316 clanModules: remove unused test code 2025-08-16 11:58:55 +02:00
Johannes Kirschbauer
308a10d6e6 clanModules: remove unused code 2025-08-16 11:48:13 +02:00
clan-bot
90f513a08f Merge pull request 'Update nixpkgs' (#4784) from update-nixpkgs into main 2025-08-16 00:21:23 +00:00
clan-bot
4ddc61d132 Update nixpkgs 2025-08-16 00:01:27 +00:00
clan-bot
fc0088e9ea Merge pull request 'Update nix-darwin' (#4783) from update-nix-darwin into main 2025-08-15 20:16:14 +00:00
clan-bot
71094f7fa1 Update nix-darwin 2025-08-15 20:00:52 +00:00
clan-bot
a8516cf9c6 Merge pull request 'Update nixpkgs-dev in devFlake' (#4782) from update-devFlake-nixpkgs-dev into main 2025-08-15 15:08:18 +00:00
clan-bot
a89e2f877a Update nixpkgs-dev in devFlake 2025-08-15 15:01:50 +00:00
Mic92
ed78e49c47 Merge pull request 'vms/inspect: mark test as pure' (#4781) from no-impure into main
Reviewed-on: https://git.clan.lol/clan/clan-core/pulls/4781
2025-08-15 11:54:46 +00:00
Jörg Thalheim
3ef0a7919d vms/inspect: mark test as pure 2025-08-15 13:31:27 +02:00
Jörg Thalheim
36812d5f95 test_vars_deployment: simplify test to just start one vm 2025-08-15 13:30:30 +02:00
Mic92
f5bcdb4ba0 Merge pull request 'flakes/inspect: mark test as pure' (#4779) from no-impure into main
Reviewed-on: https://git.clan.lol/clan/clan-core/pulls/4779
2025-08-15 11:28:22 +00:00
Jörg Thalheim
b69ad0eca5 backups/list: mark as pure 2025-08-15 13:10:41 +02:00
Jörg Thalheim
b221c29694 flakes/inspect: mark test as pure 2025-08-15 13:08:30 +02:00
Luis Hebendanz
7dc7f09173 Merge pull request 'clanServices: telegraf -> add basic auth' (#4777) from Qubasa/clan-core:basic_auth_telegraf into main
Reviewed-on: https://git.clan.lol/clan/clan-core/pulls/4777
2025-08-15 11:07:44 +00:00
Mic92
ec3d224e1d Merge pull request 'tests_secrets_generate: mark as pure' (#4766) from no-impure into main
Reviewed-on: https://git.clan.lol/clan/clan-core/pulls/4766
2025-08-15 11:06:47 +00:00
Luis Hebendanz
00c5312080 Merge pull request 'docs: Revamp Getting Started guide for clarity and usability' (#4776) from scriptogre/clan-core:update-getting-started-docs into main
Reviewed-on: https://git.clan.lol/clan/clan-core/pulls/4776
2025-08-15 11:04:52 +00:00
Qubasa
7811a56d2b clanServices: telegraf -> add basic auth
treefmt
2025-08-15 18:02:31 +07:00
Jörg Thalheim
e9401177b7 installation: make sure target host is actually down 2025-08-15 12:51:20 +02:00
Jörg Thalheim
ef56258e8b impure-checks: reduce to 6 jobs 2025-08-15 12:51:20 +02:00
Jörg Thalheim
c4d9b39a17 tests_secrets_generate: mark as pure 2025-08-15 12:51:20 +02:00
Mic92
1f59b75c20 Merge pull request 'Delete old files when deploying docs' (#4775) from deploy-docs-delete into main
Reviewed-on: https://git.clan.lol/clan/clan-core/pulls/4775
2025-08-15 10:24:10 +00:00
scriptogre
6b6da7b897 docs: Revamp and simplify Getting Started guide 2025-08-15 13:19:39 +03:00
pinpox
4391c19ee9 Delete old files when deploying docs 2025-08-15 12:04:46 +02:00
hsjobeki
eb993b7060 Merge pull request 'ui/vars: add more vars to install story' (#4747) from ui-install-3 into main
Reviewed-on: https://git.clan.lol/clan/clan-core/pulls/4747
2025-08-15 09:14:46 +00:00
Johannes Kirschbauer
08cb6993a8 install/progress: display usb-stick 2025-08-15 11:10:57 +02:00
Johannes Kirschbauer
872f640211 install/assets: init usb-stick png image 2025-08-15 11:04:10 +02:00
Johannes Kirschbauer
c58f7c573d ui/install: clean up design 2025-08-15 11:04:09 +02:00
Johannes Kirschbauer
7b807a0745 ui/vars: add more vars to install story 2025-08-15 11:04:09 +02:00
165 changed files with 10054 additions and 4237 deletions

View File

@@ -1,9 +0,0 @@
name: checks
on:
pull_request:
jobs:
checks-impure:
runs-on: nix
steps:
- uses: actions/checkout@v4
- run: nix run .#impure-checks

1
.gitignore vendored
View File

@@ -39,7 +39,6 @@ select
# Generated files
pkgs/clan-app/ui/api/API.json
pkgs/clan-app/ui/api/API.ts
pkgs/clan-app/ui/api/Inventory.ts
pkgs/clan-app/ui/api/modules_schemas.json
pkgs/clan-app/ui/api/schema.json
pkgs/clan-app/ui/.fonts

View File

@@ -0,0 +1,20 @@
clanServices/.* @pinpox @kenji
lib/test/container-test-driver/.* @DavHau @mic92
lib/modules/inventory/.* @hsjobeki
lib/modules/inventoryClass/.* @hsjobeki
pkgs/clan-app/ui/.* @hsjobeki @brianmcgee
pkgs/clan-app/clan_app/.* @qubasa @hsjobeki
pkgs/clan-cli/clan_cli/.* @lassulus @mic92 @kenji
pkgs/clan-cli/clan_cli/(secrets|vars)/.* @DavHau @lassulus
pkgs/clan-cli/clan_lib/log_machines/.* @Qubasa
pkgs/clan-cli/clan_lib/ssh/.* @Qubasa @Mic92 @lassulus
pkgs/clan-cli/clan_lib/tags/.* @hsjobeki
pkgs/clan-cli/clan_lib/persist/.* @hsjobeki
pkgs/clan-cli/clan_lib/flake/.* @lassulus
pkgs/clan-cli/api.py @hsjobeki
pkgs/clan-cli/openapi.py @hsjobeki

View File

@@ -36,7 +36,6 @@ in
++ filter pathExists [
./devshell/flake-module.nix
./flash/flake-module.nix
./impure/flake-module.nix
./installation/flake-module.nix
./update/flake-module.nix
./morph/flake-module.nix
@@ -139,33 +138,6 @@ in
nixosTests
// flakeOutputs
// {
# TODO: Automatically provide this check to downstream users to check their modules
clan-modules-json-compatible =
let
allSchemas = lib.mapAttrs (
_n: m:
let
schema =
(self.clanLib.evalService {
modules = [ m ];
prefix = [
"checks"
system
];
}).config.result.api.schema;
in
schema
) self.clan.modules;
in
pkgs.runCommand "combined-result"
{
schemaFile = builtins.toFile "schemas.json" (builtins.toJSON allSchemas);
}
''
mkdir -p $out
cat $schemaFile > $out/allSchemas.json
'';
clan-core-for-checks = pkgs.runCommand "clan-core-for-checks" { } ''
cp -r ${privateInputs.clan-core-for-checks} $out
chmod -R +w $out

View File

@@ -1,51 +0,0 @@
{
perSystem =
{
pkgs,
lib,
self',
...
}:
{
# a script that executes all other checks
packages.impure-checks = pkgs.writeShellScriptBin "impure-checks" ''
#!${pkgs.bash}/bin/bash
set -euo pipefail
unset CLAN_DIR
export PATH="${
lib.makeBinPath (
[
pkgs.gitMinimal
pkgs.nix
pkgs.coreutils
pkgs.rsync # needed to have rsync installed on the dummy ssh server
]
++ self'.packages.clan-cli-full.runtimeDependencies
)
}"
ROOT=$(git rev-parse --show-toplevel)
cd "$ROOT/pkgs/clan-cli"
# Set up custom git configuration for tests
export GIT_CONFIG_GLOBAL=$(mktemp)
git config --file "$GIT_CONFIG_GLOBAL" user.name "Test User"
git config --file "$GIT_CONFIG_GLOBAL" user.email "test@example.com"
export GIT_CONFIG_SYSTEM=/dev/null
# this disables dynamic dependency loading in clan-cli
export CLAN_NO_DYNAMIC_DEPS=1
jobs=$(nproc)
# Spawning worker in pytest is relatively slow, so we limit the number of jobs to 13
# (current number of impure tests)
jobs="$((jobs > 13 ? 13 : jobs))"
nix develop "$ROOT#clan-cli" -c bash -c "TMPDIR=/tmp python -m pytest -n $jobs -m impure ./clan_cli $@"
# Clean up temporary git config
rm -f "$GIT_CONFIG_GLOBAL"
'';
};
}

View File

@@ -241,7 +241,7 @@
target.shutdown()
except BrokenPipeError:
# qemu has already exited
pass
target.connected = False
# Create a new machine instance that boots from the installed system
installed_machine = create_test_machine(target, "${pkgs.qemu_test}", name="after_install")

View File

@@ -24,12 +24,5 @@
};
};
# roles.prometheus = {
# interface = { lib, ... }: { };
# };
imports = [
./telegraf.nix
./prometheus.nix
];
imports = [ ./telegraf.nix ];
}

View File

@@ -1,182 +0,0 @@
{
roles.prometheus.perInstance =
{ settings, roles, ... }:
{
nixosModule =
{ pkgs, lib, ... }:
{
# imports = [
# # ./matrix-alertmanager.nix
# # ./irc-alertmanager.nix
# # ./rules.nix
# ];
services.prometheus = {
# webExternalUrl = "https://prometheus.thalheim.io";
extraFlags = [ "--storage.tsdb.retention.time=30d" ];
scrapeConfigs = [
{
job_name = "telegraf";
scrape_interval = "60s";
metrics_path = "/metrics";
static_configs = [
(map (host: {
labels.host = host;
# labels.org = "TODO";
targets = [ "${host}.clan:9273" ];
}) lib.attrNames roles.telegraf.machines)
# {
# # labels.host = "rauter.r:9273";
# # labels.org = "TODO";
# targets = map (host: "${host}.clan:9273") lib.attrNames roles.telegraf.machines;
# }
];
}
# {
# job_name = "gitea";
# scrape_interval = "60s";
# metrics_path = "/metrics";
#
# scheme = "https";
# static_configs = [ { targets = [ "git.thalheim.io:443" ]; } ];
# }
];
alertmanagers = [ { static_configs = [ { targets = [ "localhost:9093" ]; } ]; } ];
};
services.prometheus.alertmanager = {
enable = true;
# environmentFile = config.sops.secrets.alertmanager.path;
# webExternalUrl = "https://alertmanager.thalheim.io";
# listenAddress = "[::1]";
# configuration = {
# global = {
# # The smarthost and SMTP sender used for mail notifications.
# smtp_smarthost = "mail.thalheim.io:587";
# smtp_from = "alertmanager@thalheim.io";
# smtp_auth_username = "alertmanager@thalheim.io";
# smtp_auth_password = "$SMTP_PASSWORD";
# };
# route = {
# receiver = "default";
# routes = [
# {
# group_by = [ "host" ];
# match_re.org = "krebs";
# group_wait = "5m";
# group_interval = "5m";
# repeat_interval = "4h";
# receiver = "krebs";
# }
# {
# group_by = [ "host" ];
# match_re.org = "nixos-wiki";
# group_wait = "5m";
# group_interval = "5m";
# repeat_interval = "4h";
# receiver = "nixos-wiki";
# }
# {
# group_by = [ "host" ];
# match_re.org = "numtide";
# group_wait = "5m";
# group_interval = "5m";
# repeat_interval = "4h";
# receiver = "numtide";
# }
# {
# group_by = [ "host" ];
# match_re.org = "clan-lol";
# group_wait = "5m";
# group_interval = "5m";
# repeat_interval = "4h";
# receiver = "clan-lol";
# }
# {
# group_by = [ "host" ];
# match_re.org = "dave";
# group_wait = "5m";
# group_interval = "5m";
# repeat_interval = "4h";
# receiver = "dave";
# }
# {
# group_by = [ "host" ];
# group_wait = "30s";
# group_interval = "2m";
# repeat_interval = "2h";
# receiver = "all";
# }
# ];
# };
# receivers = [
# {
# name = "krebs";
# webhook_configs = [
# {
# url = "http://127.0.0.1:9223/";
# max_alerts = 5;
# }
# ];
# }
# {
# name = "numtide";
# webhook_configs = [
# # TODO
# #{
# # send_resolved = true;
# # url = "https://chat.ntd.one/plugins/alertmanager/api/webhook?token='xxxxxxxxxxxxxxxxxxx-yyyyyyy'";
# #}
# ];
# }
# {
# name = "nixos-wiki";
# webhook_configs = [
# {
# url = "http://localhost:9088/alert";
# max_alerts = 5;
# }
# ];
# }
# {
# name = "clan-lol";
# webhook_configs = [
# # TODO
# #{
# # url = "http://localhost:4050/services/hooks/YWxlcnRtYW5hZ2VyX3NlcnZpY2U";
# # max_alerts = 5;
# #}
# ];
# }
# {
# name = "dave";
# telegram_configs = [
# {
# chat_id = 42927997;
# bot_token = "$TELEGRAM_BOT_TOKEN";
# }
# ];
# }
# {
# name = "all";
# # pushover_configs = [
# # {
# # user_key = "$PUSHOVER_USER_KEY";
# # token = "$PUSHOVER_TOKEN";
# # priority = "0";
# # }
# # ];
# }
# { name = "default"; }
# ];
# };
};
};
};
}

View File

@@ -4,22 +4,53 @@
{
nixosModule =
{ pkgs, lib, ... }:
{
config,
pkgs,
lib,
...
}:
let
jsonpath = /tmp/telegraf.json;
in
{
networking.firewall.interfaces = lib.mkIf (settings.allowAllInterfaces == false) (
builtins.listToAttrs (
map (name: {
inherit name;
value.allowedTCPPorts = [ 9273 ];
value.allowedTCPPorts = [ 9273 9990 ];
}) settings.interfaces
)
);
systemd.services.telegsaf-json.script = "${pkgs.miniserve}/bin/miniserve -p 9990 ${jsonpath}";
networking.firewall.allowedTCPPorts = lib.mkIf (settings.allowAllInterfaces == true) [ 9273 ];
clan.core.vars.generators."telegraf-password" = {
files.telegraf-password.neededFor = "users";
files.telegraf-password.restartUnits = [ "telegraf.service" ];
runtimeInputs = [
pkgs.coreutils
pkgs.xkcdpass
pkgs.mkpasswd
];
script = ''
PASSWORD=$(xkcdpass --numwords 4 --delimiter - --count 1 | tr -d "\n")
echo "BASIC_AUTH_PWD=$PASSWORD" > "$out"/telegraf-password
'';
};
services.telegraf = {
enable = true;
environmentFiles = [
(builtins.toString
config.clan.core.vars.generators."telegraf-password".files.telegraf-password.path
)
];
extraConfig = {
agent.interval = "60s";
inputs = {
@@ -46,9 +77,18 @@
}
];
};
outputs.file = {
files = [ jsonpath ];
data_format = "json";
json_timestamp_units = "1s";
};
outputs.prometheus_client = {
listen = ":9273";
metric_version = 2;
basic_username = "prometheus";
basic_password = "$${BASIC_AUTH_PWD}";
};
};
};

18
devFlake/flake.lock generated
View File

@@ -3,10 +3,10 @@
"clan-core-for-checks": {
"flake": false,
"locked": {
"lastModified": 1755093452,
"narHash": "sha256-NKBss7QtNnOqYVyJmYCgaCvYZK0mpQTQc9fLgE1mGyk=",
"lastModified": 1755649112,
"narHash": "sha256-Tk/Sjlb7W+5usS4upH0la4aGYs2velkHADnhhn2xO88=",
"ref": "main",
"rev": "7e97734797f0c6bd3c2d3a51cf54a2a6b371c222",
"rev": "bdab3e23af4a9715dfa6346f344f1bd428508672",
"shallow": true,
"type": "git",
"url": "https://git.clan.lol/clan/clan-core"
@@ -84,11 +84,11 @@
},
"nixpkgs-dev": {
"locked": {
"lastModified": 1755166611,
"narHash": "sha256-sk8pK8kWz4IE4ErAjKE1d8tMChY6VQR32U4yS68FIog=",
"lastModified": 1755628699,
"narHash": "sha256-IAM29K+Cz9pu90lDDkJpmOpWzQ9Ed0FNkRJcereY+rM=",
"owner": "NixOS",
"repo": "nixpkgs",
"rev": "1a341e3c908f4a3105e737bd13af0318dc06fbe3",
"rev": "01b1b3809cfa3b0fbca8d75c9cf4b2efdb8182cf",
"type": "github"
},
"original": {
@@ -107,11 +107,11 @@
]
},
"locked": {
"lastModified": 1754869408,
"narHash": "sha256-G1zNuxiCDfqNQVoL9j5v+ZYfUER7AI158ev98/JC8LI=",
"lastModified": 1755555503,
"narHash": "sha256-WiOO7GUOsJ4/DoMy2IC5InnqRDSo2U11la48vCCIjjY=",
"owner": "NuschtOS",
"repo": "search",
"rev": "2f5478267557a0f7a70d953b6c0867a5b4282739",
"rev": "6f3efef888b92e6520f10eae15b86ff537e1d2ea",
"type": "github"
},
"original": {

View File

@@ -40,6 +40,7 @@ writeShellScriptBin "deploy-docs" ''
rsync \
--checksum \
--delete \
-e "ssh -o StrictHostKeyChecking=no $sshExtraArgs" \
-a ${docs}/ \
www@clan.lol:/var/www/docs.clan.lol

View File

@@ -18,27 +18,8 @@
inherit (self) clanModules;
clan-core = self;
inherit pkgs;
evalClanModules = self.clanLib.evalClan.evalClanModules;
modulesRolesOptions = self.clanLib.evalClan.evalClanModulesWithRoles {
allModules = self.clanModules;
inherit pkgs;
clan-core = self;
};
};
# Frontmatter for clanModules
clanModulesFrontmatter =
let
docs = pkgs.nixosOptionsDoc {
options = self.clanLib.modules.frontmatterOptions;
transformOptions = self.clanLib.docs.stripStorePathsFromDeclarations;
};
in
docs.optionsJSON;
# Options available when imported via ` inventory.${moduleName}....${rolesName} `
clanModulesViaRoles = pkgs.writeText "info.json" (builtins.toJSON jsonDocs.clanModulesViaRoles);
# clan service options
clanModulesViaService = pkgs.writeText "info.json" (builtins.toJSON jsonDocs.clanModulesViaService);
@@ -88,12 +69,10 @@
}
}
export CLAN_CORE_DOCS=${jsonDocs.clanCore}/share/doc/nixos/options.json
# A file that contains the links to all clanModule docs
export CLAN_MODULES_VIA_ROLES=${clanModulesViaRoles}
export CLAN_MODULES_VIA_SERVICE=${clanModulesViaService}
export CLAN_SERVICE_INTERFACE=${self'.legacyPackages.clan-service-module-interface}/share/doc/nixos/options.json
# Frontmatter format for clanModules
export CLAN_MODULES_FRONTMATTER_DOCS=${clanModulesFrontmatter}/share/doc/nixos/options.json
export BUILD_CLAN_PATH=${buildClanOptions}/share/doc/nixos/options.json
@@ -107,7 +86,6 @@
legacyPackages = {
inherit
jsonDocs
clanModulesViaRoles
clanModulesViaService
;
};

View File

@@ -1,7 +1,5 @@
{
modulesRolesOptions,
nixosOptionsDoc,
evalClanModules,
lib,
pkgs,
clan-core,
@@ -10,21 +8,36 @@
let
inherit (clan-core.clanLib.docs) stripStorePathsFromDeclarations;
transformOptions = stripStorePathsFromDeclarations;
nixosConfigurationWithClan =
let
evaled = lib.evalModules {
class = "nixos";
modules = [
# Basemodule
(
{ config, ... }:
{
imports = (import (pkgs.path + "/nixos/modules/module-list.nix"));
nixpkgs.pkgs = pkgs;
clan.core.name = "dummy";
system.stateVersion = config.system.nixos.release;
# Set this to work around a bug where `clan.core.settings.machine.name`
# is forced due to `networking.interfaces` being forced
# somewhere in the nixpkgs options
facter.detected.dhcp.enable = lib.mkForce false;
}
)
{
clan.core.settings.directory = clan-core;
}
clan-core.nixosModules.clanCore
];
};
in
evaled;
in
{
clanModulesViaRoles = lib.mapAttrs (
_moduleName: rolesOptions:
lib.mapAttrs (
_roleName: options:
(nixosOptionsDoc {
inherit options;
warningsAreErrors = true;
inherit transformOptions;
}).optionsJSON
) rolesOptions
) modulesRolesOptions;
# Test with:
# nix build .\#legacyPackages.x86_64-linux.clanModulesViaService
clanModulesViaService = lib.mapAttrs (
@@ -38,7 +51,6 @@ in
{
roles = lib.mapAttrs (
_roleName: role:
(nixosOptionsDoc {
transformOptions =
opt:
@@ -54,20 +66,13 @@ in
warningsAreErrors = true;
}).optionsJSON
) evaluatedService.config.roles;
manifest = evaluatedService.config.manifest;
}
) clan-core.clan.modules;
clanCore =
(nixosOptionsDoc {
options =
((evalClanModules {
modules = [ ];
inherit pkgs clan-core;
}).options
).clan.core or { };
options = nixosConfigurationWithClan.options.clan.core;
warningsAreErrors = true;
inherit transformOptions;
}).optionsJSON;

View File

@@ -33,22 +33,13 @@ from clan_lib.errors import ClanError
from clan_lib.services.modules import (
CategoryInfo,
Frontmatter,
extract_frontmatter,
get_roles,
)
# Get environment variables
CLAN_CORE_PATH = Path(os.environ["CLAN_CORE_PATH"])
CLAN_CORE_DOCS = Path(os.environ["CLAN_CORE_DOCS"])
CLAN_MODULES_FRONTMATTER_DOCS = os.environ.get("CLAN_MODULES_FRONTMATTER_DOCS")
BUILD_CLAN_PATH = os.environ.get("BUILD_CLAN_PATH")
## Clan modules ##
# Some modules can be imported via nix natively
CLAN_MODULES_VIA_NIX = os.environ.get("CLAN_MODULES_VIA_NIX")
# Some modules can be imported via inventory
CLAN_MODULES_VIA_ROLES = os.environ.get("CLAN_MODULES_VIA_ROLES")
# Options how to author clan.modules
# perInstance, perMachine, ...
CLAN_SERVICE_INTERFACE = os.environ.get("CLAN_SERVICE_INTERFACE")
@@ -190,23 +181,6 @@ def module_header(module_name: str, has_inventory_feature: bool = False) -> str:
return f"# {module_name}{indicator}\n\n"
def module_nix_usage(module_name: str) -> str:
return f"""## Usage via Nix
**This module can be also imported directly in your nixos configuration. Although it is recommended to use the [inventory](../../concepts/inventory.md) interface if available.**
Some modules are considered 'low-level' or 'expert modules' and are not available via the inventory interface.
```nix
{{config, lib, inputs, ...}}: {{
imports = [ inputs.clan-core.clanModules.{module_name} ];
# ...
}}
```
"""
clan_core_descr = """
`clan.core` is always present in a clan machine
@@ -223,68 +197,6 @@ The following options are available for this module.
"""
def produce_clan_modules_frontmatter_docs() -> None:
if not CLAN_MODULES_FRONTMATTER_DOCS:
msg = f"Environment variables are not set correctly: $CLAN_CORE_DOCS={CLAN_CORE_DOCS}"
raise ClanError(msg)
if not OUT:
msg = f"Environment variables are not set correctly: $out={OUT}"
raise ClanError(msg)
with Path(CLAN_MODULES_FRONTMATTER_DOCS).open() as f:
options: dict[str, dict[str, Any]] = json.load(f)
# header
output = """# Frontmatter
Every clan module has a `frontmatter` section within its readme. It provides
machine readable metadata about the module.
!!! example
The used format is `TOML`
The content is separated by `---` and the frontmatter must be placed at the very top of the `README.md` file.
```toml
---
description = "A description of the module"
categories = ["category1", "category2"]
[constraints]
roles.client.max = 10
roles.server.min = 1
---
# Readme content
...
```
"""
output += """## Overview
This provides an overview of the available attributes of the `frontmatter`
within the `README.md` of a clan module.
"""
# for option_name, info in options.items():
# if option_name == "_module.args":
# continue
# output += render_option(option_name, info)
root = options_to_tree(options, debug=True)
for option in root.suboptions:
output += options_docs_from_tree(option, init_level=2)
outfile = Path(OUT) / "clanModules/frontmatter/index.md"
outfile.parent.mkdir(
parents=True,
exist_ok=True,
)
with outfile.open("w") as of:
of.write(output)
def produce_clan_core_docs() -> None:
if not CLAN_CORE_DOCS:
msg = f"Environment variables are not set correctly: $CLAN_CORE_DOCS={CLAN_CORE_DOCS}"
@@ -505,154 +417,6 @@ Learn how to use `clanServices` in practice in the [Using clanServices guide](..
of.write(output)
def produce_clan_modules_docs() -> None:
if not CLAN_MODULES_VIA_NIX:
msg = f"Environment variables are not set correctly: $CLAN_MODULES_VIA_NIX={CLAN_MODULES_VIA_NIX}"
raise ClanError(msg)
if not CLAN_MODULES_VIA_ROLES:
msg = f"Environment variables are not set correctly: $CLAN_MODULES_VIA_ROLES={CLAN_MODULES_VIA_ROLES}"
raise ClanError(msg)
if not CLAN_CORE_PATH:
msg = f"Environment variables are not set correctly: $CLAN_CORE_PATH={CLAN_CORE_PATH}"
raise ClanError(msg)
if not OUT:
msg = f"Environment variables are not set correctly: $out={OUT}"
raise ClanError(msg)
modules_index = "# Modules Overview\n\n"
modules_index += clan_modules_descr
modules_index += "## Overview\n\n"
modules_index += '<div class="grid cards" markdown>\n\n'
with Path(CLAN_MODULES_VIA_ROLES).open() as f2:
role_links: dict[str, dict[str, str]] = json.load(f2)
with Path(CLAN_MODULES_VIA_NIX).open() as f:
links: dict[str, str] = json.load(f)
for module_name, options_file in links.items():
print(f"Rendering ClanModule: {module_name}")
readme_file = CLAN_CORE_PATH / "clanModules" / module_name / "README.md"
with readme_file.open() as f:
readme = f.read()
frontmatter: Frontmatter
frontmatter, readme_content = extract_frontmatter(readme, str(readme_file))
# skip if experimental feature enabled
if "experimental" in frontmatter.features:
print(f"Skipping {module_name}: Experimental feature")
continue
modules_index += build_option_card(module_name, frontmatter)
##### Print module documentation #####
# 1. Header
output = module_header(module_name, "inventory" in frontmatter.features)
# 2. Description from README.md
if frontmatter.description:
output += f"*{frontmatter.description}*\n\n"
# 2. Deprecation note if the module is deprecated
if "deprecated" in frontmatter.features:
output += f"""
!!! Warning "Deprecated"
The `{module_name}` module is deprecated.*
Use 'clanServices/{module_name}' or a similar successor instead
"""
else:
output += f"""
!!! Warning "Will be deprecated"
The `{module_name}` module might eventually be migrated to 'clanServices'*
See: [clanServices](../../guides/clanServices.md)
"""
# 3. Categories from README.md
output += "## Categories\n\n"
output += render_categories(frontmatter.categories, frontmatter.categories_info)
output += "\n---\n\n"
# 3. README.md content
output += f"{readme_content}\n"
# 4. Usage
##### Print usage via Inventory #####
# get_roles(str) -> list[str] | None
# if not isinstance(options_file, str):
roles = get_roles(CLAN_CORE_PATH / "clanModules" / module_name)
if roles:
# Render inventory usage
output += """## Usage via Inventory\n\n"""
output += render_roles(roles, module_name)
for role in roles:
role_options_file = role_links[module_name][role]
# Abort if the options file is not found
if not isinstance(role_options_file, str):
print(
f"Error: module: {module_name} in role: {role} - options file not found, Got {role_options_file}"
)
exit(1)
no_options = f"""### Options of `{role}` role
**The `{module_name}` `{role}` doesnt offer / require any options to be set.**
"""
heading = f"""### Options of `{role}` role
The following options are available when using the `{role}` role.
"""
output += print_options(
role_options_file,
heading,
no_options,
replace_prefix=f"clan.{module_name}",
)
else:
# No roles means no inventory usage
output += """## Usage via Inventory
**This module cannot be used via the inventory interface.**
"""
##### Print usage via Nix / nixos #####
if not isinstance(options_file, str):
print(
f"Skipping {module_name}: Cannot be used via import clanModules.{module_name}"
)
output += """## Usage via Nix
**This module cannot be imported directly in your nixos configuration.**
"""
else:
output += module_nix_usage(module_name)
no_options = "** This module doesnt require any options to be set.**"
output += print_options(options_file, options_head, no_options)
outfile = Path(OUT) / f"clanModules/{module_name}.md"
outfile.parent.mkdir(
parents=True,
exist_ok=True,
)
with outfile.open("w") as of:
of.write(output)
modules_index += "</div>"
modules_index += "\n"
modules_outfile = Path(OUT) / "clanModules/index.md"
with modules_outfile.open("w") as of:
of.write(modules_index)
def build_option_card(module_name: str, frontmatter: Frontmatter) -> str:
"""
Build the overview index card for each reference target option.
@@ -863,8 +627,4 @@ if __name__ == "__main__": #
produce_clan_core_docs()
produce_clan_service_author_docs()
# produce_clan_modules_docs()
produce_clan_service_docs()
# produce_clan_modules_frontmatter_docs()

View File

@@ -90,13 +90,10 @@ export CLAN_DEBUG_COMMANDS=1
These options help you pinpoint the source and context of print messages and debug logs during development.
## Analyzing Performance
To understand what's causing slow performance, set the environment variable `export CLAN_CLI_PERF=1`. When you complete a clan command, you'll see a summary of various performance metrics, helping you identify what's taking up time.
## See all possible packages and tests
To quickly show all possible packages and tests execute:
@@ -155,28 +152,16 @@ To test the CLI locally in a development environment and set breakpoints for deb
## Test Locally in a Nix Sandbox
To run tests in a Nix sandbox, you have two options depending on whether your test functions have been marked as impure or not:
### Running Tests Marked as Impure
If your test functions need to execute `nix build` and have been marked as impure because you can't execute `nix build` inside a Nix sandbox, use the following command:
To run tests in a Nix sandbox:
```bash
nix run .#impure-checks -L
nix build .#checks.x86_64-linux.clan-pytest-with-core
```
This command will run the impure test functions.
### Running Pure Tests
For test functions that have not been marked as impure and don't require executing `nix build`, you can use the following command:
```bash
nix build .#checks.x86_64-linux.clan-pytest --rebuild
nix build .#checks.x86_64-linux.clan-pytest-without-core
```
This command will run all pure test functions.
### Inspecting the Nix Sandbox
If you need to inspect the Nix sandbox while running tests, follow these steps:

View File

@@ -1,110 +1,129 @@
# :material-clock-fast: Getting Started
Ready to create your own Clan and manage a fleet of machines? Follow these simple steps to get started.
Ready to manage your fleet of machines?
This guide walks your through setting up your own declarative infrastructure using clan, git and flakes. By the end of this, you will have one or more machines integrated and installed. You can then import your existing NixOS configuration into this setup if you wish.
We will create a declarative infrastructure using **clan**, **git**, and **nix flakes**.
The following steps are meant to be executed on the machine on which to administer the infrastructure.
In order to get started you should have at least one machine with either physical or ssh access available as an installation target. Your local machine can also be used as an installation target if it is already running NixOS.
You'll finish with a centrally managed fleet, ready to import your existing NixOS configuration.
## Prerequisites
=== "**Linux**"
Make sure you have the following:
Clan requires Nix to be installed on your system. Run the following command to install Nix:
* 💻 **Administration Machine**: Run the setup commands from this machine.
* 🛠️ **Nix**: The Nix package manager, installed on your administration machine.
??? info "**How to install Nix (Linux / MacOS / NixOS)**"
**On Linux or macOS:**
1. Run the recommended installer:
```shellSession
curl --proto '=https' --tlsv1.2 -sSf -L [https://install.determinate.systems/nix](https://install.determinate.systems/nix) | sh -s -- install
```
2. After installation, ensure flakes are enabled by adding this line to `~/.config/nix/nix.conf`:
```
experimental-features = nix-command flakes
```
**On NixOS:**
Nix is already installed. You only need to enable flakes for your user in your `configuration.nix`:
```nix
{
nix.settings.experimental-features = [ "nix-command" "flakes" ];
}
```
Then, run `nixos-rebuild switch` to apply the changes.
* 🎯 **Target Machine(s)**: A remote machine with SSH, or your local machine (if NixOS).
## Create a New Clan
1. Navigate to your desired directory:
```shellSession
cd <your-directory>
```
2. Create a new clan flake:
**Note:** This creates a new directory in your current location
```shellSession
curl --proto '=https' --tlsv1.2 -sSf -L https://install.determinate.systems/nix | sh -s -- install
nix run https://git.clan.lol/clan/clan-core/archive/main.tar.gz#clan-cli --refresh -- flakes create
```
If you have previously installed Nix, make sure `experimental-features = nix-command flakes` is present in `~/.config/nix/nix.conf` or `/etc/nix/nix.conf`. If this is not the case, please add it to `~/.config/nix/nix.conf`.
3. Enter a **name** in the prompt:
=== "**NixOS**"
If you run NixOS the `nix` binary is already installed.
You will also need to enable the `nix-command` and `flakes` experimental features in your `configuration.nix`:
```nix
{ nix.settings.experimental-features = [ "nix-command" "flakes" ]; }
```terminalSession
Enter a name for the new clan: my-clan
```
=== "**macOS**"
## Project Structure
Clan requires Nix to be installed on your system. Run the following command to install Nix:
Your new directory, `my-clan`, should contain the following structure:
```shellSession
curl --proto '=https' --tlsv1.2 -sSf -L https://install.determinate.systems/nix | sh -s -- install
```
```
my-clan/
├── clan.nix
├── flake.lock
├── flake.nix
├── modules/
└── sops/
```
If you have previously installed Nix, make sure `experimental-features = nix-command flakes` is present in `~/.config/nix/nix.conf` or `/etc/nix/nix.conf`. If this is not the case, please add it to `~/.config/nix/nix.conf`.
!!! note "Templates"
This is the structure for the `default` template.
## Create a new clan
Use `clan templates list` and `clan templates --help` for available templates & more. Keep in mind that the exact files may change as templates evolve.
Initialize a new clan flake
## Activate the Environment
To get started, `cd` into your new project directory.
```shellSession
nix run https://git.clan.lol/clan/clan-core/archive/main.tar.gz#clan-cli --refresh -- flakes create
cd my-clan
```
This should prompt for a *name*:
```terminalSession
Enter a name for the new clan: my-clan
```
Enter a *name*, confirm with *enter*. A directory with that name will be created and initialized.
!!! Note
This command uses the `default` template
See `clan templates list` and the `--help` reference for how to use other templates.
## Explore the Project Structure
Take a look at all project files:
For example, you might see something like:
```{ .console .no-copy }
$ cd my-clan
$ ls
clan.nix flake.lock flake.nix modules sops
```
Dont worry if your output looks different — Clan templates evolve over time.
To interact with your newly created clan the you need to load the `clan` cli-package it into your environment by running:
Now, activate the environment using one of the following methods.
=== "Automatic (direnv, recommended)"
- prerequisite: [install nix-direnv](https://github.com/nix-community/nix-direnv)
**Prerequisite**: You must have [nix-direnv](https://github.com/nix-community/nix-direnv) installed.
Run `direnv allow` to automatically load the environment whenever you enter this directory.
```shellSession
direnv allow
```
=== "Manual (nix develop)"
Run nix develop to load the environment for your current shell session.
```shellSession
nix develop
```
verify that you can run `clan` commands:
## Verify the Setup
Once your environment is active, verify that the clan command is available by running:
```shellSession
clan show
```
You should see something like this:
You should see the default metadata for your new clan:
```shellSession
Name: __CHANGE_ME__
Description: None
```
To change the name of your clan edit `meta.name` in the `clan.nix` or `flake.nix` file
This confirms your setup is working correctly.
You can now change the default name by editing the `meta.name` field in your `clan.nix` file.
```{.nix title="clan.nix" hl_lines="3"}
{

24
flake.lock generated
View File

@@ -31,11 +31,11 @@
]
},
"locked": {
"lastModified": 1754971456,
"narHash": "sha256-p04ZnIBGzerSyiY2dNGmookCldhldWAu03y0s3P8CB0=",
"lastModified": 1755519972,
"narHash": "sha256-bU4nqi3IpsUZJeyS8Jk85ytlX61i4b0KCxXX9YcOgVc=",
"owner": "nix-community",
"repo": "disko",
"rev": "8246829f2e675a46919718f9a64b71afe3bfb22d",
"rev": "4073ff2f481f9ef3501678ff479ed81402caae6d",
"type": "github"
},
"original": {
@@ -71,11 +71,11 @@
]
},
"locked": {
"lastModified": 1751313918,
"narHash": "sha256-HsJM3XLa43WpG+665aGEh8iS8AfEwOIQWk3Mke3e7nk=",
"lastModified": 1755275010,
"narHash": "sha256-lEApCoWUEWh0Ifc3k1JdVjpMtFFXeL2gG1qvBnoRc2I=",
"owner": "nix-darwin",
"repo": "nix-darwin",
"rev": "e04a388232d9a6ba56967ce5b53a8a6f713cdfcf",
"rev": "7220b01d679e93ede8d7b25d6f392855b81dd475",
"type": "github"
},
"original": {
@@ -99,11 +99,11 @@
},
"nixos-facter-modules": {
"locked": {
"lastModified": 1750412875,
"narHash": "sha256-uP9Xxw5XcFwjX9lNoYRpybOnIIe1BHfZu5vJnnPg3Jc=",
"lastModified": 1755504238,
"narHash": "sha256-mw7q5DPdmz/1au8mY0u1DztRgVyJToGJfJszxjKSNes=",
"owner": "nix-community",
"repo": "nixos-facter-modules",
"rev": "14df13c84552a7d1f33c1cd18336128fbc43f920",
"rev": "354ed498c9628f32383c3bf5b6668a17cdd72a28",
"type": "github"
},
"original": {
@@ -115,10 +115,10 @@
"nixpkgs": {
"locked": {
"lastModified": 315532800,
"narHash": "sha256-2ILJtWugqmMyZnaWnHh+5yyw8RZWbKu9rVdeWmrBVhY=",
"rev": "a595dde4d0d31606e19dcec73db02279db59d201",
"narHash": "sha256-h8Sx4S+/0FpodZji6W9lHzwY5BcuUG85Aj3GfhvGC2o=",
"rev": "a650b5d0de99158323597f048667c4d914243224",
"type": "tarball",
"url": "https://releases.nixos.org/nixpkgs/nixpkgs-25.11pre844295.a595dde4d0d3/nixexprs.tar.xz"
"url": "https://releases.nixos.org/nixpkgs/nixpkgs-25.11pre845298.a650b5d0de99/nixexprs.tar.xz"
},
"original": {
"type": "tarball",

View File

@@ -67,7 +67,6 @@
clan = {
meta.name = "clan-core";
inventory = {
services = { };
machines = {
"test-darwin-machine" = {
machineClass = "darwin";
@@ -97,6 +96,7 @@
./nixosModules/flake-module.nix
./pkgs/flake-module.nix
./templates/flake-module.nix
./pkgs/clan-cli/clan_cli/tests/flake-module.nix
]
++ [
(if pathExists ./flakeModules/clan.nix then import ./flakeModules/clan.nix inputs.self else { })

View File

@@ -33,7 +33,6 @@ lib.fix (
evalService = clanLib.callLib ./modules/inventory/distributed-service/evalService.nix { };
# ------------------------------------
# ClanLib functions
evalClan = clanLib.callLib ./modules/inventory/eval-clan-modules { };
inventory = clanLib.callLib ./modules/inventory { };
modules = clanLib.callLib ./modules/inventory/frontmatter { };
test = clanLib.callLib ./test { };

View File

@@ -1,108 +0,0 @@
{
lib,
clanLib,
}:
let
baseModule =
{ pkgs }:
# Module
{ config, ... }:
{
imports = (import (pkgs.path + "/nixos/modules/module-list.nix"));
nixpkgs.pkgs = pkgs;
clan.core.name = "dummy";
system.stateVersion = config.system.nixos.release;
# Set this to work around a bug where `clan.core.settings.machine.name`
# is forced due to `networking.interfaces` being forced
# somewhere in the nixpkgs options
facter.detected.dhcp.enable = lib.mkForce false;
};
# This function takes a list of module names and evaluates them
# [ module ] -> { config, options, ... }
evalClanModulesLegacy =
{
modules,
pkgs,
clan-core,
}:
let
evaled = lib.evalModules {
class = "nixos";
modules = [
(baseModule { inherit pkgs; })
{
clan.core.settings.directory = clan-core;
}
clan-core.nixosModules.clanCore
]
++ modules;
};
in
# lib.warn ''
# doesn't respect role specific interfaces.
# The following {module}/default.nix file trying to be imported.
# Modules: ${builtins.toJSON modulenames}
# This might result in incomplete or incorrect interfaces.
# FIX: Use evalClanModuleWithRole instead.
# ''
evaled;
/*
This function takes a list of module names and evaluates them
Returns a set of interfaces as described below:
Fn :: { ${moduleName} = Module; } -> {
${moduleName} :: {
${roleName}: JSONSchema
}
}
*/
evalClanModulesWithRoles =
{
allModules,
clan-core,
pkgs,
}:
let
res = builtins.mapAttrs (
moduleName: module:
let
frontmatter = clanLib.modules.getFrontmatter allModules.${moduleName} moduleName;
roles =
if builtins.elem "inventory" frontmatter.features or [ ] then
assert lib.isPath module;
clan-core.clanLib.modules.getRoles "Documentation: inventory.modules" allModules moduleName
else
[ ];
in
lib.listToAttrs (
lib.map (role: {
name = role;
value =
(lib.evalModules {
class = "nixos";
modules = [
(baseModule { inherit pkgs; })
clan-core.nixosModules.clanCore
{
clan.core.settings.directory = clan-core;
}
# Role interface
(module + "/roles/${role}.nix")
];
}).options.clan.${moduleName} or { };
}) roles
)
) allModules;
in
res;
in
{
evalClanModules = evalClanModulesLegacy;
inherit evalClanModulesWithRoles;
}

View File

@@ -1,12 +1,8 @@
{
self,
inputs,
options,
...
}:
let
inputOverrides = self.clanLib.flake-inputs.getOverrides inputs;
in
{
imports = [
./distributed-service/flake-module.nix
@@ -15,16 +11,13 @@ in
{
pkgs,
lib,
config,
system,
self',
...
}:
{
devShells.inventory-schema = pkgs.mkShell {
name = "clan-inventory-schema";
inputsFrom = with config.checks; [
eval-lib-inventory
inputsFrom = [
self'.devShells.default
];
};
@@ -51,41 +44,5 @@ in
warningsAreErrors = true;
transformOptions = self.clanLib.docs.stripStorePathsFromDeclarations;
}).optionsJSON;
# Run: nix-unit --extra-experimental-features flakes --flake .#legacyPackages.x86_64-linux.evalTests
legacyPackages.evalTests-inventory = import ./tests {
inherit lib;
clan-core = self;
inherit (self) clanLib;
inherit (self.inputs) nix-darwin;
};
checks = {
eval-lib-inventory = pkgs.runCommand "tests" { nativeBuildInputs = [ pkgs.nix-unit ]; } ''
export HOME="$(realpath .)"
export NIX_ABORT_ON_WARN=1
nix-unit --eval-store "$HOME" \
--extra-experimental-features flakes \
--show-trace \
${inputOverrides} \
--flake ${
lib.fileset.toSource {
root = ../../..;
fileset = lib.fileset.unions [
../../../flake.nix
../../../flake.lock
(lib.fileset.fileFilter (file: file.name == "flake-module.nix") ../../..)
../../../flakeModules
../../../lib
../../../nixosModules/clanCore
../../../machines
../../../inventory.json
];
}
}#legacyPackages.${system}.evalTests-inventory
touch $out
'';
};
};
}

View File

@@ -3,51 +3,6 @@ let
# Trim the .nix extension from a filename
trimExtension = name: builtins.substring 0 (builtins.stringLength name - 4) name;
jsonWithoutHeader = clanLib.jsonschema {
includeDefaults = true;
header = { };
};
getModulesSchema =
{
modules,
clan-core,
pkgs,
}:
lib.mapAttrs
(
_moduleName: rolesOptions:
lib.mapAttrs (_roleName: options: jsonWithoutHeader.parseOptions options { }) rolesOptions
)
(
clanLib.evalClan.evalClanModulesWithRoles {
allModules = modules;
inherit pkgs clan-core;
}
);
evalFrontmatter =
{
moduleName,
instanceName,
resolvedRoles,
allModules,
}:
lib.evalModules {
modules = [
(getFrontmatter allModules.${moduleName} moduleName)
./interface.nix
{
constraints.imports = [
(lib.modules.importApply ../constraints {
inherit moduleName resolvedRoles instanceName;
allRoles = getRoles "inventory.modules" allModules moduleName;
})
];
}
];
};
# For Documentation purposes only
frontmatterOptions =
(lib.evalModules {
@@ -119,17 +74,12 @@ let
builtins.readDir (checkedPath)
)
);
checkConstraints = args: (evalFrontmatter args).config.constraints.assertions;
getFrontmatter = _modulepath: _modulename: "clanModules are removed!";
in
{
inherit
frontmatterOptions
getModulesSchema
getFrontmatter
checkConstraints
getRoles
;
}

View File

@@ -1,29 +1,14 @@
{
self,
self',
lib,
pkgs,
flakeOptions,
...
}:
let
modulesSchema = self.clanLib.modules.getModulesSchema {
modules = self.clanModules;
inherit pkgs;
clan-core = self;
};
jsonLib = self.clanLib.jsonschema { inherit includeDefaults; };
includeDefaults = true;
frontMatterSchema = jsonLib.parseOptions self.clanLib.modules.frontmatterOptions { };
inventorySchema = jsonLib.parseModule ({
imports = [ ../../inventoryClass/interface.nix ];
_module.args = { inherit (self) clanLib; };
});
opts = (flakeOptions.flake.type.getSubOptions [ "flake" ]);
clanOpts = opts.clan.type.getSubOptions [ "clan" ];
include = [
@@ -38,13 +23,6 @@ let
];
clanSchema = jsonLib.parseOptions (lib.filterAttrs (n: _v: lib.elem n include) clanOpts) { };
renderSchema = pkgs.writers.writePython3Bin "render-schema" {
flakeIgnore = [
"F401"
"E501"
];
} ./render_schema.py;
clan-schema-abstract = pkgs.stdenv.mkDerivation {
name = "clan-schema-files";
buildInputs = [ pkgs.cue ];
@@ -63,29 +41,7 @@ in
{
inherit
flakeOptions
frontMatterSchema
clanSchema
inventorySchema
modulesSchema
renderSchema
clan-schema-abstract
;
# Inventory schema, with the modules schema added per role
inventory =
pkgs.runCommand "rendered"
{
buildInputs = [
pkgs.python3
self'.packages.clan-cli
];
}
''
export INVENTORY_SCHEMA_PATH=${builtins.toFile "inventory-schema.json" (builtins.toJSON inventorySchema)}
export MODULES_SCHEMA_PATH=${builtins.toFile "modules-schema.json" (builtins.toJSON modulesSchema)}
mkdir $out
# The python script will place the schemas in the output directory
exec python3 ${renderSchema}/bin/render-schema
'';
}

View File

@@ -1,162 +0,0 @@
"""
Python script to join the abstract inventory schema, with the concrete clan modules
Inventory has slots which are 'Any' type.
We dont want to evaluate the clanModules interface in nix, when evaluating the inventory
"""
import json
import os
from pathlib import Path
from typing import Any
from clan_lib.errors import ClanError
# Get environment variables
INVENTORY_SCHEMA_PATH = Path(os.environ["INVENTORY_SCHEMA_PATH"])
# { [moduleName] :: { [roleName] :: SCHEMA }}
MODULES_SCHEMA_PATH = Path(os.environ["MODULES_SCHEMA_PATH"])
OUT = os.environ.get("out")
if not INVENTORY_SCHEMA_PATH:
msg = f"Environment variables are not set correctly: INVENTORY_SCHEMA_PATH={INVENTORY_SCHEMA_PATH}."
raise ClanError(msg)
if not MODULES_SCHEMA_PATH:
msg = f"Environment variables are not set correctly: MODULES_SCHEMA_PATH={MODULES_SCHEMA_PATH}."
raise ClanError(msg)
if not OUT:
msg = f"Environment variables are not set correctly: OUT={OUT}."
raise ClanError(msg)
def service_roles_to_schema(
schema: dict[str, Any],
service_name: str,
roles: list[str],
roles_schemas: dict[str, dict[str, Any]],
# Original service properties: {'config': Schema, 'machines': Schema, 'meta': Schema, 'extraModules': Schema, ...?}
orig: dict[str, Any],
) -> dict[str, Any]:
"""
Add roles to the service schema
"""
# collect all the roles for the service, to form a type union
all_roles_schema: list[dict[str, Any]] = []
for role_name, role_schema in roles_schemas.items():
role_schema["title"] = f"{module_name}-config-role-{role_name}"
all_roles_schema.append(role_schema)
role_schema = {}
for role in roles:
role_schema[role] = {
"type": "object",
"additionalProperties": False,
"properties": {
**orig["roles"]["additionalProperties"]["properties"],
"config": {
**roles_schemas.get(role, {}),
"title": f"{service_name}-config-role-{role}",
"type": "object",
"default": {},
"additionalProperties": False,
},
},
}
machines_schema = {
"type": "object",
"additionalProperties": {
"type": "object",
"properties": {
**orig["machines"]["additionalProperties"]["properties"],
"config": {
"title": f"{service_name}-config",
"oneOf": all_roles_schema,
"type": "object",
"default": {},
"additionalProperties": False,
},
},
},
}
services["properties"][service_name] = {
"type": "object",
"additionalProperties": {
"type": "object",
"additionalProperties": False,
"properties": {
# Original inventory schema
**orig,
# Inject the roles schemas
"roles": {
"title": f"{service_name}-roles",
"type": "object",
"properties": role_schema,
"additionalProperties": False,
},
"machines": machines_schema,
"config": {
"title": f"{service_name}-config",
"oneOf": all_roles_schema,
"type": "object",
"default": {},
"additionalProperties": False,
},
},
},
}
return schema
if __name__ == "__main__":
print("Joining inventory schema with modules schema")
print(f"Inventory schema path: {INVENTORY_SCHEMA_PATH}")
print(f"Modules schema path: {MODULES_SCHEMA_PATH}")
modules_schema = {}
with Path.open(MODULES_SCHEMA_PATH) as f:
modules_schema = json.load(f)
inventory_schema = {}
with Path.open(INVENTORY_SCHEMA_PATH) as f:
inventory_schema = json.load(f)
services = inventory_schema["properties"]["services"]
original_service_props = services["additionalProperties"]["additionalProperties"][
"properties"
].copy()
# Init the outer services schema
# Properties (service names) will be filled in the next step
services = {
"type": "object",
"properties": {
# Service names
},
"additionalProperties": False,
}
for module_name, roles_schemas in modules_schema.items():
# Add the roles schemas to the service schema
roles = list(roles_schemas.keys())
if roles:
services = service_roles_to_schema(
services,
module_name,
roles,
roles_schemas,
original_service_props,
)
inventory_schema["properties"]["services"] = services
outpath = Path(OUT)
with (outpath / "schema.json").open("w") as f:
json.dump(inventory_schema, f, indent=2)
with (outpath / "modules_schemas.json").open("w") as f:
json.dump(modules_schema, f, indent=2)

View File

@@ -1,90 +0,0 @@
{
clan-core,
nix-darwin,
lib,
clanLib,
}:
let
# TODO: Unify these tests with clan tests
clan =
m:
lib.evalModules {
specialArgs = { inherit clan-core nix-darwin clanLib; };
modules = [
clan-core.modules.clan.default
{
self = { };
}
m
];
};
in
{
test_inventory_a =
let
eval = clan {
inventory = {
machines = {
A = { };
};
services = {
legacyModule = { };
};
modules = {
legacyModule = ./legacyModule;
};
};
directory = ./.;
};
in
{
inherit eval;
expr = {
legacyModule = lib.filterAttrs (
name: _: name == "isClanModule"
) eval.config.clanInternals.inventoryClass.machines.A.compiledServices.legacyModule;
};
expected = {
legacyModule = {
};
};
};
test_inventory_empty =
let
eval = clan {
inventory = { };
directory = ./.;
};
in
{
# Empty inventory should return an empty module
expr = eval.config.clanInternals.inventoryClass.machines;
expected = { };
};
test_inventory_module_doesnt_exist =
let
eval = clan {
directory = ./.;
inventory = {
services = {
fanatasy.instance_1 = {
roles.default.machines = [ "machine_1" ];
};
};
machines = {
"machine_1" = { };
};
};
};
in
{
inherit eval;
expr = eval.config.clanInternals.inventoryClass.machines.machine_1.machineImports;
expectedError = {
type = "ThrownError";
msg = "ClanModule not found*";
};
};
}

View File

@@ -1,4 +0,0 @@
---
features = [ "inventory" ]
---
Description

View File

@@ -1,9 +0,0 @@
{
lib,
clan-core,
...
}:
{
# Just some random stuff
options.test = lib.mapAttrs clan-core;
}

View File

@@ -1,78 +0,0 @@
# Integrity validation of the inventory
{ config, lib, ... }:
{
# Assertion must be of type
# { assertion :: bool, message :: string, severity :: "error" | "warning" }
imports = [
# Check that each machine used in a service is defined in the top-level machines
{
assertions = lib.foldlAttrs (
ass1: serviceName: c:
ass1
++ lib.foldlAttrs (
ass2: instanceName: instanceConfig:
let
topLevelMachines = lib.attrNames config.machines;
# All machines must be defined in the top-level machines
assertions = lib.foldlAttrs (
assertions: roleName: role:
assertions
++ builtins.filter (a: !a.assertion) (
builtins.map (m: {
assertion = builtins.elem m topLevelMachines;
message = ''
Machine '${m}' is not defined in the inventory. This might still work, if the machine is defined via nix.
Defined in service: '${serviceName}' instance: '${instanceName}' role: '${roleName}'.
Inventory machines:
${builtins.concatStringsSep "\n" (map (n: "'${n}'") topLevelMachines)}
'';
severity = "warning";
}) role.machines
)
) [ ] instanceConfig.roles;
in
ass2 ++ assertions
) [ ] c
) [ ] config.services;
}
# Check that each tag used in a role is defined in at least one machines tags
{
assertions = lib.foldlAttrs (
ass1: serviceName: c:
ass1
++ lib.foldlAttrs (
ass2: instanceName: instanceConfig:
let
allTags = lib.foldlAttrs (
tags: _machineName: machine:
tags ++ machine.tags
) [ ] config.machines;
# All machines must be defined in the top-level machines
assertions = lib.foldlAttrs (
assertions: roleName: role:
assertions
++ builtins.filter (a: !a.assertion) (
builtins.map (m: {
assertion = builtins.elem m allTags;
message = ''
Tag '${m}' is not defined in the inventory.
Defined in service: '${serviceName}' instance: '${instanceName}' role: '${roleName}'.
Available tags:
${builtins.concatStringsSep "\n" (map (n: "'${n}'") allTags)}
'';
severity = "error";
}) role.tags
)
) [ ] instanceConfig.roles;
in
ass2 ++ assertions
) [ ] c
) [ ] config.services;
}
];
}

View File

@@ -1,268 +1,5 @@
{
lib,
config,
clanLib,
...
}:
let
inherit (config) inventory directory;
resolveTags =
# Inventory, { machines :: [string], tags :: [string] }
{
serviceName,
instanceName,
roleName,
inventory,
members,
}:
{
machines =
members.machines or [ ]
++ (builtins.foldl' (
acc: tag:
let
# For error printing
availableTags = lib.foldlAttrs (
acc: _: v:
v.tags or [ ] ++ acc
) [ ] (inventory.machines);
tagMembers = builtins.attrNames (
lib.filterAttrs (_n: v: builtins.elem tag v.tags or [ ]) inventory.machines
);
in
if tagMembers == [ ] then
lib.warn ''
inventory.services.${serviceName}.${instanceName}: - ${roleName} tags: no machine with tag '${tag}' found.
Available tags: ${builtins.toJSON (lib.unique availableTags)}
'' [ ]
else
acc ++ tagMembers
) [ ] members.tags or [ ]);
};
checkService =
modulepath: serviceName:
builtins.elem "inventory" (clanLib.modules.getFrontmatter modulepath serviceName).features or [ ];
compileMachine =
{ machineConfig }:
{
machineImports = [
(lib.optionalAttrs (machineConfig.deploy.targetHost or null != null) {
config.clan.core.networking.targetHost = lib.mkForce machineConfig.deploy.targetHost;
})
(lib.optionalAttrs (machineConfig.deploy.buildHost or null != null) {
config.clan.core.networking.buildHost = lib.mkForce machineConfig.deploy.buildHost;
})
];
assertions = { };
};
resolveImports =
{
supportedRoles,
resolvedRolesPerInstance,
serviceConfigs,
serviceName,
machineName,
getRoleFile,
}:
(lib.foldlAttrs (
# : [ Modules ] -> String -> ServiceConfig -> [ Modules ]
acc2: instanceName: serviceConfig:
let
resolvedRoles = resolvedRolesPerInstance.${instanceName};
isInService = builtins.any (members: builtins.elem machineName members.machines) (
builtins.attrValues resolvedRoles
);
# all roles where the machine is present
machineRoles = builtins.attrNames (
lib.filterAttrs (_role: roleConfig: builtins.elem machineName roleConfig.machines) resolvedRoles
);
machineServiceConfig = (serviceConfig.machines.${machineName} or { }).config or { };
globalConfig = serviceConfig.config or { };
globalExtraModules = serviceConfig.extraModules or [ ];
machineExtraModules = serviceConfig.machines.${machineName}.extraModules or [ ];
roleServiceExtraModules = builtins.foldl' (
acc: role: acc ++ serviceConfig.roles.${role}.extraModules or [ ]
) [ ] machineRoles;
# TODO: maybe optimize this don't lookup the role in inverse roles. Imports are not lazy
roleModules = builtins.map (
role:
if builtins.elem role supportedRoles && inventory.modules ? ${serviceName} then
getRoleFile role
else
throw "Module ${serviceName} doesn't have role: '${role}'. Role: ${
inventory.modules.${serviceName}
}/roles/${role}.nix not found."
) machineRoles;
roleServiceConfigs = builtins.filter (m: m != { }) (
builtins.map (role: serviceConfig.roles.${role}.config or { }) machineRoles
);
extraModules = map (s: if builtins.typeOf s == "string" then "${directory}/${s}" else s) (
globalExtraModules ++ machineExtraModules ++ roleServiceExtraModules
);
features =
(clanLib.modules.getFrontmatter inventory.modules.${serviceName} serviceName).features or [ ];
deprecationWarning = lib.optionalAttrs (builtins.elem "deprecated" features) {
warnings = [
''
The '${serviceName}' module has been migrated from `inventory.services` to `inventory.instances`
See https://docs.clan.lol/guides/clanServices/ for usage.
''
];
};
in
if !(serviceConfig.enabled or true) then
acc2
else if isInService then
acc2
++ [
deprecationWarning
{
imports = roleModules ++ extraModules;
clan.inventory.services.${serviceName}.${instanceName} = {
roles = resolvedRoles;
# TODO: Add inverseRoles to the service config if needed
# inherit inverseRoles;
};
}
(lib.optionalAttrs (globalConfig != { } || machineServiceConfig != { } || roleServiceConfigs != [ ])
{
clan.${serviceName} = lib.mkMerge (
[
globalConfig
machineServiceConfig
]
++ roleServiceConfigs
);
}
)
]
else
acc2
) [ ] (serviceConfigs));
in
{
imports = [
./interface.nix
];
config = {
machines = builtins.mapAttrs (
machineName: machineConfig: m:
let
compiledServices = lib.mapAttrs (
_: serviceConfigs:
(
{ config, ... }:
let
serviceName = config.serviceName;
getRoleFile = role: builtins.seq role inventory.modules.${serviceName} + "/roles/${role}.nix";
in
{
_file = "inventory/builder.nix";
_module.args = {
inherit
resolveTags
inventory
clanLib
machineName
serviceConfigs
;
};
imports = [
./roles.nix
];
machineImports = resolveImports {
supportedRoles = config.supportedRoles;
resolvedRolesPerInstance = config.resolvedRolesPerInstance;
inherit
serviceConfigs
serviceName
machineName
getRoleFile
;
};
# Assertions
assertions = {
"checkservice.${serviceName}" = {
assertion = checkService inventory.modules.${serviceName} serviceName;
message = ''
Service ${serviceName} cannot be used in inventory. It does not declare the 'inventory' feature.
To allow it add the following to the beginning of the README.md of the module:
---
...
features = [ "inventory" ]
---
Also make sure to test the module with the 'inventory' feature enabled.
'';
};
};
}
)
) (config.inventory.services or { });
compiledMachine = compileMachine {
inherit
machineConfig
;
};
machineImports = (
compiledMachine.machineImports
++ builtins.foldl' (
acc: service:
let
failedAssertions = (lib.filterAttrs (_: v: !v.assertion) service.assertions);
failedAssertionsImports =
if failedAssertions != { } then
[
{
clan.inventory.assertions = failedAssertions;
}
]
else
[
{
clan.inventory.assertions = {
"alive.assertion.inventory" = {
assertion = true;
message = ''
No failed assertions found for machine ${machineName}. This will never be displayed.
It is here for testing purposes.
'';
};
};
}
];
in
acc
++ service.machineImports
# Import failed assertions
++ failedAssertionsImports
) [ ] (builtins.attrValues m.config.compiledServices)
);
in
{
inherit machineImports compiledServices compiledMachine;
}
) (inventory.machines or { });
};
}

View File

@@ -16,76 +16,13 @@ in
type = types.raw;
};
machines = mkOption {
type = types.attrsOf (
submodule (
{ name, ... }:
let
machineName = name;
in
{
options = {
compiledMachine = mkOption {
type = types.raw;
};
compiledServices = mkOption {
# type = types.attrsOf;
type = types.attrsOf (
types.submoduleWith {
modules = [
(
{ name, ... }:
let
serviceName = name;
in
{
options = {
machineName = mkOption {
default = machineName;
readOnly = true;
};
serviceName = mkOption {
default = serviceName;
readOnly = true;
};
# Outputs
machineImports = mkOption {
type = types.listOf types.raw;
};
supportedRoles = mkOption {
type = types.listOf types.str;
};
matchedRoles = mkOption {
type = types.listOf types.str;
};
machinesRoles = mkOption {
type = types.attrsOf (types.listOf types.str);
};
resolvedRolesPerInstance = mkOption {
type = types.attrsOf (
types.attrsOf (submodule {
options.machines = mkOption {
type = types.listOf types.str;
};
})
);
};
assertions = mkOption {
type = types.attrsOf types.raw;
};
};
}
)
];
}
);
};
machineImports = mkOption {
type = types.listOf types.raw;
};
};
}
)
);
type = types.attrsOf (submodule ({
options = {
machineImports = mkOption {
type = types.listOf types.raw;
};
};
}));
};
};
}

View File

@@ -1,65 +0,0 @@
{
lib,
config,
resolveTags,
inventory,
clanLib,
machineName,
serviceConfigs,
...
}:
let
serviceName = config.serviceName;
in
{
# Roles resolution
# : List String
supportedRoles = clanLib.modules.getRoles "inventory.modules" inventory.modules serviceName;
matchedRoles = builtins.attrNames (
lib.filterAttrs (_: ms: builtins.elem machineName ms) config.machinesRoles
);
resolvedRolesPerInstance = lib.mapAttrs (
instanceName: instanceConfig:
let
resolvedRoles = lib.genAttrs config.supportedRoles (
roleName:
resolveTags {
members = instanceConfig.roles.${roleName} or { };
inherit
instanceName
serviceName
roleName
inventory
;
}
);
usedRoles = builtins.attrNames instanceConfig.roles;
unmatchedRoles = builtins.filter (role: !builtins.elem role config.supportedRoles) usedRoles;
in
if unmatchedRoles != [ ] then
throw ''
Roles ${builtins.toJSON unmatchedRoles} are not defined in the service ${serviceName}.
Instance: '${instanceName}'
Please use one of available roles: ${builtins.toJSON config.supportedRoles}
''
else
resolvedRoles
) serviceConfigs;
machinesRoles = builtins.zipAttrsWith (
_n: vs:
let
flat = builtins.foldl' (acc: s: acc ++ s.machines) [ ] vs;
in
lib.unique flat
) (builtins.attrValues config.resolvedRolesPerInstance);
assertions = lib.concatMapAttrs (
instanceName: resolvedRoles:
clanLib.modules.checkConstraints {
moduleName = serviceName;
allModules = inventory.modules;
inherit resolvedRoles instanceName;
}
) config.resolvedRolesPerInstance;
}

View File

@@ -31,70 +31,13 @@ let
'';
};
};
moduleConfig = lib.mkOption {
default = { };
# TODO: use types.deferredModule
# clan.borgbackup MUST be defined as submodule
type = types.attrsOf types.anything;
description = ''
Configuration of the specific clanModule.
!!! Note
Configuration is passed to the nixos configuration scoped to the module.
```nix
clan.<serviceName> = { ... # Config }
```
'';
};
extraModulesOption = lib.mkOption {
description = ''
List of additionally imported `.nix` expressions.
Supported types:
- **Strings**: Interpreted relative to the 'directory' passed to `lib.clan`.
- **Paths**: should be relative to the current file.
- **Any**: Nix expression must be serializable to JSON.
!!! Note
**The import only happens if the machine is part of the service or role.**
Other types are passed through to the nixos configuration.
???+ Example
To import the `special.nix` file
```
. Clan Directory
flake.nix
...
modules
special.nix
...
```
```nix
{
extraModules = [ "modules/special.nix" ];
}
```
'';
apply = value: if lib.isString value then value else builtins.seq (builtins.toJSON value) value;
default = [ ];
type = types.listOf (
types.oneOf [
types.str
types.anything
]
);
};
in
{
imports = [
./assertions.nix
(lib.mkRemovedOptionModule [ "services" ] ''
The `inventory.services` option has been removed. Use `inventory.instances` instead.
See: https://docs.clan.lol/concepts/inventory/#services
'')
];
options = {
# Internal things
@@ -312,6 +255,16 @@ in
'';
};
installedAt = lib.mkOption {
type = types.nullOr types.int;
default = null;
description = ''
Indicates when the machine was first installed.
Timestamp is in unix time (seconds since epoch).
'';
};
tags = lib.mkOption {
description = ''
List of tags for the machine.
@@ -415,160 +368,5 @@ in
);
default = { };
};
services = lib.mkOption {
# TODO: deprecate these options
# services are deprecated in favor of `instances`
# visible = false;
description = ''
Services of the inventory.
- The first `<name>` is the moduleName. It must be a valid clanModule name.
- The second `<name>` is an arbitrary instance name.
???+ Example
```nix
# ClanModule name. See the module documentation for the available modules.
# Instance name, can be anything, some services might use it as a unique identifier.
services.borgbackup."instance_1" = {
roles.client.machines = ["machineA"];
};
```
!!! Note
Services MUST be added to machines via `roles` exclusively.
See [`roles.<rolename>.machines`](#inventory.services.roles.machines) or [`roles.<rolename>.tags`](#inventory.services.roles.tags) for more information.
'';
default = { };
type = types.attrsOf (
types.attrsOf (
types.submodule (
# instance name
{ name, ... }:
{
options.enabled = lib.mkOption {
type = lib.types.bool;
default = true;
description = ''
Enable or disable the complete service.
If the service is disabled, it will not be added to any machine.
!!! Note
This flag is primarily used to temporarily disable a service.
I.e. A 'backup service' without any 'server' might be incomplete and would cause failure if enabled.
'';
};
options.meta = metaOptionsWith name;
options.extraModules = extraModulesOption;
options.config = moduleConfig // {
description = ''
Configuration of the specific clanModule.
!!! Note
Configuration is passed to the nixos configuration scoped to the module.
```nix
clan.<serviceName> = { ... # Config }
```
???+ Example
For `services.borgbackup` the config is the passed to the machine with the prefix of `clan.borgbackup`.
This means all config values are mapped to the `borgbackup` clanModule exclusively (`config.clan.borgbackup`).
```nix
{
services.borgbackup."instance_1".config = {
destinations = [ ... ];
# See the 'borgbackup' module docs for all options
};
}
```
!!! Note
The module author is responsible for supporting multiple instance configurations in different roles.
See each clanModule's documentation for more information.
'';
};
options.machines = lib.mkOption {
description = ''
Attribute set of machines specific config for the service.
Will be merged with other service configs, such as the role config and the global config.
For machine specific overrides use `mkForce` or other higher priority methods.
???+ Example
```{.nix hl_lines="4-7"}
services.borgbackup."instance_1" = {
roles.client.machines = ["machineA"];
machines.machineA.config = {
# Additional specific config for the machine
# This is merged with all other config places
};
};
```
'';
default = { };
type = types.attrsOf (
types.submodule {
options.extraModules = extraModulesOption;
options.config = moduleConfig // {
description = ''
Additional configuration of the specific machine.
See how [`service.<name>.<name>.config`](#inventory.services.config) works in general for further information.
'';
};
}
);
};
options.roles = lib.mkOption {
default = { };
type = types.attrsOf (
types.submodule {
options.machines = lib.mkOption {
default = [ ];
type = types.listOf types.str;
example = [ "machineA" ];
description = ''
List of machines which are part of the role.
The machines are referenced by their `attributeName` in the `inventory.machines` attribute set.
Memberships are declared here to determine which machines are part of the service.
Alternatively, `tags` can be used to determine the membership, more dynamically.
'';
};
options.tags = lib.mkOption {
default = [ ];
apply = lib.unique;
type = types.listOf types.str;
description = ''
List of tags which are used to determine the membership of the role.
The tags are matched against the `inventory.machines.<machineName>.tags` attribute set.
If a machine has at least one tag of the role, it is part of the role.
'';
};
options.config = moduleConfig // {
description = ''
Additional configuration of the specific role.
See how [`service.<name>.<name>.config`](#inventory.services.config) works in general for further information.
'';
};
options.extraModules = extraModulesOption;
}
);
};
}
)
)
);
};
};
}

View File

@@ -11,6 +11,10 @@
default =
builtins.removeAttrs (clanLib.introspection.getPrios { options = config.inventory.options; })
# tags are freeformType which is not supported yet.
[ "tags" ];
# services is removed and throws an error if accessed.
[
"tags"
"services"
];
};
}

View File

@@ -290,9 +290,11 @@ in
};
owner = mkOption {
description = "The user name or id that will own the file.";
type = str;
default = "root";
};
group = mkOption {
type = str;
description = "The group name or id that will own the file.";
default = if _class == "darwin" then "wheel" else "root";
defaultText = lib.literalExpression ''if _class == "darwin" then "wheel" else "root"'';

View File

@@ -0,0 +1,116 @@
# Standalone VM base module that can be imported independently
# This module contains the core VM configuration without the system extension
{
lib,
config,
pkgs,
modulesPath,
...
}:
let
# Flatten the list of state folders into a single list
stateFolders = lib.flatten (
lib.mapAttrsToList (_item: attrs: attrs.folders) config.clan.core.state
);
in
{
imports = [
(modulesPath + "/virtualisation/qemu-vm.nix")
./serial.nix
./waypipe.nix
];
clan.core.state.HOME.folders = [ "/home" ];
clan.services.waypipe = {
inherit (config.clan.core.vm.inspect.waypipe) enable command;
};
# required for issuing shell commands via qga
services.qemuGuest.enable = true;
# required to react to system_powerdown qmp command
# Some desktop managers like xfce override the poweroff signal and therefore
# make it impossible to handle it via 'logind' directly.
services.acpid.enable = true;
services.acpid.handlers.power.event = "button/power.*";
services.acpid.handlers.power.action = "poweroff";
# only works on x11
services.spice-vdagentd.enable = config.services.xserver.enable;
boot.initrd.systemd.enable = true;
boot.initrd.systemd.storePaths = [
pkgs.util-linux
pkgs.e2fsprogs
];
boot.initrd.systemd.emergencyAccess = true;
# userborn would be faster because it doesn't need perl, but it cannot create normal users
services.userborn.enable = true;
users.mutableUsers = false;
users.allowNoPasswordLogin = true;
boot.initrd.kernelModules = [ "virtiofs" ];
virtualisation.writableStore = false;
virtualisation.fileSystems = lib.mkForce (
{
"/nix/store" = {
device = "nix-store";
options = [
"x-systemd.requires=systemd-modules-load.service"
"ro"
];
fsType = "virtiofs";
};
"/" = {
device = "/dev/vda";
fsType = "ext4";
options = [
"defaults"
"x-systemd.makefs"
"nobarrier"
"noatime"
"nodiratime"
"data=writeback"
"discard"
];
};
"/vmstate" = {
device = "/dev/vdb";
options = [
"x-systemd.makefs"
"noatime"
"nodiratime"
"discard"
];
noCheck = true;
fsType = "ext4";
};
${config.clan.core.facts.secretUploadDirectory} = {
device = "secrets";
fsType = "9p";
neededForBoot = true;
options = [
"trans=virtio"
"version=9p2000.L"
"cache=loose"
];
};
}
// lib.listToAttrs (
map (
folder:
lib.nameValuePair folder {
device = "/vmstate${folder}";
fsType = "none";
options = [ "bind" ];
}
) stateFolders
)
);
}

View File

@@ -4,116 +4,11 @@
pkgs,
options,
extendModules,
modulesPath,
...
}:
let
# Flatten the list of state folders into a single list
stateFolders = lib.flatten (
lib.mapAttrsToList (_item: attrs: attrs.folders) config.clan.core.state
);
vmModule = {
imports = [
(modulesPath + "/virtualisation/qemu-vm.nix")
./serial.nix
./waypipe.nix
];
clan.core.state.HOME.folders = [ "/home" ];
clan.services.waypipe = {
inherit (config.clan.core.vm.inspect.waypipe) enable command;
};
# required for issuing shell commands via qga
services.qemuGuest.enable = true;
# required to react to system_powerdown qmp command
# Some desktop managers like xfce override the poweroff signal and therefore
# make it impossible to handle it via 'logind' directly.
services.acpid.enable = true;
services.acpid.handlers.power.event = "button/power.*";
services.acpid.handlers.power.action = "poweroff";
# only works on x11
services.spice-vdagentd.enable = config.services.xserver.enable;
boot.initrd.systemd.enable = true;
boot.initrd.systemd.storePaths = [
pkgs.util-linux
pkgs.e2fsprogs
];
boot.initrd.systemd.emergencyAccess = true;
# userborn would be faster because it doesn't need perl, but it cannot create normal users
services.userborn.enable = true;
users.mutableUsers = false;
users.allowNoPasswordLogin = true;
boot.initrd.kernelModules = [ "virtiofs" ];
virtualisation.writableStore = false;
virtualisation.fileSystems = lib.mkForce (
{
"/nix/store" = {
device = "nix-store";
options = [
"x-systemd.requires=systemd-modules-load.service"
"ro"
];
fsType = "virtiofs";
};
"/" = {
device = "/dev/vda";
fsType = "ext4";
options = [
"defaults"
"x-systemd.makefs"
"nobarrier"
"noatime"
"nodiratime"
"data=writeback"
"discard"
];
};
"/vmstate" = {
device = "/dev/vdb";
options = [
"x-systemd.makefs"
"noatime"
"nodiratime"
"discard"
];
noCheck = true;
fsType = "ext4";
};
${config.clan.core.facts.secretUploadDirectory} = {
device = "secrets";
fsType = "9p";
neededForBoot = true;
options = [
"trans=virtio"
"version=9p2000.L"
"cache=loose"
];
};
}
// lib.listToAttrs (
map (
folder:
lib.nameValuePair folder {
device = "/vmstate${folder}";
fsType = "none";
options = [ "bind" ];
}
) stateFolders
)
);
};
# Import the standalone VM base module
vmModule = import ./vm-base.nix;
# We cannot simply merge the VM config into the current system config, because
# it is not necessarily a VM.

View File

@@ -34,4 +34,7 @@ in
flake.nixosModules.clanCore = clanCore;
flake.darwinModules.clanCore = clanCore;
# Standalone VM base module that can be imported for VM testing
flake.nixosModules.clan-vm-base = ./clanCore/vm-base.nix;
}

View File

@@ -2,4 +2,5 @@ app/api
app/.fonts
.vite
storybook-static
storybook-static
*.css.d.ts

File diff suppressed because it is too large Load Diff

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.8 KiB

File diff suppressed because it is too large Load Diff

View File

@@ -57,7 +57,9 @@
"tailwindcss": "^3.4.3",
"typescript": "^5.4.5",
"typescript-eslint": "^8.32.1",
"typescript-plugin-css-modules": "^5.2.0",
"vite": "^6.3.5",
"vite-css-modules": "^1.10.0",
"vite-plugin-solid": "^2.8.2",
"vite-plugin-solid-svg": "^0.8.1",
"vitest": "^3.2.3",

View File

@@ -1,30 +1,21 @@
div.alert {
.alert {
@apply flex flex-row gap-2.5 p-4 rounded-md items-start;
&.has-icon {
&.hasIcon {
@apply pl-3;
svg.icon {
@apply relative top-0.5;
}
}
&.has-dismiss {
@apply pr-3;
}
& > button.dismiss-trigger {
&.hasIcon svg.icon {
@apply relative top-0.5;
}
& > div.content {
@apply flex flex-col size-full gap-1;
&.hasDismiss {
@apply pr-3;
}
&.info {
@apply bg-semantic-info-1 border border-semantic-info-3 fg-semantic-info-3;
}
&.error {
@apply bg-semantic-error-2 border border-semantic-error-3 fg-semantic-error-3;
}
@@ -38,6 +29,18 @@ div.alert {
}
&.transparent {
@apply bg-transparent border-none p-0;
@apply bg-transparent border-none;
}
&.noPadding {
@apply p-0;
}
}
.alertContent {
@apply flex flex-col size-full gap-1;
}
.dismissTrigger {
@apply relative top-0.5;
}

View File

@@ -1,10 +1,10 @@
import "./Alert.css";
import cx from "classnames";
import Icon, { IconVariant } from "@/src/components/Icon/Icon";
import { Typography } from "@/src/components/Typography/Typography";
import { Button } from "@kobalte/core/button";
import { Alert as KAlert } from "@kobalte/core/alert";
import { Show } from "solid-js";
import styles from "./Alert.module.css";
export interface AlertProps {
icon?: IconVariant;
@@ -13,6 +13,7 @@ export interface AlertProps {
title: string;
onDismiss?: () => void;
transparent?: boolean;
dense?: boolean;
description?: string;
}
@@ -24,16 +25,17 @@ export const Alert = (props: AlertProps) => {
return (
<KAlert
class={cx("alert", props.type, {
"has-icon": props.icon,
"has-dismiss": props.onDismiss,
transparent: props.transparent,
class={cx(styles.alert, styles[props.type], {
[styles.hasIcon]: props.icon,
[styles.hasDismiss]: props.onDismiss,
[styles.transparent]: props.transparent,
[styles.noPadding]: props.dense,
})}
>
{props.icon && (
<Icon icon={props.icon} color="inherit" size={iconSize()} />
)}
<div class="content">
<div class={styles.alertContent}>
<Typography
hierarchy="body"
family="condensed"
@@ -57,7 +59,7 @@ export const Alert = (props: AlertProps) => {
{props.onDismiss && (
<Button
name="dismiss-alert"
class="dismiss-trigger"
class={styles.dismissTrigger}
onClick={props.onDismiss}
aria-label={`Dismiss ${props.type} alert`}
>

View File

@@ -1,17 +1,12 @@
.button {
@apply flex gap-2 shrink-0 items-center justify-center;
@apply px-4 py-2;
height: theme(height.9);
border-radius: 3px;
@apply h-[2.125rem] px-4 py-2 rounded-[0.1875rem];
/* Add transition for smooth width animation */
transition: width 0.5s ease 0.1s;
&.s {
@apply px-3 py-1.5;
height: theme(height.7);
border-radius: 2px;
@apply h-[1.625rem] px-3 py-1.5 rounded-[0.125rem];
&:has(> .icon-start):has(> .label) {
@apply pl-2;
@@ -22,6 +17,18 @@
}
}
&.xs {
@apply h-[1.125rem] gap-0.5 p-2 rounded-[0.125rem];
&:has(> .icon-start):has(> .label) {
@apply pl-1.5;
}
&:has(> .icon-end):has(> .label) {
@apply pr-1.5;
}
}
&.primary {
@apply bg-inv-acc-4 fg-inv-1;
@apply border border-solid border-inv-4;

View File

@@ -8,7 +8,7 @@ const getCursorStyle = (el: Element) => window.getComputedStyle(el).cursor;
const ButtonExamples: Component<ButtonProps> = (props) => (
<>
<div class="grid w-fit grid-cols-4 gap-8">
<div class="grid w-fit grid-cols-6 gap-8">
<div>
<Button data-testid="default" {...props}>
Label
@@ -19,6 +19,11 @@ const ButtonExamples: Component<ButtonProps> = (props) => (
Label
</Button>
</div>
<div>
<Button data-testid="xsmall" size="xs" {...props}>
Label
</Button>
</div>
<div>
<Button data-testid="default-disabled" {...props} disabled={true}>
Disabled
@@ -35,6 +40,17 @@ const ButtonExamples: Component<ButtonProps> = (props) => (
</Button>
</div>
<div>
<Button
data-testid="xsmall-disabled"
{...props}
disabled={true}
size="xs"
>
Disabled
</Button>
</div>
<div>
<Button data-testid="default-start-icon" {...props} startIcon="Flash">
Label
@@ -50,6 +66,16 @@ const ButtonExamples: Component<ButtonProps> = (props) => (
Label
</Button>
</div>
<div>
<Button
data-testid="xsmall-start-icon"
{...props}
startIcon="Flash"
size="xs"
>
Label
</Button>
</div>
<div>
<Button
data-testid="default-disabled-start-icon"
@@ -72,6 +98,18 @@ const ButtonExamples: Component<ButtonProps> = (props) => (
</Button>
</div>
<div>
<Button
data-testid="xsmall-disabled-start-icon"
{...props}
startIcon="Flash"
size="xs"
disabled={true}
>
Disabled
</Button>
</div>
<div>
<Button data-testid="default-end-icon" {...props} endIcon="Flash">
Label
@@ -87,6 +125,16 @@ const ButtonExamples: Component<ButtonProps> = (props) => (
Label
</Button>
</div>
<div>
<Button
data-testid="xsmall-end-icon"
{...props}
endIcon="Flash"
size="xs"
>
Label
</Button>
</div>
<div>
<Button
data-testid="default-disabled-end-icon"
@@ -108,12 +156,27 @@ const ButtonExamples: Component<ButtonProps> = (props) => (
Disabled
</Button>
</div>
<div>
<Button
data-testid="xsmall-disabled-end-icon"
{...props}
endIcon="Flash"
size="xs"
disabled={true}
>
Disabled
</Button>
</div>
<div>
<Button data-testid="default-icon" {...props} icon="Flash" />
</div>
<div>
<Button data-testid="small-icon" {...props} icon="Flash" size="s" />
</div>
<div>
<Button data-testid="xsmall-icon" {...props} icon="Flash" size="xs" />
</div>
<div>
<Button
data-testid="default-disabled-icon"
@@ -131,6 +194,15 @@ const ButtonExamples: Component<ButtonProps> = (props) => (
size="s"
/>
</div>
<div>
<Button
data-testid="xsmall-disabled-icon"
{...props}
icon="Flash"
disabled={true}
size="xs"
/>
</div>
</div>
</>
);

View File

@@ -7,7 +7,7 @@ import "./Button.css";
import Icon, { IconVariant } from "@/src/components/Icon/Icon";
import { Loader } from "@/src/components/Loader/Loader";
export type Size = "default" | "s";
export type Size = "default" | "s" | "xs";
export type Hierarchy = "primary" | "secondary";
export type Action = () => Promise<void>;
@@ -28,6 +28,7 @@ export interface ButtonProps
const iconSizes: Record<Size, string> = {
default: "1rem",
s: "0.8125rem",
xs: "0.625rem",
};
export const Button = (props: ButtonProps) => {

View File

@@ -36,7 +36,7 @@ export interface LabelProps {
}
export const Label = (props: LabelProps) => {
const descriptionSize = () => (props.size == "default" ? "xs" : "xxs");
const descriptionSize = () => (props.size == "default" ? "s" : "xs");
return (
<Show when={props.label}>

View File

@@ -1,5 +1,5 @@
span.machine-status {
@apply flex items-center gap-1;
@apply flex items-center gap-1.5;
.indicator {
@apply w-1.5 h-1.5 rounded-full m-1.5;
@@ -13,7 +13,7 @@ span.machine-status {
background-color: theme(colors.fg.semantic.error.1);
}
&.installed > .indicator {
&.out-of-sync > .indicator {
background-color: theme(colors.fg.inv.3);
}
}

View File

@@ -20,27 +20,38 @@ export default meta;
type Story = StoryObj<MachineStatusProps>;
export const Loading: Story = {
args: {},
};
export const Online: Story = {
args: {
status: "Online",
status: "online",
},
};
export const Offline: Story = {
args: {
status: "Offline",
status: "offline",
},
};
export const Installed: Story = {
export const OutOfSync: Story = {
args: {
status: "Installed",
status: "out_of_sync",
},
};
export const NotInstalled: Story = {
args: {
status: "Not Installed",
status: "not_installed",
},
};
export const LoadingWithLabel: Story = {
args: {
...Loading.args,
label: true,
},
};
@@ -60,7 +71,7 @@ export const OfflineWithLabel: Story = {
export const InstalledWithLabel: Story = {
args: {
...Installed.args,
...OutOfSync.args,
label: true,
},
};

View File

@@ -2,41 +2,58 @@ import "./MachineStatus.css";
import { Badge } from "@kobalte/core/badge";
import cx from "classnames";
import { Show } from "solid-js";
import { Match, Show, Switch } from "solid-js";
import Icon from "../Icon/Icon";
import { Typography } from "@/src/components/Typography/Typography";
export type MachineStatus =
| "Online"
| "Offline"
| "Installed"
| "Not Installed";
import { MachineStatus as MachineStatusModel } from "@/src/hooks/queries";
import { Loader } from "../Loader/Loader";
export interface MachineStatusProps {
label?: boolean;
status: MachineStatus;
status?: MachineStatusModel;
}
export const MachineStatus = (props: MachineStatusProps) => (
<Badge
class={cx("machine-status", {
online: props.status == "Online",
offline: props.status == "Offline",
installed: props.status == "Installed",
"not-installed": props.status == "Not Installed",
})}
textValue={props.status}
>
{props.label && (
<Typography hierarchy="label" size="xs" weight="medium" inverted={true}>
{props.status}
</Typography>
)}
<Show
when={props.status == "Not Installed"}
fallback={<div class="indicator" />}
>
<Icon icon="Offline" inverted={true} />
</Show>
</Badge>
);
export const MachineStatus = (props: MachineStatusProps) => {
const status = () => props.status;
// remove the '_' from the enum
// we will use css transform in the typography component to capitalize
const statusText = () => props.status?.replaceAll("_", " ");
return (
<Switch>
<Match when={!status()}>
<Loader />
</Match>
<Match when={status()}>
<Badge
class={cx("machine-status", {
online: status() == "online",
offline: status() == "offline",
"out-of-sync": status() == "out_of_sync",
"not-installed": status() == "not_installed",
})}
textValue={status()}
>
{props.label && (
<Typography
hierarchy="label"
size="xs"
weight="medium"
inverted={true}
transform="capitalize"
>
{statusText()}
</Typography>
)}
<Show
when={status() != "not_installed"}
fallback={<Icon icon="Offline" inverted={true} />}
>
<div class="indicator" />
</Show>
</Badge>
</Match>
</Switch>
);
};

View File

@@ -35,3 +35,12 @@
.header_divider {
@apply bg-def-3 h-[6px] border-def-2 border-t-[1px];
}
.backdrop {
@apply absolute top-0 left-0 w-full h-svh flex justify-center items-center backdrop-blur-0 z-50;
-webkit-backdrop-filter: blur(4px);
}
.contentWrapper {
@apply absolute left-0 top-0 z-50 flex size-full items-center justify-center;
}

View File

@@ -1,7 +1,7 @@
import { TagProps } from "@/src/components/Tag/Tag";
import { Meta, StoryObj } from "@kachurun/storybook-solid";
import { fn } from "storybook/test";
import { Modal, ModalContext, ModalProps } from "@/src/components/Modal/Modal";
import { Modal, ModalProps } from "@/src/components/Modal/Modal";
import { Fieldset, FieldsetFieldProps } from "@/src/components/Form/Fieldset";
import { TextInput } from "@/src/components/Form/TextInput";
import { TextArea } from "@/src/components/Form/TextArea";
@@ -21,7 +21,7 @@ export const Default: Story = {
args: {
title: "Example Modal",
onClose: fn(),
children: ({ close }: ModalContext) => (
children: (
<form class="flex flex-col gap-5">
<Fieldset legend="General">
{(props: FieldsetFieldProps) => (

View File

@@ -1,4 +1,11 @@
import { Component, createSignal, JSX, Show } from "solid-js";
import {
Component,
JSX,
Show,
createContext,
createSignal,
useContext,
} from "solid-js";
import { Dialog as KDialog } from "@kobalte/core/dialog";
import styles from "./Modal.module.css";
import { Typography } from "../Typography/Typography";
@@ -6,66 +13,81 @@ import Icon from "../Icon/Icon";
import cx from "classnames";
import { Dynamic } from "solid-js/web";
export interface ModalContext {
close(): void;
}
export type ModalContextType = {
portalRef: HTMLDivElement;
};
const ModalContext = createContext<unknown>();
export const useModalContext = () => {
const context = useContext(ModalContext);
if (!context) {
return null;
}
return context as ModalContextType;
};
export interface ModalProps {
id?: string;
title: string;
onClose: () => void;
children: (ctx: ModalContext) => JSX.Element;
children: JSX.Element;
mount?: Node;
class?: string;
metaHeader?: Component;
disablePadding?: boolean;
open: boolean;
}
export const Modal = (props: ModalProps) => {
const [open, setOpen] = createSignal(true);
const [portalRef, setPortalRef] = createSignal<HTMLDivElement>();
return (
<KDialog id={props.id} open={open()} modal={true}>
<KDialog.Portal mount={props.mount}>
<KDialog.Content class={cx(styles.modal_content, props.class)}>
<div class={styles.modal_header}>
<Typography
class={styles.modal_title}
hierarchy="label"
family="mono"
size="xs"
>
{props.title}
</Typography>
<KDialog.CloseButton
onClick={() => {
setOpen(false);
props.onClose();
}}
>
<Icon icon="Close" size="0.75rem" />
</KDialog.CloseButton>
<Show when={props.open}>
<KDialog id={props.id} open={props.open} modal={true}>
<KDialog.Portal mount={props.mount}>
<div class={styles.backdrop} />
<div class={styles.contentWrapper}>
<KDialog.Content class={cx(styles.modal_content, props.class)}>
<div class={styles.modal_header}>
<Typography
class={styles.modal_title}
hierarchy="label"
family="mono"
size="xs"
>
{props.title}
</Typography>
<KDialog.CloseButton
onClick={() => {
props.onClose();
}}
>
<Icon icon="Close" size="0.75rem" />
</KDialog.CloseButton>
</div>
<Show when={props.metaHeader}>
{(metaHeader) => (
<>
<div class="flex h-9 items-center px-6 py-2 bg-def-1">
<Dynamic component={metaHeader()} />
</div>
<div class={styles.header_divider} />
</>
)}
</Show>
<div
class={styles.modal_body}
data-no-padding={props.disablePadding}
ref={setPortalRef}
>
<ModalContext.Provider value={{ portalRef: portalRef()! }}>
{props.children}
</ModalContext.Provider>
</div>
</KDialog.Content>
</div>
<Show when={props.metaHeader}>
{(metaHeader) => (
<>
<div class="flex h-9 items-center px-6 py-2 bg-def-1">
<Dynamic component={metaHeader()} />
</div>
<div class={styles.header_divider} />
</>
)}
</Show>
<div class={styles.modal_body} data-no-padding={props.disablePadding}>
{props.children({
close: () => {
setOpen(false);
props.onClose();
},
})}
</div>
</KDialog.Content>
</KDialog.Portal>
</KDialog>
</KDialog.Portal>
</KDialog>
</Show>
);
};

View File

@@ -60,11 +60,11 @@
/* Option elements (typically <li>) */
& [role="option"] {
@apply px-2 py-4 rounded-sm flex items-center gap-1 flex-shrink-0;
@apply w-full p-2 rounded-sm flex items-center gap-1 flex-shrink-0;
&[data-highlighted],
&:focus-visible {
@apply outline outline-1 outline-inv-2;
@apply outline outline-1 outline-inv-2 outline-offset-[-1px];
}
&:hover {
@@ -77,6 +77,10 @@
}
& [role="listbox"] {
width: var(--kb-popper-anchor-width);
overflow-x: hidden;
overflow-y: scroll;
&:focus-visible {
@apply outline-none;
}

View File

@@ -36,7 +36,10 @@ export const Default: Story = {
description: "Choose your favorite pet from the list",
},
options: [
{ value: "dog", label: "Doggy" },
{
value: "dog",
label: "DoggyDoggyDoggyDoggyDoggyDoggy DoggyDoggyDoggyDoggyDoggy",
},
{ value: "cat", label: "Catty" },
{ value: "fish", label: "Fishy" },
{ value: "bird", label: "Birdy" },

View File

@@ -6,6 +6,7 @@ import { createEffect, createSignal, JSX, Show, splitProps } from "solid-js";
import styles from "./Select.module.css";
import { Typography } from "../Typography/Typography";
import cx from "classnames";
import { useModalContext } from "../Modal/Modal";
export interface Option {
value: string;
@@ -17,6 +18,7 @@ export type SelectProps = {
// Kobalte Select props, for modular forms
name: string;
placeholder?: string | undefined;
noOptionsText?: string | undefined;
value: string | undefined;
error: string;
required?: boolean | undefined;
@@ -79,6 +81,13 @@ export const Select = (props: SelectProps) => {
setValue(options().find((option) => props.value === option.value));
});
const modalContext = useModalContext();
const defaultMount =
props.portalProps?.mount || modalContext?.portalRef || document.body;
createEffect(() => {
console.debug("Select component mounted at:", defaultMount);
});
return (
<KSelect
{...root}
@@ -100,9 +109,10 @@ export const Select = (props: SelectProps) => {
</KSelect.ItemIndicator>
<KSelect.ItemLabel>
<Typography
hierarchy="body"
size="xs"
hierarchy="label"
size="s"
weight="bold"
family="condensed"
class="flex w-full items-center"
>
{props.item.rawValue.label}
@@ -115,9 +125,10 @@ export const Select = (props: SelectProps) => {
when={!loading()}
fallback={
<Typography
hierarchy="body"
size="xs"
hierarchy="label"
size="s"
weight="bold"
family="condensed"
class="flex w-full items-center"
color="secondary"
>
@@ -125,14 +136,33 @@ export const Select = (props: SelectProps) => {
</Typography>
}
>
<Typography
hierarchy="body"
size="xs"
weight="bold"
class="flex w-full items-center"
<Show
when={options().length > 0}
fallback={
<Typography
hierarchy="label"
size="s"
weight="normal"
family="condensed"
class="flex w-full items-center"
color="secondary"
>
{props.noOptionsText || "No options available"}
</Typography>
}
>
{props.placeholder}
</Typography>
<Show when={props.placeholder}>
<Typography
hierarchy="label"
size="s"
weight="bold"
family="condensed"
class="flex w-full items-center"
>
{props.placeholder}
</Typography>
</Show>
</Show>
</Show>
}
>
@@ -152,9 +182,10 @@ export const Select = (props: SelectProps) => {
<KSelect.Value<Option>>
{(state) => (
<Typography
hierarchy="body"
size="xs"
hierarchy="label"
size="s"
weight="bold"
family="condensed"
class="flex w-full items-center"
>
{state.selectedOption().label}
@@ -170,12 +201,40 @@ export const Select = (props: SelectProps) => {
</KSelect.Icon>
</KSelect.Trigger>
</Orienter>
<KSelect.Portal {...props.portalProps}>
<KSelect.Portal mount={defaultMount} {...props.portalProps}>
<KSelect.Content
class={styles.options_content}
style={{ "--z-index": zIndex() }}
>
<KSelect.Listbox />
<KSelect.Listbox>
{() => (
<KSelect.Trigger
class={cx(styles.trigger)}
style={{ "--z-index": zIndex() }}
data-loading={loading() || undefined}
>
<KSelect.Value<Option>>
{(state) => (
<Typography
hierarchy="body"
size="xs"
weight="bold"
class="flex w-full items-center"
>
{state.selectedOption().label}
</Typography>
)}
</KSelect.Value>
<KSelect.Icon
as="button"
class={styles.icon}
data-loading={loading() || undefined}
>
<Icon icon="Expand" color="inherit" />
</KSelect.Icon>
</KSelect.Trigger>
)}
</KSelect.Listbox>
</KSelect.Content>
</KSelect.Portal>
{/* TODO: Display error next to the problem */}

View File

@@ -1,10 +1,7 @@
div.sidebar {
.sidebar {
@apply w-60 border-none z-10;
& > div.header {
}
& > div.body {
.body {
@apply pt-4 pb-3 px-2;
}
}

View File

@@ -1,6 +1,7 @@
import "./Sidebar.css";
import styles from "./Sidebar.module.css";
import { SidebarHeader } from "@/src/components/Sidebar/SidebarHeader";
import { SidebarBody } from "@/src/components/Sidebar/SidebarBody";
import cx from "classnames";
export interface LinkProps {
path: string;
@@ -13,16 +14,15 @@ export interface SectionProps {
}
export interface SidebarProps {
class?: string;
staticSections?: SectionProps[];
}
export const Sidebar = (props: SidebarProps) => {
return (
<>
<div class="sidebar">
<SidebarHeader />
<SidebarBody {...props} />
</div>
</>
<div class={cx(styles.sidebar, props.class)}>
<SidebarHeader />
<SidebarBody class={cx(styles.body)} {...props} />
</div>
);
};

View File

@@ -9,13 +9,13 @@ div.sidebar-body {
overflow-y: auto;
scrollbar-width: none;
scrollbar-color: theme(colors.primary.700) theme(colors.primary.600);
background: linear-gradient(
180deg,
theme(colors.bg.inv.1) 0%,
theme(colors.bg.inv.3) 100%
);
background:
linear-gradient(0deg, rgba(0, 0, 0, 0.1) 0%, rgba(0, 0, 0, 0.1) 100%),
linear-gradient(
180deg,
theme(colors.bg.inv.1) 0%,
theme(colors.bg.inv.3) 100%
);
@apply backdrop-blur-sm;
@@ -27,14 +27,12 @@ div.sidebar-body {
}
& > .item {
@apply py-3 px-1.5 bg-inv-3 rounded-md mb-4;
&:last-child {
@apply mb-0;
}
& > .header {
@apply flex mb-4 px-2;
@apply flex mb-2 px-2;
& > .trigger {
@apply inline-flex items-center justify-between w-full;
@@ -61,6 +59,8 @@ div.sidebar-body {
& > .content {
@apply overflow-hidden flex flex-col;
@apply py-3 px-1.5 bg-inv-4 rounded-md mb-4;
animation: slideAccordionUp 300ms cubic-bezier(0.87, 0, 0.13, 1);
&[data-expanded] {

View File

@@ -6,47 +6,53 @@ import { Typography } from "@/src/components/Typography/Typography";
import { For } from "solid-js";
import { MachineStatus } from "@/src/components/MachineStatus/MachineStatus";
import { buildMachinePath, useClanURI } from "@/src/hooks/clan";
import { useMachinesQuery } from "@/src/hooks/queries";
import { useMachinesQuery, useMachineStateQuery } from "@/src/hooks/queries";
import { SidebarProps } from "./Sidebar";
interface MachineProps {
clanURI: string;
machineID: string;
name: string;
status: MachineStatus;
serviceCount: number;
}
const MachineRoute = (props: MachineProps) => (
<A href={buildMachinePath(props.clanURI, props.machineID)}>
<div class="flex w-full flex-col gap-2">
<div class="flex flex-row items-center justify-between">
<Typography
hierarchy="label"
size="xs"
weight="bold"
color="primary"
inverted={true}
>
{props.name}
</Typography>
<MachineStatus status={props.status} />
const MachineRoute = (props: MachineProps) => {
const statusQuery = useMachineStateQuery(props.clanURI, props.machineID);
const status = () =>
statusQuery.isSuccess ? statusQuery.data.status : undefined;
return (
<A href={buildMachinePath(props.clanURI, props.machineID)}>
<div class="flex w-full flex-col gap-2">
<div class="flex flex-row items-center justify-between">
<Typography
hierarchy="label"
size="xs"
weight="bold"
color="primary"
inverted={true}
>
{props.name}
</Typography>
<MachineStatus status={status()} />
</div>
<div class="flex w-full flex-row items-center gap-1">
<Icon icon="Flash" size="0.75rem" inverted={true} color="tertiary" />
<Typography
hierarchy="label"
family="mono"
size="s"
inverted={true}
color="primary"
>
{props.serviceCount}
</Typography>
</div>
</div>
<div class="flex w-full flex-row items-center gap-1">
<Icon icon="Flash" size="0.75rem" inverted={true} color="tertiary" />
<Typography
hierarchy="label"
family="mono"
size="s"
inverted={true}
color="primary"
>
{props.serviceCount}
</Typography>
</div>
</div>
</A>
);
</A>
);
};
export const SidebarBody = (props: SidebarProps) => {
const clanURI = useClanURI();
@@ -96,7 +102,6 @@ export const SidebarBody = (props: SidebarProps) => {
clanURI={clanURI}
machineID={id}
name={machine.name || id}
status="Not Installed"
serviceCount={0}
/>
)}

View File

@@ -0,0 +1,7 @@
.machineStatus {
@apply flex flex-col gap-2 w-full;
.summary {
@apply flex flex-row justify-between items-center;
}
}

View File

@@ -0,0 +1,34 @@
import styles from "./SidebarMachineStatus.module.css";
import { Typography } from "@/src/components/Typography/Typography";
import { useMachineStateQuery } from "@/src/hooks/queries";
import { MachineStatus } from "@/src/components/MachineStatus/MachineStatus";
export interface SidebarMachineStatusProps {
class?: string;
clanURI: string;
machineName: string;
}
export const SidebarMachineStatus = (props: SidebarMachineStatusProps) => {
const query = useMachineStateQuery(props.clanURI, props.machineName);
return (
<div class={styles.machineStatus}>
<div class={styles.summary}>
<Typography
hierarchy="body"
size="xs"
weight="medium"
inverted={true}
color="tertiary"
>
Status
</Typography>
<MachineStatus
label
status={query.isSuccess ? query.data.status : undefined}
/>
</div>
</div>
);
};

View File

@@ -11,6 +11,7 @@ div.sidebar-pane {
animation: sidebarPaneHide 250ms ease-out 300ms forwards;
& > div.header > *,
& > div.sub-header > *,
& > div.body > * {
animation: sidebarFadeOut 250ms ease-out forwards;
}
@@ -35,6 +36,25 @@ div.sidebar-pane {
}
}
& > div.sub-header {
@apply px-3 py-1;
@apply border-b-[1px] border-b-bg-inv-4;
@apply border-r-[1px] border-r-bg-inv-3 border-l-[1px] border-l-bg-inv-3;
background:
linear-gradient(0deg, rgba(0, 0, 0, 0.08) 0%, rgba(0, 0, 0, 0.08) 100%),
linear-gradient(
90deg,
theme(colors.bg.inv.3) 0%,
theme(colors.bg.inv.4) 100%
);
& > * {
@apply opacity-0;
animation: sidebarFadeIn 250ms ease-in 250ms forwards;
}
}
& > div.body {
@apply flex flex-col gap-4 px-2 pt-4 pb-3 w-full h-full;
@apply backdrop-blur-md;

View File

@@ -1,4 +1,4 @@
import { createSignal, JSX, onMount } from "solid-js";
import { createSignal, JSX, onMount, Show } from "solid-js";
import "./SidebarPane.css";
import { Typography } from "@/src/components/Typography/Typography";
import Icon from "../Icon/Icon";
@@ -6,8 +6,10 @@ import { Button as KButton } from "@kobalte/core/button";
import cx from "classnames";
export interface SidebarPaneProps {
class?: string;
title: string;
onClose: () => void;
subHeader?: () => JSX.Element;
children: JSX.Element;
}
@@ -26,7 +28,12 @@ export const SidebarPane = (props: SidebarPaneProps) => {
});
return (
<div class={cx("sidebar-pane", { closing: closing(), open: open() })}>
<div
class={cx("sidebar-pane", props.class, {
closing: closing(),
open: open(),
})}
>
<div class="header">
<Typography hierarchy="body" size="s" weight="bold" inverted={true}>
{props.title}
@@ -35,6 +42,9 @@ export const SidebarPane = (props: SidebarPaneProps) => {
<Icon icon="Close" color="primary" size="0.75rem" inverted={true} />
</KButton>
</div>
<Show when={props.subHeader}>
<div class="sub-header">{props.subHeader!()}</div>
</Show>
<div class="body">{props.children}</div>
</div>
);

View File

@@ -0,0 +1,3 @@
.install {
@apply flex flex-col gap-4 w-full justify-center items-center;
}

View File

@@ -0,0 +1,41 @@
import { createSignal, Show } from "solid-js";
import { Button } from "@/src/components/Button/Button";
import { InstallModal } from "@/src/workflows/Install/install";
import { useMachineName } from "@/src/hooks/clan";
import { useMachineStateQuery } from "@/src/hooks/queries";
import styles from "./SidebarSectionInstall.module.css";
import { Alert } from "../Alert/Alert";
export interface SidebarSectionInstallProps {
clanURI: string;
machineName: string;
}
export const SidebarSectionInstall = (props: SidebarSectionInstallProps) => {
const query = useMachineStateQuery(props.clanURI, props.machineName);
const [showInstall, setShowModal] = createSignal(false);
return (
<Show when={query.isSuccess && query.data.status == "not_installed"}>
<div class={styles.install}>
<Alert
type="warning"
size="s"
title="Your machine is not installed yet"
description="Start the process by clicking the button below."
></Alert>
<Button hierarchy="primary" size="s" onClick={() => setShowModal(true)}>
Install machine
</Button>
<Show when={showInstall()}>
<InstallModal
open={showInstall()}
machineName={useMachineName()}
onClose={() => setShowModal(false)}
/>
</Show>
</div>
</Show>
);
};

View File

@@ -1,5 +1,5 @@
span.tag {
@apply flex items-center gap-1 w-fit px-2 py-1 rounded-full;
@apply flex items-center gap-1 w-fit px-2 py-[0.1875rem] rounded-full;
@apply bg-def-4;
&:focus-visible {

View File

@@ -28,25 +28,25 @@
&.size-default {
font-size: 1rem;
line-height: 1.32;
letter-spacing: 0.02rem;
letter-spacing: 0.005rem;
}
&.size-s {
font-size: 0.875rem;
line-height: 1.32;
letter-spacing: 0.0175rem;
letter-spacing: 0.00875rem;
}
&.size-xs {
font-size: 0.75rem;
font-size: 0.8125rem;
line-height: 1.32;
letter-spacing: 0.0225rem;
letter-spacing: 0.01625rem;
}
&.size-xxs {
font-size: 0.6875rem;
font-size: 0.75rem;
line-height: 1.32;
letter-spacing: 0.00688rem;
letter-spacing: 0.015rem;
}
}
@@ -55,27 +55,21 @@
font-family: "Archivo SemiCondensed", sans-serif;
&.size-default {
font-size: 0.875rem;
line-height: 1;
letter-spacing: 0.0175rem;
font-size: 1rem;
line-height: normal;
letter-spacing: 0.02rem;
}
&.size-s {
font-size: 0.8125rem;
line-height: 1;
font-size: 0.875rem;
line-height: normal;
letter-spacing: 0.0175rem;
}
&.size-xs {
font-size: 0.75rem;
line-height: 1;
letter-spacing: 0.0075rem;
}
&.size-xxs {
font-size: 0.6875rem;
line-height: 1;
letter-spacing: normal;
font-size: 0.8125rem;
line-height: normal;
letter-spacing: 0.008125rem;
}
}
@@ -83,20 +77,20 @@
font-family: "Commit Mono", monospace;
&.size-default {
font-size: 0.8125rem;
line-height: 1;
font-size: 1rem;
line-height: normal;
letter-spacing: normal;
}
&.size-s {
font-size: 0.75rem;
line-height: 1;
font-size: 0.875rem;
line-height: normal;
letter-spacing: normal;
}
&.size-xs {
font-size: 0.6875rem;
line-height: 1;
font-size: 0.8125rem;
line-height: normal;
letter-spacing: normal;
}
}

View File

@@ -1,17 +1,10 @@
import { API } from "@/api/API";
import { Schema as Inventory } from "@/api/Inventory";
export type OperationNames = keyof API;
type Services = NonNullable<Inventory["services"]>;
type ServiceNames = keyof Services;
export type OperationArgs<T extends OperationNames> = API[T]["arguments"];
export type OperationResponse<T extends OperationNames> = API[T]["return"];
export type ClanServiceInstance<T extends ServiceNames> = NonNullable<
Services[T]
>[string];
export type SuccessQuery<T extends OperationNames> = Extract<
OperationResponse<T>,
{ status: "success" }

View File

@@ -8,6 +8,10 @@ export type ClanDetailsWithURI = ClanDetails & { uri: string };
export type Tags = SuccessData<"list_tags">;
export type Machine = SuccessData<"get_machine">;
export type MachineState = SuccessData<"get_machine_state">;
export type MachineStatus = MachineState["status"];
export type ListMachines = SuccessData<"list_machines">;
export type MachineDetails = SuccessData<"get_machine_details">;
@@ -22,6 +26,11 @@ export type ClanListQueryResult = UseQueryResult<ClanDetailsWithURI>[];
export const useMachinesQuery = (clanURI: string) => {
const client = useApiClient();
if (!clanURI) {
throw new Error("useMachinesQuery: clanURI is undefined");
}
return useQuery<ListMachines>(() => ({
queryKey: ["clans", encodeBase64(clanURI), "machines"],
queryFn: async () => {
@@ -94,6 +103,33 @@ export const useMachineQuery = (clanURI: string, machineName: string) => {
}));
};
export const useMachineStateQuery = (clanURI: string, machineName: string) => {
const client = useApiClient();
return useQuery<MachineState>(() => ({
queryKey: ["clans", encodeBase64(clanURI), "machine", machineName, "state"],
refetchInterval: 1000 * 60, // poll every 60 seconds
queryFn: async () => {
const apiCall = client.fetch("get_machine_state", {
machine: {
name: machineName,
flake: {
identifier: clanURI,
},
},
});
const result = await apiCall.result;
if (result.status === "error") {
throw new Error(
"Error fetching machine status: " + result.errors[0].message,
);
}
return result.data;
},
}));
};
export const useMachineDetailsQuery = (
clanURI: string,
machineName: string,

View File

@@ -1,24 +0,0 @@
.fade-out {
opacity: 0;
transition: opacity 0.5s ease;
}
.create-backdrop {
@apply absolute top-0 left-0 w-full h-svh flex justify-center items-center backdrop-blur-0 z-50;
-webkit-backdrop-filter: blur(4px);
}
.create-modal {
@apply min-w-96;
}
.sidebar-container {
}
div.sidebar {
@apply absolute top-10 bottom-20 left-4 w-60;
}
div.sidebar-pane {
@apply absolute top-12 bottom-20 left-[16.5rem] w-64;
}

View File

@@ -0,0 +1,15 @@
.fadeOut {
opacity: 0;
transition: opacity 0.5s ease;
}
.createModal {
@apply min-w-96;
}
.sidebar {
@apply absolute left-4 top-10 w-60;
@apply min-h-96;
height: calc(100vh - 8rem);
}

View File

@@ -22,12 +22,12 @@ import {
useMachinesQuery,
} from "@/src/hooks/queries";
import { callApi } from "@/src/hooks/api";
import { store, setStore, clanURIs } from "@/src/stores/clan";
import { store, setStore, clanURIs, setActiveClanURI } from "@/src/stores/clan";
import { produce } from "solid-js/store";
import { Button } from "@/src/components/Button/Button";
import { Splash } from "@/src/scene/splash";
import cx from "classnames";
import "./Clan.css";
import styles from "./Clan.module.css";
import { Modal } from "@/src/components/Modal/Modal";
import { TextInput } from "@/src/components/Form/TextInput";
import { createForm, FieldValues, reset } from "@modular-forms/solid";
@@ -37,7 +37,7 @@ import { useNavigate } from "@solidjs/router";
export const Clan: Component<RouteSectionProps> = (props) => {
return (
<>
<Sidebar />
<Sidebar class={cx(styles.sidebar)} />
{props.children}
<ClanSceneController {...props} />
</>
@@ -54,54 +54,43 @@ interface MockProps {
}
const MockCreateMachine = (props: MockProps) => {
let container: Node;
const [form, { Form, Field, FieldArray }] = createForm<CreateFormValues>();
return (
<div ref={(el) => (container = el)} class="create-backdrop">
<Modal
mount={container!}
onClose={() => {
reset(form);
props.onClose();
}}
class="create-modal"
title="Create Machine"
>
{() => (
<Form class="flex flex-col" onSubmit={props.onSubmit}>
<Field name="name">
{(field, props) => (
<>
<TextInput
{...field}
label="Name"
size="s"
required={true}
input={{ ...props, placeholder: "name", autofocus: true }}
/>
</>
)}
</Field>
<div class="mt-4 flex w-full items-center justify-end gap-4">
<Button size="s" hierarchy="secondary" onClick={props.onClose}>
Cancel
</Button>
<Button
<Modal
open={true}
onClose={() => {
reset(form);
props.onClose();
}}
class={cx(styles.createModal)}
title="Create Machine"
>
<Form class="flex flex-col" onSubmit={props.onSubmit}>
<Field name="name">
{(field, props) => (
<>
<TextInput
{...field}
label="Name"
size="s"
type="submit"
hierarchy="primary"
onClick={close}
>
Create
</Button>
</div>
</Form>
)}
</Modal>
</div>
required={true}
input={{ ...props, placeholder: "name", autofocus: true }}
/>
</>
)}
</Field>
<div class="mt-4 flex w-full items-center justify-end gap-4">
<Button size="s" hierarchy="secondary" onClick={props.onClose}>
Cancel
</Button>
<Button size="s" type="submit" hierarchy="primary" onClick={close}>
Create
</Button>
</div>
</Form>
</Modal>
);
};
@@ -109,6 +98,8 @@ const ClanSceneController = (props: RouteSectionProps) => {
const clanURI = useClanURI();
const navigate = useNavigate();
const machinesQuery = useMachinesQuery(clanURI);
const [dialogHandlers, setDialogHandlers] = createSignal<{
resolve: ({ id }: { id: string }) => void;
reject: (err: unknown) => void;
@@ -140,6 +131,10 @@ const ClanSceneController = (props: RouteSectionProps) => {
// Important: rejects the promise
throw new Error(res.errors[0].message);
}
// trigger a refetch of the machines query
machinesQuery.refetch();
return { id: values.name };
};
@@ -164,6 +159,10 @@ const ClanSceneController = (props: RouteSectionProps) => {
const machine = createMemo(() => maybeUseMachineName());
createEffect(() => {
console.log("Selected clan:", clanURI);
});
createEffect(
on(machine, (machineId) => {
if (machineId) {
@@ -220,9 +219,16 @@ const ClanSceneController = (props: RouteSectionProps) => {
}}
/>
</Show>
<Button
onClick={() => setActiveClanURI(undefined)}
hierarchy="primary"
class="absolute bottom-4 right-4"
>
close this clan
</Button>
<div
class={cx({
"fade-out": !machinesQuery.isLoading && loadingCooldown(),
[styles.fadeOut]: !machinesQuery.isLoading && loadingCooldown(),
})}
>
<Splash />

View File

@@ -1,4 +1,4 @@
import { Component } from "solid-js";
import { Component, createEffect, on } from "solid-js";
import { RouteSectionProps, useNavigate } from "@solidjs/router";
import { activeClanURI } from "@/src/stores/clan";
import { navigateToClan } from "@/src/hooks/clan";
@@ -6,13 +6,17 @@ import { navigateToClan } from "@/src/hooks/clan";
export const Layout: Component<RouteSectionProps> = (props) => {
const navigate = useNavigate();
// check for an active clan uri and redirect to it on first load
const activeURI = activeClanURI();
if (!props.location.pathname.startsWith("/clans/") && activeURI) {
navigateToClan(navigate, activeURI);
} else {
navigate("/");
}
// check for an active clan uri and redirect if no clan is active
createEffect(
on(activeClanURI, (activeURI) => {
console.debug("Active Clan URI changed:", activeURI);
if (activeURI && !props.location.pathname.startsWith("/clans/")) {
navigateToClan(navigate, activeURI);
} else {
navigate("/");
}
}),
);
return <div class="size-full h-screen">{props.children}</div>;
};

View File

@@ -0,0 +1,6 @@
.sidebarPane {
@apply absolute left-[16.5rem] top-12 w-64;
@apply min-h-96;
height: calc(100vh - 10rem);
}

View File

@@ -1,13 +1,16 @@
import { RouteSectionProps, useNavigate } from "@solidjs/router";
import { SidebarPane } from "@/src/components/Sidebar/SidebarPane";
import { navigateToClan, useClanURI, useMachineName } from "@/src/hooks/clan";
import { createSignal, Show } from "solid-js";
import { Show } from "solid-js";
import { SectionGeneral } from "./SectionGeneral";
import { InstallModal } from "@/src/workflows/Install/install";
import { Button } from "@/src/components/Button/Button";
import { Machine as MachineModel, useMachineQuery } from "@/src/hooks/queries";
import { SectionTags } from "@/src/routes/Machine/SectionTags";
import { callApi } from "@/src/hooks/api";
import { SidebarMachineStatus } from "@/src/components/Sidebar/SidebarMachineStatus";
import { SidebarSectionInstall } from "@/src/components/Sidebar/SidebarSectionInstall";
import cx from "classnames";
import styles from "./Machine.module.css";
export const Machine = (props: RouteSectionProps) => {
const navigate = useNavigate();
@@ -18,10 +21,6 @@ export const Machine = (props: RouteSectionProps) => {
navigateToClan(navigate, clanURI);
};
const [showInstall, setShowModal] = createSignal(false);
let container: Node;
const sidebarPane = (machineName: string) => {
const machineQuery = useMachineQuery(clanURI, machineName);
@@ -53,7 +52,15 @@ export const Machine = (props: RouteSectionProps) => {
const sectionProps = { clanURI, machineName, onSubmit, machineQuery };
return (
<SidebarPane title={machineName} onClose={onClose}>
<SidebarPane
class={cx(styles.sidebarPane)}
title={machineName}
onClose={onClose}
subHeader={() => (
<SidebarMachineStatus clanURI={clanURI} machineName={machineName} />
)}
>
<SidebarSectionInstall clanURI={clanURI} machineName={machineName} />
<SectionGeneral {...sectionProps} />
<SectionTags {...sectionProps} />
</SidebarPane>
@@ -62,25 +69,6 @@ export const Machine = (props: RouteSectionProps) => {
return (
<Show when={useMachineName()} keyed>
<Button
hierarchy="primary"
onClick={() => setShowModal(true)}
class="absolute right-0 top-0 m-4"
>
Install me!
</Button>
<Show when={showInstall()}>
<div
class="absolute left-0 top-0 z-50 flex size-full items-center justify-center bg-white/90"
ref={(el) => (container = el)}
>
<InstallModal
machineName={useMachineName()}
mount={container!}
onClose={() => setShowModal(false)}
/>
</div>
</Show>
{sidebarPane(useMachineName())}
</Show>
);

View File

@@ -62,6 +62,11 @@ class RenderLoop {
this.renderRequested = true;
requestAnimationFrame(() => {
if (!this.initialized) {
console.log("RenderLoop not initialized, skipping render.");
return;
}
this.updateTweens();
const needsUpdate = this.controls.update(); // returns true if damping is ongoing
@@ -69,6 +74,7 @@ class RenderLoop {
this.render();
this.renderRequested = false;
// Controls smoothing may require another render
if (needsUpdate) {
this.requestRender();
}

View File

@@ -5,7 +5,7 @@
}
.toolbar-container {
@apply absolute bottom-8 z-10 w-full;
@apply absolute bottom-10 z-10 w-full;
@apply flex justify-center items-center;
}

View File

@@ -41,7 +41,8 @@ const activeClanURI = () => store.activeClanURI;
*
* @param {string} uri - The URI to be set as the active Clan URI.
*/
const setActiveClanURI = (uri: string) => setStore("activeClanURI", uri);
const setActiveClanURI = (uri: string | undefined) =>
setStore("activeClanURI", uri);
/**
* Retrieves the current list of clan URIs from the store.

View File

@@ -30,12 +30,14 @@ const mockFetcher: Fetcher = <K extends OperationNames>(
{
name: "sda_bla_bla",
path: "/dev/sda",
id_link: "sda_bla_bla",
id_link: "usb-bla-bla",
size: "12gb",
},
{
name: "sdb_foo_foo",
path: "/dev/sdb",
id_link: "sdb_foo_foo",
id_link: "usb-boo-foo",
size: "16gb",
},
] as SuccessQuery<"list_system_storage_devices">["data"]["blockdevices"],
},
@@ -64,17 +66,79 @@ const mockFetcher: Fetcher = <K extends OperationNames>(
description: "Name of the gritty",
prompt_type: "line",
display: {
label: "Gritty Name",
helperText: null,
label: "(1) Name",
group: "User",
required: true,
},
},
{
name: "gritty.foo",
description: "Name of the gritty",
prompt_type: "line",
display: {
helperText: null,
label: "(2) Password",
group: "Root",
required: true,
},
},
{
name: "gritty.bar",
description: "Name of the gritty",
prompt_type: "line",
display: {
helperText: null,
label: "(3) Gritty",
group: "Root",
required: true,
},
},
],
},
{
name: "funny.dodo",
prompts: [
{
name: "gritty.name",
description: "Name of the gritty",
prompt_type: "line",
display: {
helperText: null,
label: "(4) Name",
group: "User",
required: true,
},
},
{
name: "gritty.foo",
description: "Name of the gritty",
prompt_type: "line",
display: {
helperText: null,
label: "(5) Password",
group: "Lonely",
required: true,
},
},
{
name: "gritty.bar",
description: "Name of the gritty",
prompt_type: "line",
display: {
helperText: null,
label: "(6) Batty",
group: "Root",
required: true,
},
},
],
},
],
run_generators: null,
get_machine_hardware_summary: {
hardware_config: "nixos-facter",
platform: "x86_64-linux",
},
};
@@ -139,6 +203,7 @@ type Story = StoryObj<typeof InstallModal>;
export const Init: Story = {
description: "Welcome step for the installation workflow",
args: {
open: true,
machineName: "Test Machine",
initialStep: "init",
},
@@ -146,6 +211,7 @@ export const Init: Story = {
export const CreateInstallerProse: Story = {
description: "Prose step for creating an installer",
args: {
open: true,
machineName: "Test Machine",
initialStep: "create:prose",
},
@@ -153,6 +219,7 @@ export const CreateInstallerProse: Story = {
export const CreateInstallerImage: Story = {
description: "Configure the image to install",
args: {
open: true,
machineName: "Test Machine",
initialStep: "create:image",
},
@@ -160,6 +227,7 @@ export const CreateInstallerImage: Story = {
export const CreateInstallerDisk: Story = {
description: "Select a disk to install the image on",
args: {
open: true,
machineName: "Test Machine",
initialStep: "create:disk",
},
@@ -167,6 +235,7 @@ export const CreateInstallerDisk: Story = {
export const CreateInstallerProgress: Story = {
description: "Showed while the USB stick is being flashed",
args: {
open: true,
machineName: "Test Machine",
initialStep: "create:progress",
},
@@ -174,6 +243,7 @@ export const CreateInstallerProgress: Story = {
export const CreateInstallerDone: Story = {
description: "Installation done step",
args: {
open: true,
machineName: "Test Machine",
initialStep: "create:done",
},
@@ -181,6 +251,7 @@ export const CreateInstallerDone: Story = {
export const InstallConfigureAddress: Story = {
description: "Installation configure address step",
args: {
open: true,
machineName: "Test Machine",
initialStep: "install:address",
},
@@ -188,6 +259,7 @@ export const InstallConfigureAddress: Story = {
export const InstallCheckHardware: Story = {
description: "Installation check hardware step",
args: {
open: true,
machineName: "Test Machine",
initialStep: "install:check-hardware",
},
@@ -195,6 +267,7 @@ export const InstallCheckHardware: Story = {
export const InstallSelectDisk: Story = {
description: "Select disk to install the system on",
args: {
open: true,
machineName: "Test Machine",
initialStep: "install:disk",
},
@@ -202,6 +275,7 @@ export const InstallSelectDisk: Story = {
export const InstallVars: Story = {
description: "Fill required credentials and data for the installation",
args: {
open: true,
machineName: "Test Machine",
initialStep: "install:data",
},
@@ -209,6 +283,7 @@ export const InstallVars: Story = {
export const InstallSummary: Story = {
description: "Summary of the installation steps",
args: {
open: true,
machineName: "Test Machine",
initialStep: "install:summary",
},
@@ -216,6 +291,7 @@ export const InstallSummary: Story = {
export const InstallProgress: Story = {
description: "Shown while the installation is in progress",
args: {
open: true,
machineName: "Test Machine",
initialStep: "install:progress",
},
@@ -223,6 +299,7 @@ export const InstallProgress: Story = {
export const InstallDone: Story = {
description: "Shown after the installation is done",
args: {
open: true,
machineName: "Test Machine",
initialStep: "install:done",
},

View File

@@ -32,6 +32,7 @@ export interface InstallModalProps {
initialStep?: InstallSteps[number]["id"];
mount?: Node;
onClose?: () => void;
open: boolean;
}
const steps = [
@@ -85,18 +86,18 @@ export const InstallModal = (props: InstallModalProps) => {
<StepperProvider stepper={stepper}>
<Modal
class="h-[30rem] w-screen max-w-3xl"
mount={props.mount}
title="Install machine"
onClose={() => {
console.log("Install modal closed");
props.onClose?.();
}}
open={props.open}
// @ts-expect-error some steps might not have
metaHeader={stepper.currentStep()?.title ? <MetaHeader /> : undefined}
// @ts-expect-error some steps might not have
disablePadding={stepper.currentStep()?.isSplash}
>
{(ctx) => <InstallStepper onDone={ctx.close} />}
<InstallStepper onDone={() => props.onClose} />
</Modal>
</StepperProvider>
);

View File

@@ -19,6 +19,7 @@ import Icon from "@/src/components/Icon/Icon";
import { useSystemStorageOptions } from "@/src/hooks/queries";
import { useApiClient } from "@/src/hooks/ApiClient";
import { onMount } from "solid-js";
import cx from "classnames";
const Prose = () => (
<StepLayout
@@ -141,18 +142,11 @@ const ConfigureImage = () => {
throw new Error("No data returned from api call");
};
let content: Node;
return (
<Form onSubmit={handleSubmit} class="h-full">
<StepLayout
body={
<div
class="flex flex-col gap-2"
ref={(el) => {
content = el;
}}
>
<div class="flex flex-col gap-2">
<Fieldset>
<Field name="ssh_key">
{(field, input) => (
@@ -195,6 +189,8 @@ const ChooseDiskSchema = v.object({
type ChooseDiskForm = v.InferInput<typeof ChooseDiskSchema>;
const installMediaRegex = new RegExp("^(?<type>usb|mmc)-(?<name>.*)(-(.*))?$");
const ChooseDisk = () => {
const stepSignal = useStepper<InstallSteps>();
const [store, set] = getStepStore<InstallStoreType>(stepSignal);
@@ -234,7 +230,33 @@ const ChooseDisk = () => {
stepSignal.next();
};
const stripId = (s: string) => s.split("-")[1] ?? s;
const getOptions = async () => {
if (!systemStorageQuery.data) {
await systemStorageQuery.refetch();
}
const blockDevices = systemStorageQuery.data?.blockdevices ?? [];
const options = blockDevices
// we only want writeable block devices which are USB or MMC (SD cards)
.filter(({ id_link, ro }) => !ro && installMediaRegex.test(id_link))
// transform each entry into an option
.map(({ id_link, size, path }) => {
const match = id_link.match(installMediaRegex)!;
const name = match.groups?.name || "";
const truncatedName =
name.length > 32 ? name.slice(0, 32) + "..." : name;
return {
value: path,
label: `${truncatedName.replaceAll("_", " ")} (${size})`,
};
});
return options;
};
return (
<Form onSubmit={handleSubmit} class="h-full">
<StepLayout
@@ -250,28 +272,21 @@ const ChooseDisk = () => {
error={field.error}
required
label={{
label: "USB Stick",
description: "Select the usb stick",
}}
getOptions={async () => {
if (!systemStorageQuery.data) {
await systemStorageQuery.refetch();
}
console.log(systemStorageQuery.data);
return (systemStorageQuery.data?.blockdevices ?? []).map(
(dev) => ({
value: dev.path,
label: stripId(dev.id_link),
}),
);
label: "Install Media",
description:
"Select a USB stick or SD card from the list",
}}
getOptions={getOptions}
placeholder="Choose Device"
noOptionsText="No devices found"
name={field.name}
/>
)}
</Field>
<Alert
transparent
dense
size="s"
type="error"
icon="Info"
title="You're about to format this drive"
@@ -283,7 +298,7 @@ const ChooseDisk = () => {
footer={
<div class="flex justify-between">
<BackButton />
<NextButton endIcon="Flash">Flash USB Stick</NextButton>
<NextButton endIcon="Flash">Flash Installer</NextButton>
</div>
}
/>
@@ -314,8 +329,17 @@ const FlashProgress = () => {
};
return (
<div class="flex size-full flex-col items-center justify-center bg-inv-4">
<div class="mb-6 flex w-full max-w-md flex-col items-center gap-3 fg-inv-1">
<div
class={cx(
"relative flex size-full flex-col items-center justify-center bg-inv-4",
)}
>
<img
src="/logos/usb-stick-min.png"
alt="usb logo"
class="absolute top-2 z-0"
/>
<div class="z-10 mb-6 flex w-full max-w-md flex-col items-center gap-3 fg-inv-1">
<Typography
hierarchy="title"
size="default"
@@ -327,7 +351,7 @@ const FlashProgress = () => {
<LoadingBar />
<Button
hierarchy="primary"
class="w-fit"
class="mt-3 w-fit"
size="s"
onClick={handleCancel}
>

View File

@@ -60,6 +60,7 @@ const ConfigureAddress = () => {
});
const [isReachable, setIsReachable] = createSignal<string | null>(null);
const [loading, setLoading] = createSignal<boolean>(false);
const client = useApiClient();
// TODO: push values to the parent form Store
@@ -80,12 +81,15 @@ const ConfigureAddress = () => {
return;
}
setLoading(true);
const call = client.fetch("check_machine_ssh_login", {
remote: {
address,
},
});
const result = await call.result;
setLoading(false);
console.log("SSH login check result:", result);
if (result.status === "success") {
setIsReachable(address);
@@ -118,28 +122,28 @@ const ConfigureAddress = () => {
)}
</Field>
</Fieldset>
<Button
disabled={!getValue(formStore, "targetHost")}
endIcon="ArrowRight"
onClick={tryReachable}
hierarchy="secondary"
>
Test Connection
</Button>
</div>
}
footer={
<div class="flex justify-between">
<BackButton />
<NextButton
type="submit"
disabled={
<Show
when={
!isReachable() ||
isReachable() !== getValue(formStore, "targetHost")
}
fallback={<NextButton type="submit">Next</NextButton>}
>
Next
</NextButton>
<Button
endIcon="ArrowRight"
onClick={tryReachable}
hierarchy="secondary"
loading={loading()}
>
Test Connection
</Button>
</Show>
</div>
}
/>
@@ -208,6 +212,7 @@ const CheckHardware = () => {
<Show when={hardwareQuery.data}>
{(d) => (
<Alert
size="s"
icon={reportExists() ? "Checkmark" : "Close"}
type={reportExists() ? "info" : "warning"}
title={
@@ -435,7 +440,7 @@ const PromptsFields = (props: PromptsFieldsProps) => {
};
return (
<Form onSubmit={handleSubmit} class="h-full">
<Form onSubmit={handleSubmit}>
<StepLayout
body={
<div class="flex flex-col gap-2">
@@ -545,14 +550,22 @@ const InstallSummary = () => {
return;
}
// Extract generator names from prompt values
// TODO: This is wrong. We need to extend run_generators to be able to compute
// a sane closure over a list of provided generators.
const generators = Object.keys(store.install.promptValues || {});
const runGenerators = client.fetch("run_generators", {
all_prompt_values: store.install.promptValues,
machine: {
name: store.install.machineName,
flake: {
identifier: clanUri,
generators: generators.length > 0 ? generators : undefined,
prompt_values: store.install.promptValues,
machines: [
{
name: store.install.machineName,
flake: {
identifier: clanUri,
},
},
},
],
});
set("install", (s) => ({
@@ -580,7 +593,7 @@ const InstallSummary = () => {
progress: runInstall,
}));
await runInstall.result; // Wait for the installation to finish
await runInstall.result;
stepSignal.setActiveStep("install:done");
};
@@ -645,8 +658,13 @@ const InstallProgress = () => {
);
return (
<div class="flex size-full flex-col items-center justify-center bg-inv-4">
<div class="mb-6 flex w-full max-w-md flex-col items-center gap-3 fg-inv-1">
<div class="relative flex size-full flex-col items-center justify-center bg-inv-4">
<img
src="/logos/usb-stick-min.png"
alt="usb logo"
class="absolute top-2 z-0"
/>
<div class="z-10 mb-6 flex w-full max-w-md flex-col items-center gap-3 fg-inv-1">
<Typography
hierarchy="title"
size="default"
@@ -655,10 +673,11 @@ const InstallProgress = () => {
>
Machine is beeing installed
</Typography>
<LoadingBar />
<Typography
hierarchy="label"
size="default"
class="py-2"
class=""
color="secondary"
inverted
>
@@ -694,10 +713,9 @@ const InstallProgress = () => {
</Match>
</Switch>
</Typography>
<LoadingBar />
<Button
hierarchy="primary"
class="w-fit"
class="mt-3 w-fit"
size="s"
onClick={handleCancel}
>

View File

@@ -203,7 +203,7 @@ const colorSystem = {
1: primaries.secondary["950"],
2: primaries.secondary["900"],
3: primaries.secondary["700"],
4: primaries.secondary["500"],
4: primaries.secondary["600"],
},
inv: {
1: primaries.off.white,

View File

@@ -1,5 +1,6 @@
{
"compilerOptions": {
"plugins": [{ "name": "typescript-plugin-css-modules" }],
"strict": true,
"target": "ESNext",
"module": "ESNext",

View File

@@ -1,6 +1,7 @@
import { defineConfig } from "vite";
import solidPlugin from "vite-plugin-solid";
import solidSvg from "vite-plugin-solid-svg";
import { patchCssModules } from "vite-css-modules";
import path from "node:path";
import { exec } from "child_process";
@@ -40,6 +41,7 @@ export default defineConfig({
solidPlugin(),
solidSvg(),
regenPythonApiOnFileChange(),
patchCssModules({ generateSourceTypes: true }),
],
server: {
port: 3000,

View File

@@ -32,16 +32,12 @@ You can also run a single test like this:
pytest -n0 -s tests/test_secrets_cli.py::test_users
```
## Run tests in nix container
Run all impure checks
Run all checks in a sandbox
```bash
nix run .#impure-checks
nix build .#checks.x86_64-linux.clan-pytest-with-core
```
Run all checks
```bash
nix flake check
nix build .#checks.x86_64-linux.clan-pytest-without-core
```

View File

@@ -11,5 +11,4 @@ pytest_plugins = [
"clan_cli.tests.runtime",
"clan_cli.tests.fixtures_flakes",
"clan_cli.tests.stdout",
"clan_cli.tests.nix_config",
]

View File

@@ -2,7 +2,7 @@ import argparse
import logging
from clan_lib.flake import require_flake
from clan_lib.machines.actions import list_machines
from clan_lib.machines.actions import ListOptions, MachineFilter, list_machines
from clan_cli.completions import add_dynamic_completer, complete_tags
@@ -12,7 +12,9 @@ log = logging.getLogger(__name__)
def list_command(args: argparse.Namespace) -> None:
flake = require_flake(args.flake)
for name in list_machines(flake, opts={"filter": {"tags": args.tags}}):
for name in list_machines(
flake, opts=ListOptions(filter=MachineFilter(tags=args.tags))
):
print(name)

View File

@@ -7,7 +7,7 @@ from clan_lib.async_run import AsyncContext, AsyncOpts, AsyncRuntime
from clan_lib.errors import ClanError
from clan_lib.flake import require_flake
from clan_lib.flake.flake import Flake
from clan_lib.machines.actions import list_machines
from clan_lib.machines.actions import ListOptions, MachineFilter, list_machines
from clan_lib.machines.list import instantiate_inventory_to_machines
from clan_lib.machines.machines import Machine
from clan_lib.machines.suggestions import validate_machine_names
@@ -49,7 +49,9 @@ def get_machines_for_update(
filter_tags: list[str],
) -> list[Machine]:
all_machines = list_machines(flake)
machines_with_tags = list_machines(flake, {"filter": {"tags": filter_tags}})
machines_with_tags = list_machines(
flake, ListOptions(filter=MachineFilter(tags=filter_tags))
)
if filter_tags and not machines_with_tags:
msg = f"No machines found with tags: {' AND '.join(filter_tags)}"

View File

@@ -1 +1 @@
/nix/store/q012qk78pwldxl3qjy09nwrx9jlamivm-nixpkgs
/nix/store/apspgd56g9qy6fca8d44qnhdaiqrdf2c-nixpkgs

View File

@@ -1,6 +1,5 @@
import dataclasses
import json
import os
from collections.abc import Iterable
from pathlib import Path
@@ -26,7 +25,7 @@ class SopsSetup:
def __init__(self, keys: list[KeyPair]) -> None:
self.keys = keys
self.user = os.environ.get("USER", "admin")
self.user = "admin"
def init(self, flake_path: Path) -> None:
cli.run(

View File

@@ -422,3 +422,103 @@ def test_flake_with_core(
monkeypatch=monkeypatch,
inventory_expr=inventory_expr,
)
@pytest.fixture
def writable_clan_core(
clan_core: Path,
tmp_path: Path,
) -> Path:
"""
Creates a writable copy of clan_core in a temporary directory.
If clan_core is a git repo, copies tracked files and uncommitted changes.
Removes vars/ and sops/ directories if they exist.
"""
temp_flake = tmp_path / "clan-core"
# Check if it's a git repository
if (clan_core / ".git").exists():
# Create the target directory
temp_flake.mkdir(parents=True)
# Copy all tracked and untracked files (excluding ignored)
# Using git ls-files with -z for null-terminated output to handle filenames with spaces
sp.run(
f"(git ls-files -z; git ls-files -z --others --exclude-standard) | "
f"xargs -0 cp --parents -t {temp_flake}/",
shell=True,
cwd=clan_core,
check=True,
)
# Copy .git directory to maintain git functionality
if (clan_core / ".git").is_dir():
shutil.copytree(
clan_core / ".git", temp_flake / ".git", ignore_dangling_symlinks=True
)
else:
# It's a git file (for submodules/worktrees)
shutil.copy2(clan_core / ".git", temp_flake / ".git")
else:
# Regular copy if not a git repo
shutil.copytree(clan_core, temp_flake, ignore_dangling_symlinks=True)
# Make writable
sp.run(["chmod", "-R", "+w", str(temp_flake)], check=True)
# Remove vars and sops directories
shutil.rmtree(temp_flake / "vars", ignore_errors=True)
shutil.rmtree(temp_flake / "sops", ignore_errors=True)
return temp_flake
@pytest.fixture
def vm_test_flake(
clan_core: Path,
tmp_path: Path,
) -> Path:
"""
Creates a test flake that imports the VM test nixOS modules from clan-core.
"""
test_flake_dir = tmp_path / "test-flake"
test_flake_dir.mkdir(parents=True)
metadata = sp.run(
nix_command(["flake", "metadata", "--json"]),
cwd=CLAN_CORE,
capture_output=True,
text=True,
check=True,
).stdout.strip()
metadata_json = json.loads(metadata)
clan_core_url = f"path:{metadata_json['path']}"
# Read the template and substitute the clan-core path
template_path = Path(__file__).parent / "vm_test_flake.nix"
template_content = template_path.read_text()
# Get the current system
system_result = sp.run(
nix_command(["config", "show", "system"]),
capture_output=True,
text=True,
check=True,
)
current_system = system_result.stdout.strip()
# Substitute the clan-core URL and system
flake_content = template_content.replace("__CLAN_CORE__", clan_core_url)
flake_content = flake_content.replace("__SYSTEM__", current_system)
# Write the flake.nix
(test_flake_dir / "flake.nix").write_text(flake_content)
# Lock the flake with --allow-dirty to handle uncommitted changes
sp.run(
nix_command(["flake", "lock", "--allow-dirty-locks"]),
cwd=test_flake_dir,
check=True,
)
return test_flake_dir

View File

@@ -0,0 +1,131 @@
{ self, ... }:
{
# Define machines that use the nixOS modules
clan.machines = {
test-vm-persistence-x86_64-linux = {
imports = [ self.nixosModules.test-vm-persistence ];
nixpkgs.hostPlatform = "x86_64-linux";
};
test-vm-persistence-aarch64-linux = {
imports = [ self.nixosModules.test-vm-persistence ];
nixpkgs.hostPlatform = "aarch64-linux";
};
test-vm-deployment-x86_64-linux = {
imports = [ self.nixosModules.test-vm-deployment ];
nixpkgs.hostPlatform = "x86_64-linux";
};
test-vm-deployment-aarch64-linux = {
imports = [ self.nixosModules.test-vm-deployment ];
nixpkgs.hostPlatform = "aarch64-linux";
};
};
flake.nixosModules = {
# NixOS module for test_vm_persistence
test-vm-persistence =
{ config, ... }:
{
imports = [ self.nixosModules.clan-vm-base ];
system.stateVersion = config.system.nixos.release;
# Disable services that might cause issues in tests
systemd.services.logrotate-checkconf.enable = false;
services.getty.autologinUser = "root";
# Basic networking setup
networking.useDHCP = false;
networking.firewall.enable = false;
# VM-specific settings
clan.virtualisation.graphics = false;
clan.core.networking.targetHost = "client";
# State configuration for persistence test
clan.core.state.my_state.folders = [
"/var/my-state"
"/var/user-state"
];
# Initialize users for tests
users.users = {
root = {
initialPassword = "root";
};
test = {
initialPassword = "test";
isSystemUser = true;
group = "users";
};
};
};
# NixOS module for test_vm_deployment
test-vm-deployment =
{ config, lib, ... }:
{
imports = [ self.nixosModules.clan-vm-base ];
system.stateVersion = config.system.nixos.release;
# Disable services that might cause issues in tests
systemd.services.logrotate-checkconf.enable = false;
services.getty.autologinUser = "root";
# Basic networking setup
networking.useDHCP = false;
networking.firewall.enable = false;
# VM-specific settings
clan.virtualisation.graphics = false;
# SSH for deployment tests
services.openssh.enable = true;
# Initialize users for tests
users.users = {
root = {
initialPassword = "root";
};
};
# hack to make sure
sops.validateSopsFiles = false;
sops.secrets."vars/m1_generator/my_secret" = lib.mkDefault {
sopsFile = builtins.toFile "fake" "";
};
# Vars generators configuration
clan.core.vars.generators = {
m1_generator = {
files.my_secret = {
secret = true;
path = "/run/secrets/vars/m1_generator/my_secret";
};
script = ''
echo hello > "$out"/my_secret
'';
};
my_shared_generator = {
share = true;
files = {
shared_secret = {
secret = true;
path = "/run/secrets/vars/my_shared_generator/shared_secret";
};
no_deploy_secret = {
secret = true;
deploy = false;
path = "/run/secrets/vars/my_shared_generator/no_deploy_secret";
};
};
script = ''
echo hello > "$out"/shared_secret
echo hello > "$out"/no_deploy_secret
'';
};
};
};
};
}

View File

@@ -1,24 +0,0 @@
import json
import subprocess
from dataclasses import dataclass
import pytest
@dataclass
class ConfigItem:
aliases: list[str]
defaultValue: bool # noqa: N815
description: str
documentDefault: bool # noqa: N815
experimentalFeature: str # noqa: N815
value: str | bool | list[str] | dict[str, str]
@pytest.fixture(scope="session")
def nix_config() -> dict[str, ConfigItem]:
proc = subprocess.run(
["nix", "config", "show", "--json"], check=True, stdout=subprocess.PIPE
)
data = json.loads(proc.stdout)
return {name: ConfigItem(**c) for name, c in data.items()}

View File

@@ -7,7 +7,7 @@ from pathlib import Path
from typing import cast
from clan_lib.api import API
from clan_lib.api.util import JSchemaTypeError, type_to_dict
from clan_lib.api.type_to_jsonschema import JSchemaTypeError, type_to_dict
from clan_lib.errors import ClanError

View File

@@ -3,7 +3,7 @@ from clan_cli.tests.fixtures_flakes import FlakeForTest
from clan_cli.tests.helpers import cli
@pytest.mark.impure
@pytest.mark.with_core
def test_backups(
test_flake_with_core: FlakeForTest,
) -> None:

View File

@@ -9,7 +9,7 @@ if TYPE_CHECKING:
pass
@pytest.mark.impure
@pytest.mark.with_core
def test_flakes_inspect(
test_flake_with_core: FlakeForTest, capture_output: CaptureOutput
) -> None:

View File

@@ -1,44 +0,0 @@
from clan_lib.nix_models.clan import Inventory
from clan_lib.nix_models.clan import InventoryMachine as Machine
from clan_lib.nix_models.clan import InventoryMeta as Meta
from clan_lib.nix_models.clan import InventoryService as Service
def test_make_meta_minimal() -> None:
# Name is required
res = Meta(
{
"name": "foo",
}
)
assert res == {"name": "foo"}
def test_make_inventory_minimal() -> None:
# Meta is required
res = Inventory(
{
"meta": Meta(
{
"name": "foo",
}
),
}
)
assert res == {"meta": {"name": "foo"}}
def test_make_machine_minimal() -> None:
# Empty is valid
res = Machine({})
assert res == {}
def test_make_service_minimal() -> None:
# Empty is valid
res = Service({})
assert res == {}

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