Compare commits

...

146 Commits

Author SHA1 Message Date
Brian McGee
60279fffa9 wip(storybook): run storybook in nix derivation 2025-10-13 11:06:28 +01:00
Brian McGee
5886cc3330 wip(storybook): run storybook in nix derivation 2025-10-13 11:06:27 +01:00
hsjobeki
436da16bf9 Merge pull request 'facts: add bigger migration warnings' (#5484) from fix-c into main
Reviewed-on: https://git.clan.lol/clan/clan-core/pulls/5484
2025-10-13 08:11:38 +00:00
Johannes Kirschbauer
1c3282bb63 vars: simplify collectFiles 2025-10-13 10:05:53 +02:00
Johannes Kirschbauer
3c4b3e180e facts: add bigger migration warnings 2025-10-13 10:05:53 +02:00
hsjobeki
3953715b48 Merge pull request 'clan-cli: remove unused test fixture' (#5482) from fix-c into main
Reviewed-on: https://git.clan.lol/clan/clan-core/pulls/5482
2025-10-12 16:07:44 +00:00
Johannes Kirschbauer
7b95fa039f clan-cli: remove unused test fixture 2025-10-12 18:00:52 +02:00
hsjobeki
38712d6fe0 Merge pull request 'clan-core/nixos: remove autoloading magic in favour of simple code' (#5476) from fix-a into main
Reviewed-on: https://git.clan.lol/clan/clan-core/pulls/5476
2025-10-12 14:39:17 +00:00
Johannes Kirschbauer
1d38ffa9c2 inventory: unit test autoloading with a virtual fs 2025-10-12 16:32:55 +02:00
clan-bot
665f036dec Merge pull request 'Update clan-core-for-checks in devFlake' (#5478) from update-devFlake-clan-core-for-checks into main 2025-10-12 00:12:04 +00:00
clan-bot
b74b6ff449 Update clan-core-for-checks in devFlake 2025-10-12 00:01:53 +00:00
clan-bot
9c8797e770 Merge pull request 'Update clan-core-for-checks in devFlake' (#5477) from update-devFlake-clan-core-for-checks into main 2025-10-11 20:12:29 +00:00
clan-bot
2be6cedec4 Update clan-core-for-checks in devFlake 2025-10-11 20:01:49 +00:00
Johannes Kirschbauer
7f49449f94 clan-core/nixos: remove autoloading magic in favour of simple code 2025-10-11 18:02:32 +02:00
hsjobeki
1f7bfa4e34 Merge pull request 'inventory: wrap autoloaded machines with correct file' (#5474) from fix-a into main
Reviewed-on: https://git.clan.lol/clan/clan-core/pulls/5474
2025-10-11 16:00:37 +00:00
clan-bot
67fab4b11d Merge pull request 'Update clan-core-for-checks in devFlake' (#5475) from update-devFlake-clan-core-for-checks into main 2025-10-11 15:11:33 +00:00
clan-bot
18e3c72ef0 Update clan-core-for-checks in devFlake 2025-10-11 15:01:51 +00:00
Johannes Kirschbauer
84d4660a8d inventory: wrap autoloaded machines with correct file 2025-10-11 15:57:42 +02:00
clan-bot
13c3e1411a Merge pull request 'Update nixpkgs-dev in devFlake' (#5472) from update-devFlake-nixpkgs-dev into main 2025-10-11 10:14:29 +00:00
clan-bot
3c3a505aca Merge pull request 'Update clan-core-for-checks in devFlake' (#5471) from update-devFlake-clan-core-for-checks into main 2025-10-11 10:13:33 +00:00
clan-bot
f33c8e98fe Update nixpkgs-dev in devFlake 2025-10-11 10:02:05 +00:00
clan-bot
869a04e5af Update clan-core-for-checks in devFlake 2025-10-11 10:01:50 +00:00
clan-bot
d09fdc3528 Merge pull request 'Update clan-core-for-checks in devFlake' (#5470) from update-devFlake-clan-core-for-checks into main 2025-10-11 05:09:16 +00:00
clan-bot
652677d06f Update clan-core-for-checks in devFlake 2025-10-11 05:01:53 +00:00
clan-bot
ec163657cd Merge pull request 'Update clan-core-for-checks in devFlake' (#5469) from update-devFlake-clan-core-for-checks into main 2025-10-11 00:09:33 +00:00
clan-bot
7d3aa5936d Update clan-core-for-checks in devFlake 2025-10-11 00:01:51 +00:00
clan-bot
f8f8efbb88 Merge pull request 'Update treefmt-nix' (#5466) from update-treefmt-nix into main 2025-10-10 20:12:14 +00:00
clan-bot
8887e209d6 Merge pull request 'Update clan-core-for-checks in devFlake' (#5467) from update-devFlake-clan-core-for-checks into main 2025-10-10 20:10:50 +00:00
clan-bot
a72f74a36e Merge pull request 'Update treefmt-nix in devFlake' (#5468) from update-devFlake-treefmt-nix into main 2025-10-10 20:10:42 +00:00
clan-bot
0e0f8e73ec Update treefmt-nix in devFlake 2025-10-10 20:02:13 +00:00
clan-bot
f15a113f52 Update clan-core-for-checks in devFlake 2025-10-10 20:01:50 +00:00
clan-bot
1fbb4f5014 Update treefmt-nix 2025-10-10 20:01:49 +00:00
Michael Hoang
980a3c90b5 Merge pull request 'cli: ensure init-hardware-config passes Nix options to nixos-anywhere' (#5465) from push-mwotvwkqsluy into main
Reviewed-on: https://git.clan.lol/clan/clan-core/pulls/5465
2025-10-10 15:40:34 +00:00
clan-bot
c01b14aef5 Merge pull request 'Update clan-core-for-checks in devFlake' (#5464) from update-devFlake-clan-core-for-checks into main 2025-10-10 15:10:05 +00:00
clan-bot
0a3e564ec0 Update clan-core-for-checks in devFlake 2025-10-10 15:01:52 +00:00
Michael Hoang
bc09d5c886 cli: ensure init-hardware-config passes Nix options to nixos-anywhere 2025-10-10 17:00:10 +02:00
Michael Hoang
f6b8d660d8 Merge pull request 'checks: fix SSH debugging over vsock not working' (#5463) from push-yplypuoxymkt into main
Reviewed-on: https://git.clan.lol/clan/clan-core/pulls/5463
2025-10-10 14:40:10 +00:00
Michael Hoang
6014ddcd9a checks: fix SSH debugging over vsock not working 2025-10-10 16:32:54 +02:00
hsjobeki
551f5144c7 Merge pull request 'docs: Remove surprising statement on the front of documentation' (#5460) from kenji/ke-docs-fix into main
Reviewed-on: https://git.clan.lol/clan/clan-core/pulls/5460
2025-10-10 12:24:49 +00:00
a-kenji
9a664c323c docs: Remove surprising statement on the front of documentation 2025-10-10 13:35:29 +02:00
clan-bot
7572dc8c2b Merge pull request 'Update clan-core-for-checks in devFlake' (#5454) from update-devFlake-clan-core-for-checks into main 2025-10-10 10:09:30 +00:00
clan-bot
e22f0d9e36 Merge pull request 'Update nixpkgs-dev in devFlake' (#5455) from update-devFlake-nixpkgs-dev into main 2025-10-10 10:07:47 +00:00
clan-bot
f93ae13448 Update nixpkgs-dev in devFlake 2025-10-10 10:02:12 +00:00
clan-bot
749bac63f4 Update clan-core-for-checks in devFlake 2025-10-10 10:01:53 +00:00
clan-bot
2bac2ec7ee Merge pull request 'Update clan-core-for-checks in devFlake' (#5452) from update-devFlake-clan-core-for-checks into main 2025-10-10 05:09:28 +00:00
clan-bot
f224d4b20c Update clan-core-for-checks in devFlake 2025-10-10 05:01:54 +00:00
clan-bot
47aa0a3b8e Merge pull request 'Update clan-core-for-checks in devFlake' (#5451) from update-devFlake-clan-core-for-checks into main 2025-10-10 00:11:09 +00:00
clan-bot
dd1cab5daa Update clan-core-for-checks in devFlake 2025-10-10 00:01:51 +00:00
clan-bot
32edae4ebd Merge pull request 'Update clan-core-for-checks in devFlake' (#5450) from update-devFlake-clan-core-for-checks into main 2025-10-09 20:09:43 +00:00
clan-bot
d829aa5838 Update clan-core-for-checks in devFlake 2025-10-09 20:01:50 +00:00
clan-bot
fd6619668b Merge pull request 'Update clan-core-for-checks in devFlake' (#5449) from update-devFlake-clan-core-for-checks into main 2025-10-09 15:09:37 +00:00
clan-bot
50a26ece32 Update clan-core-for-checks in devFlake 2025-10-09 15:01:53 +00:00
brianmcgee
8f224b00a6 Merge pull request 'various-ui-fixes' (#5448) from various-ui-fixes into main
Reviewed-on: https://git.clan.lol/clan/clan-core/pulls/5448
2025-10-09 14:22:06 +00:00
Brian McGee
27d43ee21d fix(storybook): disable Sidebar story until we have a better mock data approach 2025-10-09 14:57:22 +01:00
Brian McGee
9626e22db7 fix(storybook): adjust flash installer on mount
It needs to handle possible missing state in the store on mount.
2025-10-09 14:57:22 +01:00
Brian McGee
1df329fe0d fix(storybook): disable service workflow stories
Temporary until we can decide how best to mock state.
2025-10-09 14:57:21 +01:00
Brian McGee
9da38abc77 fix(storybook): clan settings mock data shape changed 2025-10-09 14:57:20 +01:00
Brian McGee
2814c46e68 fix(storybook): button stories
- role="button" was removed at some point during refactoring which broke how the story was finding buttons
- button no longer has automatic loading state, instead it is now controlled.
2025-10-09 14:56:39 +01:00
Brian McGee
feef0a513e fix(storybook): remove cubes storybook
It wasn't adding much value and requires a mock Clan context which is a lot of effort at the min.
2025-10-09 14:56:38 +01:00
Brian McGee
9cc85b36c6 feat(ui): switch to webkit for storybook tests 2025-10-09 14:56:38 +01:00
hsjobeki
1465b18820 Merge pull request 'app: fix ClanSettings story' (#5447) from ui-cleanup into main
Reviewed-on: https://git.clan.lol/clan/clan-core/pulls/5447
2025-10-09 13:27:56 +00:00
Johannes Kirschbauer
6fa0062573 app: fix ClanSettings story 2025-10-09 15:24:30 +02:00
clan-bot
6cd68c23f5 Merge pull request 'Update clan-core-for-checks in devFlake' (#5444) from update-devFlake-clan-core-for-checks into main 2025-10-09 10:09:50 +00:00
clan-bot
fdddc60676 Merge pull request 'Update nixpkgs-dev in devFlake' (#5445) from update-devFlake-nixpkgs-dev into main 2025-10-09 10:08:18 +00:00
clan-bot
684aa27068 Update nixpkgs-dev in devFlake 2025-10-09 10:02:12 +00:00
clan-bot
35d8deb393 Update clan-core-for-checks in devFlake 2025-10-09 10:01:53 +00:00
DavHau
e2f20b5ffc Merge pull request 'vars: refactor - make shared generators carry machines list' (#5443) from dave into main
Reviewed-on: https://git.clan.lol/clan/clan-core/pulls/5443
2025-10-09 09:03:09 +00:00
DavHau
fd5d7934a0 vars: refactor - make shared generators carry machines list
This should make it simpler to improve the implementation of granting a new machine access to a shared secret.
The current approach using the health_check is  pretty hacky
2025-10-09 15:41:04 +07:00
Kenji Berthold
f194c31e0e Merge pull request 'Fix typo in "Authoring a 'clan.service' module"' (#5439) from nickdichev/clan-core:nickdichev-patch-1 into main
Reviewed-on: https://git.clan.lol/clan/clan-core/pulls/5439
2025-10-09 08:32:40 +00:00
DavHau
061b598adf Merge pull request 'vars: cleanup + fix wording' (#5442) from dave into main
Reviewed-on: https://git.clan.lol/clan/clan-core/pulls/5442
2025-10-09 05:44:14 +00:00
DavHau
744f35e0cc vars: cleanup + fix wording 2025-10-09 07:38:00 +02:00
clan-bot
4a6d46198c Merge pull request 'Update clan-core-for-checks in devFlake' (#5441) from update-devFlake-clan-core-for-checks into main 2025-10-09 05:11:10 +00:00
clan-bot
82d5ca9a0b Update clan-core-for-checks in devFlake 2025-10-09 05:01:51 +00:00
clan-bot
28d8a91a30 Merge pull request 'Update clan-core-for-checks in devFlake' (#5440) from update-devFlake-clan-core-for-checks into main 2025-10-09 00:09:59 +00:00
clan-bot
18f8d69728 Update clan-core-for-checks in devFlake 2025-10-09 00:01:50 +00:00
nickdichev
1feead4ce4 Fix typo in "Authoring a 'clan.service' module" 2025-10-08 20:16:16 +00:00
clan-bot
7f28110558 Merge pull request 'Update clan-core-for-checks in devFlake' (#5438) from update-devFlake-clan-core-for-checks into main 2025-10-08 20:09:55 +00:00
clan-bot
38787da891 Update clan-core-for-checks in devFlake 2025-10-08 20:01:48 +00:00
clan-bot
2b587da9fe Merge pull request 'Update clan-core-for-checks in devFlake' (#5437) from update-devFlake-clan-core-for-checks into main 2025-10-08 15:10:06 +00:00
clan-bot
acd2c1654b Update clan-core-for-checks in devFlake 2025-10-08 15:01:52 +00:00
hsjobeki
2ecb1399c3 Merge pull request 'docs: move generated markdown into a package' (#5436) from docs-source into main
Reviewed-on: https://git.clan.lol/clan/clan-core/pulls/5436
2025-10-08 14:40:41 +00:00
Johannes Kirschbauer
46ae6b49c1 docs: move generated markdown into a package 2025-10-08 16:37:31 +02:00
hsjobeki
50a8a69719 Merge pull request 'fix: pull request template folder' (#5435) from fix-j into main
Reviewed-on: https://git.clan.lol/clan/clan-core/pulls/5435
2025-10-08 14:28:00 +00:00
Johannes Kirschbauer
203761a99c fix: pull request template folder 2025-10-08 16:24:52 +02:00
hsjobeki
990b4e0223 Merge pull request 'docs: move option-search into own package' (#5434) from docs-source into main
Reviewed-on: https://git.clan.lol/clan/clan-core/pulls/5434
2025-10-08 14:05:38 +00:00
Johannes Kirschbauer
032f54cbfb docs: fix links 2025-10-08 16:02:31 +02:00
hsjobeki
47146efa0f Merge pull request 'PR: add pull request template' (#5428) from team-workflow into main
Reviewed-on: https://git.clan.lol/clan/clan-core/pulls/5428
2025-10-08 13:44:14 +00:00
Johannes Kirschbauer
c031abcd9e docs: move option-search into own package 2025-10-08 15:42:18 +02:00
Kenji Berthold
6b5dca5842 Merge pull request 'docs: Improve list view on testing' (#5433) from kenji/ke-docs-testing-display into main
Reviewed-on: https://git.clan.lol/clan/clan-core/pulls/5433
2025-10-08 12:31:43 +00:00
a-kenji
016fe3d114 docs: Improve list view on testing 2025-10-08 14:29:01 +02:00
DavHau
9b60b4a989 Merge pull request 'lib/introspection: Test skipping instrospection of oneOf' (#5432) from ke-oneof-test into main
Reviewed-on: https://git.clan.lol/clan/clan-core/pulls/5432
2025-10-08 12:07:58 +00:00
a-kenji
3088ce025b lib/introspection: Test skipping instrospection of oneOf
This builds on top of #5422.
And tests it's behavior.
2025-10-08 13:17:55 +02:00
hsjobeki
4f1fda3de6 Merge pull request 'docs: remove unused asciinema player' (#5431) from cus-docs into main
Reviewed-on: https://git.clan.lol/clan/clan-core/pulls/5431
2025-10-08 11:08:09 +00:00
Johannes Kirschbauer
57f14827c2 docs: remove unused asciinema player 2025-10-08 13:01:26 +02:00
Johannes Kirschbauer
0390d5999d PR: add pull request template 2025-10-08 12:44:36 +02:00
clan-bot
58e9a28f14 Merge pull request 'Update clan-core-for-checks in devFlake' (#5429) from update-devFlake-clan-core-for-checks into main 2025-10-08 10:14:43 +00:00
clan-bot
b4ad5ca1bd Merge pull request 'Update nixpkgs-dev in devFlake' (#5430) from update-devFlake-nixpkgs-dev into main 2025-10-08 10:14:17 +00:00
clan-bot
84ecb1aae6 Update nixpkgs-dev in devFlake 2025-10-08 10:02:10 +00:00
clan-bot
2b9971f538 Update clan-core-for-checks in devFlake 2025-10-08 10:01:51 +00:00
pinpox
81e15cab34 Merge pull request 'Fix capitalization issue' (#5427) from doc-fixes-1 into main
Reviewed-on: https://git.clan.lol/clan/clan-core/pulls/5427
2025-10-08 09:24:34 +00:00
pinpox
215c808071 Fix capitalization issue
fixes #4574
2025-10-08 11:21:06 +02:00
DavHau
4de052e58b Merge pull request 'lib/instrospection: Skip either(oneOf)' (#5422) from ke-introspection-either-or into main
Reviewed-on: https://git.clan.lol/clan/clan-core/pulls/5422
2025-10-08 07:42:12 +00:00
Louis Opter
a06a7a7a2c clan-cli/secrets: update some error message in encrypt_secret (#5271)
Found that while reading through some code.

Co-authored-by: Jörg Thalheim <joerg@thalheim.io>
Reviewed-on: https://git.clan.lol/clan/clan-core/pulls/5271
Co-authored-by: Louis Opter <louis@opter.org>
Co-committed-by: Louis Opter <louis@opter.org>
2025-10-08 07:41:14 +00:00
DavHau
94df3855b5 Merge pull request 'vars: raise error when shared generators differ between machines' (#5425) from dave into main
Reviewed-on: https://git.clan.lol/clan/clan-core/pulls/5425
2025-10-08 07:20:03 +00:00
DavHau
a83f3c23f4 vars: raise error when shared generators differ between machines
When generators are shared between machines, their definition has to be the same across all machines. If not, it might lead to unexpected problems, as the architecture assumes that all definitions are the same.

fixes https://git.clan.lol/clan/clan-core/issues/5253
2025-10-08 14:02:39 +07:00
DavHau
da6cd324f0 Merge pull request 'vars/performance: aggregate selects over all machines and generators' (#5402) from dave into main
Reviewed-on: https://git.clan.lol/clan/clan-core/pulls/5402
2025-10-08 06:04:50 +00:00
DavHau
c5b96df7b0 vars/performance: aggregate selects over all machines and generators
This improves the performance of deployment, by aggregating uncached select calls for vars generate into two batches.
batch 1: Get all generators of all machines
batch 2: get all final scripts for generators which need to run
2025-10-08 13:00:20 +07:00
clan-bot
c4feeace31 Merge pull request 'Update clan-core-for-checks in devFlake' (#5424) from update-devFlake-clan-core-for-checks into main 2025-10-08 05:06:31 +00:00
clan-bot
6117b664ae Update clan-core-for-checks in devFlake 2025-10-08 05:02:02 +00:00
clan-bot
b8fdb48fd8 Merge pull request 'Update clan-core-for-checks in devFlake' (#5423) from update-devFlake-clan-core-for-checks into main 2025-10-08 00:06:18 +00:00
clan-bot
9165f7ccaf Update clan-core-for-checks in devFlake 2025-10-08 00:01:52 +00:00
a-kenji
8058a7c158 lib/instrospection: Skip either(oneOf)
For either(oneOf) types, we skip introspection as we cannot
determine which branch of the union was taken without more context
This *should* be safe, as it can currently mostly be triggered through
The `extraModules` setting of inventory modules.

Example:

```
importer.roles.default.extraModules = [
    ../../modules/nixos/common.nix
];
```

Error Message:

```
Traceback (most recent call last):
  File "/nix/store/zaz4r4fic03m4whgz46p5jjszzlkq694-clan-cli/lib/python3.13/site-packages/clan_lib/templates/handler.py", line 91, in machine_template
    yield dst_machine_dir
  File "/nix/store/zaz4r4fic03m4whgz46p5jjszzlkq694-clan-cli/lib/python3.13/site-packages/clan_cli/machines/create.py", line 95, in create_machine
    inventory_store.write(inventory, message=f"machine '{machine_name}'")
    ~~~~~~~~~~~~~~~~~~~~~^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
  File "/nix/store/zaz4r4fic03m4whgz46p5jjszzlkq694-clan-cli/lib/python3.13/site-packages/clan_lib/persist/inventory_store.py", line 269, in write
    write_info = self._write_map()
  File "/nix/store/zaz4r4fic03m4whgz46p5jjszzlkq694-clan-cli/lib/python3.13/site-packages/clan_lib/persist/inventory_store.py", line 214, in _write_map
    current_priority = self._get_inventory_current_priority()
  File "/nix/store/zaz4r4fic03m4whgz46p5jjszzlkq694-clan-cli/lib/python3.13/site-packages/clan_lib/persist/inventory_store.py", line 206, in _get_inventory_current_priority
    return self._flake.select("clanInternals.inventoryClass.introspection")
           ~~~~~~~~~~~~~~~~~~^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
  File "/nix/store/zaz4r4fic03m4whgz46p5jjszzlkq694-clan-cli/lib/python3.13/site-packages/clan_lib/flake/flake.py", line 1129, in select
    self.get_from_nix([selector])
    ~~~~~~~~~~~~~~~~~^^^^^^^^^^^^
  File "/nix/store/zaz4r4fic03m4whgz46p5jjszzlkq694-clan-cli/lib/python3.13/site-packages/clan_lib/flake/flake.py", line 1054, in get_from_nix
    raise ClanSelectError(
    ...<3 lines>...
    ) from e
clan_lib.flake.flake.ClanSelectError: Error on: $ clan select 'clanInternals.inventoryClass.introspection'
  Reason: Yet Unsupported type: either
Removing left-over machine directory: /tmp/hyperconfig/machines/trooo
Error on: $ clan select 'clanInternals.inventoryClass.introspection'
  Reason: Yet Unsupported type: either

```

Closes: #5387
2025-10-07 22:58:51 +02:00
clan-bot
fed61f49f9 Merge pull request 'Update clan-core-for-checks in devFlake' (#5421) from update-devFlake-clan-core-for-checks into main 2025-10-07 20:06:46 +00:00
clan-bot
f1f05c7e6b Update clan-core-for-checks in devFlake 2025-10-07 20:01:53 +00:00
clan-bot
7597d1560f Merge pull request 'Update nixpkgs-dev in devFlake' (#5419) from update-devFlake-nixpkgs-dev into main 2025-10-07 15:06:44 +00:00
clan-bot
f739e1b66d Merge pull request 'Update clan-core-for-checks in devFlake' (#5418) from update-devFlake-clan-core-for-checks into main 2025-10-07 15:06:44 +00:00
clan-bot
5d3609aacd Update nixpkgs-dev in devFlake 2025-10-07 15:02:06 +00:00
clan-bot
7aa51d6bd7 Update clan-core-for-checks in devFlake 2025-10-07 15:01:48 +00:00
pinpox
af91ae8c7f Merge pull request 'Add internal services guide' (#5254) from internal-service-doc into main
Reviewed-on: https://git.clan.lol/clan/clan-core/pulls/5254
2025-10-07 14:39:52 +00:00
pinpox
077bf55fd7 Add internal services guide 2025-10-07 16:37:22 +02:00
hsjobeki
1f6dcb910f Merge pull request 'clan: ignore nixpkgs version checks in core' (#5416) from fix-override into main
Reviewed-on: https://git.clan.lol/clan/clan-core/pulls/5416
2025-10-07 14:08:30 +00:00
Johannes Kirschbauer
6363d9c99c clan: ignore nixpkgs version checks in core 2025-10-07 16:03:56 +02:00
Kenji Berthold
fd30dbd1be Merge pull request 'docs: Fix typos' (#5415) from kenji/ke-docs-typo into main
Reviewed-on: https://git.clan.lol/clan/clan-core/pulls/5415
2025-10-07 12:15:11 +00:00
a-kenji
ba4dc36ddf docs: Fix typos 2025-10-07 14:09:38 +02:00
Mic92
5abac04b15 Merge pull request 'network: fix generator cleanup in nested context manager' (#5412) from no-leaks into main
Reviewed-on: https://git.clan.lol/clan/clan-core/pulls/5412
2025-10-07 11:51:38 +00:00
Kenji Berthold
8c84d32b13 Merge pull request 'docs: document vars generation requirement for service tests' (#5409) from kenji/ke-document-update-vars into main
Reviewed-on: https://git.clan.lol/clan/clan-core/pulls/5409
2025-10-07 11:48:46 +00:00
a-kenji
c083548795 docs: document vars generation requirement for service tests
Add documentation explaining that services defining vars need to run
update-vars before tests can execute. Clarifies how `clan.directory`
determines where vars are generated and loaded from during testing.

Closes: #5380
2025-10-07 13:45:39 +02:00
Kenji Berthold
11af5c3471 Merge pull request 'docs: docs-integrity enable UTF-8 support' (#5413) from kenji/ke-html-proofer into main
Reviewed-on: https://git.clan.lol/clan/clan-core/pulls/5413
2025-10-07 11:23:06 +00:00
a-kenji
dac8a40b9f docs: docs-integrity enable UTF-8 support 2025-10-07 13:13:55 +02:00
Jörg Thalheim
204f9d09e3 network: refactor get_best_remote to class-based context manager
Resolves the "RuntimeError: generator didn't stop after throw()" issue
by replacing the generator-based @contextmanager with an explicit class.

This maintains backward compatibility through a factory function.
2025-10-07 13:05:20 +02:00
Mic92
668067080d Merge pull request 'sops: don't leak secret key in debug logs' (#5411) from no-leaks into main
Reviewed-on: https://git.clan.lol/clan/clan-core/pulls/5411
2025-10-07 10:00:47 +00:00
Jörg Thalheim
10ed2cc7f7 sops: don't leak secret key in debug logs 2025-10-07 11:31:12 +02:00
Kenji Berthold
060b22cf21 Merge pull request 'docs: Fix nixpkgs hierarchy' (#5410) from kenji/ke-qa-nixpkgs-input into main
Reviewed-on: https://git.clan.lol/clan/clan-core/pulls/5410
2025-10-07 08:50:00 +00:00
a-kenji
965dddfee1 docs: Fix nixpkgs hierarchy 2025-10-07 10:34:22 +02:00
clan-bot
6e5d74ba22 Merge pull request 'Update nixpkgs-dev in devFlake' (#5408) from update-devFlake-nixpkgs-dev into main 2025-10-07 05:06:29 +00:00
clan-bot
4257f47a1a Merge pull request 'Update clan-core-for-checks in devFlake' (#5407) from update-devFlake-clan-core-for-checks into main 2025-10-07 05:06:22 +00:00
clan-bot
72b64a8b70 Update nixpkgs-dev in devFlake 2025-10-07 05:02:09 +00:00
clan-bot
e46e0543cd Update clan-core-for-checks in devFlake 2025-10-07 05:01:51 +00:00
clan-bot
0de79962ea Merge pull request 'Update clan-core-for-checks in devFlake' (#5406) from update-devFlake-clan-core-for-checks into main 2025-10-07 00:06:50 +00:00
clan-bot
6209816115 Update clan-core-for-checks in devFlake 2025-10-07 00:01:50 +00:00
clan-bot
ec21cda0cf Merge pull request 'Update clan-core-for-checks in devFlake' (#5404) from update-devFlake-clan-core-for-checks into main 2025-10-06 20:06:46 +00:00
clan-bot
8a29d102cd Merge pull request 'Update nixpkgs-dev in devFlake' (#5405) from update-devFlake-nixpkgs-dev into main 2025-10-06 20:06:28 +00:00
clan-bot
22787e7c93 Update nixpkgs-dev in devFlake 2025-10-06 20:02:12 +00:00
clan-bot
19fd72e075 Update clan-core-for-checks in devFlake 2025-10-06 20:01:53 +00:00
clan-bot
50be33088c Merge pull request 'Update clan-core-for-checks in devFlake' (#5403) from update-devFlake-clan-core-for-checks into main 2025-10-06 15:06:19 +00:00
clan-bot
6e7a67c830 Update clan-core-for-checks in devFlake 2025-10-06 15:01:51 +00:00
68 changed files with 1469 additions and 875 deletions

View File

@@ -0,0 +1,12 @@
## Description of the change
<!-- Brief summary of the change if not already clear from the title -->
## Checklist
- [ ] Updated Documentation
- [ ] Added tests
- [ ] Doesn't affect backwards compatibility - or check the next points
- [ ] Add the breaking change and migration details to docs/release-notes.md
- !!! Review from another person is required *BEFORE* merge !!!
- [ ] Add introduction of major feature to docs/release-notes.md

View File

@@ -19,28 +19,19 @@ let
nixosLib = import (self.inputs.nixpkgs + "/nixos/lib") { };
in
{
imports =
let
clanCoreModulesDir = ../nixosModules/clanCore;
getClanCoreTestModules =
let
moduleNames = attrNames (builtins.readDir clanCoreModulesDir);
testPaths = map (
moduleName: clanCoreModulesDir + "/${moduleName}/tests/flake-module.nix"
) moduleNames;
in
filter pathExists testPaths;
in
getClanCoreTestModules
++ filter pathExists [
./devshell/flake-module.nix
./flash/flake-module.nix
./installation/flake-module.nix
./update/flake-module.nix
./morph/flake-module.nix
./nixos-documentation/flake-module.nix
./dont-depend-on-repo-root.nix
];
imports = filter pathExists [
./devshell/flake-module.nix
./flash/flake-module.nix
./installation/flake-module.nix
./update/flake-module.nix
./morph/flake-module.nix
./nixos-documentation/flake-module.nix
./dont-depend-on-repo-root.nix
# clan core submodule tests
../nixosModules/clanCore/machine-id/tests/flake-module.nix
../nixosModules/clanCore/postgresql/tests/flake-module.nix
../nixosModules/clanCore/state-version/tests/flake-module.nix
];
flake.check = genAttrs [ "x86_64-linux" "aarch64-darwin" ] (
system:
let
@@ -120,7 +111,7 @@ in
) (self.darwinConfigurations or { })
// lib.mapAttrs' (n: lib.nameValuePair "package-${n}") (
if system == "aarch64-darwin" then
lib.filterAttrs (n: _: n != "docs" && n != "deploy-docs" && n != "docs-options") packagesToBuild
lib.filterAttrs (n: _: n != "docs" && n != "deploy-docs" && n != "option-search") packagesToBuild
else
packagesToBuild
)

View File

@@ -15,7 +15,6 @@ let
networking.useNetworkd = true;
services.openssh.enable = true;
services.openssh.settings.UseDns = false;
services.openssh.settings.PasswordAuthentication = false;
system.nixos.variant_id = "installer";
environment.systemPackages = [
pkgs.nixos-facter

18
devFlake/flake.lock generated
View File

@@ -3,10 +3,10 @@
"clan-core-for-checks": {
"flake": false,
"locked": {
"lastModified": 1759727242,
"narHash": "sha256-15Q9eXbfsLmzIbYWasZ3Nuqafnc5o9al9RmGuBGVK74=",
"lastModified": 1760213549,
"narHash": "sha256-XosVRUEcdsoEdRtXyz9HrRc4Dt9Ke+viM5OVF7tLK50=",
"ref": "main",
"rev": "c737271585ff3df308feab22c09967fce8f278d3",
"rev": "9c8797e77031d8d472d057894f18a53bdc9bbe1e",
"shallow": true,
"type": "git",
"url": "https://git.clan.lol/clan/clan-core"
@@ -105,11 +105,11 @@
},
"nixpkgs-dev": {
"locked": {
"lastModified": 1759670943,
"narHash": "sha256-JBjTDfwzAwtd8+5X/Weg27WE/3hVYOP3uggP2JPaQVQ=",
"lastModified": 1760161054,
"narHash": "sha256-PO3cKHFIQEPI0dr/SzcZwG50cHXfjoIqP2uS5W78OXg=",
"owner": "NixOS",
"repo": "nixpkgs",
"rev": "21980a9c20f34648121f60bda15f419fa568db21",
"rev": "e18d8ec6fafaed55561b7a1b54eb1c1ce3ffa2c5",
"type": "github"
},
"original": {
@@ -208,11 +208,11 @@
"nixpkgs": []
},
"locked": {
"lastModified": 1758728421,
"narHash": "sha256-ySNJ008muQAds2JemiyrWYbwbG+V7S5wg3ZVKGHSFu8=",
"lastModified": 1760120816,
"narHash": "sha256-gq9rdocpmRZCwLS5vsHozwB6b5nrOBDNc2kkEaTXHfg=",
"owner": "numtide",
"repo": "treefmt-nix",
"rev": "5eda4ee8121f97b218f7cc73f5172098d458f1d1",
"rev": "761ae7aff00907b607125b2f57338b74177697ed",
"type": "github"
},
"original": {

2
docs/.gitignore vendored
View File

@@ -1,6 +1,6 @@
/site/reference
/site/services/official
/site/static
/site/options
/site/option-search
/site/openapi.json
!/site/static/extra.css

View File

@@ -1,5 +1,5 @@
# Serve documentation locally
```
$ nix develop .#docs -c mkdocs serve
nix develop .#docs -c mkdocs serve
```

View File

@@ -1,41 +0,0 @@
from typing import Any
def define_env(env: Any) -> None:
static_dir = "/static/"
video_dir = "https://clan.lol/" + "videos/"
asciinema_dir = static_dir + "asciinema-player/"
@env.macro
def video(name: str) -> str:
return f"""<video loop muted autoplay id="{name}">
<source src={video_dir + name} type="video/webm">
Your browser does not support the video tag.
</video>"""
@env.macro
def asciinema(name: str) -> str:
return f"""<div id="{name}">
<script>
// Function to load the script and then create the Asciinema player
function loadAsciinemaPlayer() {{
var script = document.createElement('script');
script.src = "{asciinema_dir}/asciinema-player.min.js";
script.onload = function() {{
AsciinemaPlayer.create('{video_dir + name}', document.getElementById("{name}"), {{
loop: true,
autoPlay: true,
controls: false,
speed: 1.5,
theme: "solarized-light"
}});
}};
document.head.appendChild(script);
}}
// Load the Asciinema player script
loadAsciinemaPlayer();
</script>
<link rel="stylesheet" type="text/css" href="{asciinema_dir}/asciinema-player.css" />
</div>"""

View File

@@ -58,7 +58,7 @@ nav:
- getting-started/configure-disk.md
- getting-started/update-machines.md
- getting-started/continuous-integration.md
- getting-started/convert-existing-NixOS-configuration.md
- Convert existing NixOS configurations: getting-started/convert-existing-NixOS-configuration.md
- Guides:
- Inventory:
- Introduction to Inventory: guides/inventory/inventory.md
@@ -66,6 +66,7 @@ nav:
- Services:
- Introduction to Services: guides/services/introduction-to-services.md
- Author Your Own Service: guides/services/community.md
- Internal Services with SSL: guides/internal-ssl-services.md
- Vars:
- Introduction to Vars: guides/vars/vars-overview.md
- Minimal Example: guides/vars/vars-backend.md
@@ -179,7 +180,7 @@ nav:
- services/official/zerotier.md
- services/community.md
- Search Clan Options: "/options"
- Search Clan Options: "/option-search"
docs_dir: site
site_dir: out

View File

@@ -3,11 +3,9 @@
module-docs,
clan-cli-docs,
clan-lib-openapi,
asciinema-player-js,
asciinema-player-css,
roboto,
fira-code,
docs-options,
option-search,
...
}:
let
@@ -53,13 +51,9 @@ pkgs.stdenv.mkDerivation {
chmod -R +w ./site
echo "Generated API documentation in './site/reference/' "
rm -rf ./site/options
cp -r ${docs-options} ./site/options
chmod -R +w ./site/options
mkdir -p ./site/static/asciinema-player
ln -snf ${asciinema-player-js} ./site/static/asciinema-player/asciinema-player.min.js
ln -snf ${asciinema-player-css} ./site/static/asciinema-player/asciinema-player.css
rm -rf ./site/option-search
cp -r ${option-search} ./site/option-search
chmod -R +w ./site/option-search
# Link to fonts
ln -snf ${roboto}/share/fonts/truetype/Roboto-Regular.ttf ./site/static/

View File

@@ -1,8 +1,5 @@
{ inputs, self, ... }:
{ inputs, ... }:
{
imports = [
./options/flake-module.nix
];
perSystem =
{
config,
@@ -10,83 +7,7 @@
pkgs,
...
}:
let
# Simply evaluated options (JSON)
# { clanCore = «derivation JSON»; clanModules = { ${name} = «derivation JSON» }; }
jsonDocs = pkgs.callPackage ./get-module-docs.nix {
inherit (self) clanModules;
clan-core = self;
inherit pkgs;
};
# clan service options
clanModulesViaService = pkgs.writeText "info.json" (builtins.toJSON jsonDocs.clanModulesViaService);
# Simply evaluated options (JSON)
renderOptions =
pkgs.runCommand "render-options"
{
# TODO: ruff does not splice properly in nativeBuildInputs
depsBuildBuild = [ pkgs.ruff ];
nativeBuildInputs = [
pkgs.python3
pkgs.mypy
self'.packages.clan-cli
];
}
''
install -D -m755 ${./render_options}/__init__.py $out/bin/render-options
patchShebangs --build $out/bin/render-options
ruff format --check --diff $out/bin/render-options
ruff check --line-length 88 $out/bin/render-options
mypy --strict $out/bin/render-options
'';
asciinema-player-js = pkgs.fetchurl {
url = "https://github.com/asciinema/asciinema-player/releases/download/v3.7.0/asciinema-player.min.js";
sha256 = "sha256-Ymco/+FinDr5YOrV72ehclpp4amrczjo5EU3jfr/zxs=";
};
asciinema-player-css = pkgs.fetchurl {
url = "https://github.com/asciinema/asciinema-player/releases/download/v3.7.0/asciinema-player.css";
sha256 = "sha256-GZMeZFFGvP5GMqqh516mjJKfQaiJ6bL38bSYOXkaohc=";
};
module-docs =
pkgs.runCommand "rendered"
{
buildInputs = [
pkgs.python3
self'.packages.clan-cli
];
}
''
export CLAN_CORE_PATH=${
inputs.nixpkgs.lib.fileset.toSource {
root = ../..;
fileset = ../../clanModules;
}
}
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_SERVICE=${clanModulesViaService}
export CLAN_SERVICE_INTERFACE=${self'.legacyPackages.clan-service-module-interface}/share/doc/nixos/options.json
export CLAN_OPTIONS_PATH=${self'.legacyPackages.clan-options}/share/doc/nixos/options.json
mkdir $out
# The python script will place mkDocs files in the output directory
exec python3 ${renderOptions}/bin/render-options
'';
in
{
legacyPackages = {
inherit
jsonDocs
clanModulesViaService
;
};
devShells.docs = self'.packages.docs.overrideAttrs (_old: {
nativeBuildInputs = [
# Run: htmlproofer --disable-external
@@ -105,22 +26,20 @@
docs = pkgs.python3.pkgs.callPackage ./default.nix {
inherit (self'.packages)
clan-cli-docs
docs-options
option-search
inventory-api-docs
clan-lib-openapi
module-docs
;
inherit (inputs) nixpkgs;
inherit module-docs;
inherit asciinema-player-js;
inherit asciinema-player-css;
};
deploy-docs = pkgs.callPackage ./deploy-docs.nix { inherit (config.packages) docs; };
inherit module-docs;
};
checks.docs-integrity =
pkgs.runCommand "docs-integrity"
{
nativeBuildInputs = [ pkgs.html-proofer ];
LANG = "C.UTF-8";
}
''
# External links should be avoided in the docs, because they often break

9
docs/release-notes.md Normal file
View File

@@ -0,0 +1,9 @@
# clan-core release notes 25.11
<!-- This is not rendered yet -->
## New features
## Breaking Changes
## Misc

View File

@@ -4,14 +4,14 @@ This guide will help you convert your existing NixOS configurations into a Clan.
Migrating instead of starting new can be trickier and might lead to bugs or
unexpected issues. We recommend reading the [Getting Started](../getting-started/creating-your-first-clan.md) guide first.
Once you have a working setup and understand the concepts transfering your NixOS configurations over is easy.
Once you have a working setup and understand the concepts transferring your NixOS configurations over is easy.
## Back up your existing configuration
Before you start, it is strongly recommended to back up your existing
configuration in any form you see fit. If you use version control to manage
your configuration changes, it is also a good idea to follow the migration
guide in a separte branch until everything works as expected.
guide in a separate branch until everything works as expected.
## Starting Point

View File

@@ -67,6 +67,59 @@ nix build .#checks.x86_64-linux.{test-attr-name}
```
(replace `{test-attr-name}` with the name of the test)
### Testing services with vars
Services that define their own vars (using `clan.core.vars.generators`) require generating test vars before running the tests.
#### Understanding the `clan.directory` setting
The `clan.directory` option is critical for vars generation and loading in tests. This setting determines:
1. **Where vars are generated**: When you run `update-vars`, it creates `vars/` and `sops/` directories inside the path specified by `clan.directory`
2. **Where vars are loaded from**: During test execution, machines look for their vars and secrets relative to `clan.directory`
#### Generating test vars
For services that define vars, you must first run:
```shellSession
nix run .#checks.x86_64-linux.{test-attr-name}.update-vars
```
This generates the necessary var files in the directory specified by `clan.directory`. After running this command, you can run the test normally:
```shellSession
nix run .#checks.x86_64-linux.{test-attr-name}
```
#### Example: service-dummy-test
The `service-dummy-test` is a good example of a test that uses vars. To run it:
```shellSession
# First, generate the test vars
nix run .#checks.x86_64-linux.service-dummy-test.update-vars
# Then run the test
nix run .#checks.x86_64-linux.service-dummy-test
```
#### Common issues
If `update-vars` fails, you may need to ensure that:
- **`clan.directory` is set correctly**: It should point to the directory where you want vars to be generated (typically `clan.directory = ./.;` in your test definition)
- **Your test defines machines**: Machines must be defined in `clan.inventory.machines` or through the inventory system
- **Machine definitions are complete**: Each machine should have the necessary service configuration that defines the vars generators
**If vars are not found during test execution:**
- Verify that `clan.directory` points to the same location where you ran `update-vars`
- Check that the `vars/` and `sops/` directories exist in that location
- Ensure the generated files match the machines and generators defined in your test
You can reference `/checks/service-dummy-test/` to see a complete working example of a test with vars, including the correct directory structure.
### Debugging VM tests
The following techniques can be used to debug a VM test:

View File

@@ -0,0 +1,213 @@
A common use case you might have is to host services and applications which are
only reachable within your clan.
This guide explains how to set up such secure, clan-internal web services using
a custom top-level domain (TLD) with SSL certificates.
Your services will be accessible only within your clan network and secured with
proper SSL certificates that all clan machines trust.
## Overview
By combining the `coredns` and `certificates` clan services, you can:
- Create a custom TLD for your clan (e.g. `.c`)
- Host internal web services accessible via HTTPS (e.g. `https://api.c`, `https://dashboard.c`)
- Automatically provision and trust SSL certificates across all clan machines
- Keep internal services secure and isolated from the public internet
The setup uses two clan services working together:
- **coredns service**: Provides DNS resolution for your custom TLD within the clan
- **certificates service**: Creates a certificate authority (CA) and issues SSL certificates for your TLD
### DNS Resolution Flow
1. A clan machine tries to access `https://service.c`
2. The machine queries its local DNS resolver (unbound)
3. For `.c` domains, the query is forwarded to your clan's CoreDNS server. All
other domains will be resolved as usual.
4. CoreDNS returns the IP address of the machine hosting the service
5. The machine connects directly to the service over HTTPS
6. The SSL certificate is trusted because all machines trust your clan's CA
## Step-by-Step Setup
The following setup assumes you have a VPN (e.g. Zerotier) already running. The
IPs configured in the options below will probably the Zerotier-IPs of the
respective machines.
### Configure the CoreDNS Service
The CoreDNS service has two roles:
- `server`: Runs the DNS server for your custom TLD
- `default`: Makes machines use the DNS server for TLD resolution and allows exposing services
Add this to your inventory:
```nix
inventory = {
machines = {
dns-server = { }; # Machine that will run the DNS server
web-server = { }; # Machine that will host web services
client = { }; # Any other machines in your clan
};
instances = {
coredns = {
# Add the default role to all machines
roles.default.tags = [ "all" ];
# DNS server for the .c TLD
roles.server.machines.dns-server.settings = {
ip = "192.168.1.10"; # IP of your DNS server machine
tld = "c";
};
# Machine hosting services (example: ca.c and admin.c)
roles.default.machines.web-server.settings = {
ip = "192.168.1.20"; # IP of your web server
services = [ "ca" "admin" ];
};
};
};
};
```
### Configure the Certificates Service
The certificates service also has two roles:
- `ca`: Sets up the certificate authority on a server
- `default`: Makes machines trust the CA and allows them to request certificates
Add this to your inventory:
```nix
inventory = {
instances = {
# ... coredns configuration from above ...
certificates = {
# Set up CA for .c domain
roles.ca.machines.dns-server.settings = {
tlds = [ "c" ];
acmeEmail = "admin@example.com"; # Optional: your email
};
# Add default role to all machines to trust the CA
roles.default.tags = [ "all" ];
};
};
};
```
### Complete Example Configuration
Here's a complete working example:
```nix
nventory = {
machines = {
caserver = { }; # DNS server + CA + web services
webserver = { }; # Additional web services
client = { }; # Client machine
};
instances = {
coredns = {
# Add the default role to all machines
roles.default.tags = [ "all" ];
# DNS server for the .c TLD
roles.server.machines.caserver.settings = {
ip = "192.168.8.5";
tld = "c";
};
# machine hosting https://ca.c (our CA for SSL)
roles.default.machines.caserver.settings = {
ip = "192.168.8.5";
services = [ "ca" ];
};
# machine hosting https://blub.c (some internal web-service)
roles.default.machines.webserver.settings = {
ip = "192.168.8.6";
services = [ "blub" ];
};
};
# Provide https for the .c top-level domain
certificates = {
roles.ca.machines.caserver.settings = {
tlds = [ "c" ];
acmeEmail = "admin@example.com";
};
roles.default.tags = [ "all" ];
};
};
};
```
## Testing Your Configuration
DNS resolution can be tested with:
```bash
# On any clan machine, test DNS resolution
nslookup ca.c
nslookup blub.c
```
You should also now be able to visit `https://ca.c` to access the certificate authority or visit `https://blub.c` to access your web service.
## Troubleshooting
### DNS Resolution Issues
1. **Check if DNS server is running**:
```bash
# On the DNS server machine
systemctl status coredns
```
2. **Verify DNS configuration**:
```bash
# Check if the right nameservers are configured
cat /etc/resolv.conf
systemctl status systemd-resolved
```
3. **Test DNS directly**:
```bash
# Query the DNS server directly
dig @192.168.8.5 ca.c
```
### Certificate Issues
1. **Check CA status**:
```bash
# On the CA machine
systemctl status step-ca
systemctl status nginx
```
2. **Verify certificate trust**:
```bash
# Test certificate trust
curl -v https://ca.c
openssl s_client -connect ca.c:443 -verify_return_error
```
3. **Check ACME configuration**:
```bash
# View ACME certificates
ls /var/lib/acme/
journalctl -u acme-ca.c.service
```

View File

@@ -5,11 +5,11 @@
## Option 1: Follow `clan-core`
- **Pros**:
- Recommended for most users.
- Verified by our CI and widely used by others.
- Recommended for most users.
- Verified by our CI and widely used by others.
- **Cons**:
- Coupled to version bumps in `clan-core`.
- Upstream features and packages may take longer to land.
- Coupled to version bumps in `clan-core`.
- Upstream features and packages may take longer to land.
Example:
@@ -24,10 +24,10 @@ inputs = {
## Option 2: Use Your Own `nixpkgs` Version
- **Pros**:
- Faster access to new upstream features and packages.
- Faster access to new upstream features and packages.
- **Cons**:
- Recommended for advanced users.
- Not covered by our CI — youre on the frontier.
- Recommended for advanced users.
- Not covered by our CI — youre on the frontier.
Example:

View File

@@ -288,7 +288,7 @@ of their type.
In the inventory we the assign machines to a type, e.g. by using tags
```nix title="flake.nix"
instnaces.machine-type = {
instances.machine-type = {
module.input = "self";
module.name = "@pinpox/machine-type";
roles.desktop.tags.desktop = { };
@@ -303,3 +303,4 @@ instnaces.machine-type = {
- [Reference Documentation for Service Authors](../../reference/options/clan_service.md)
- [Migration Guide from ClanModules to ClanServices](../../guides/migrations/migrate-inventory-services.md)
- [Decision that lead to ClanServices](../../decisions/01-Clan-Modules.md)
- [Testing Guide for Services with Vars](../contributing/testing.md#testing-services-with-vars)

View File

@@ -122,7 +122,7 @@ hide:
command line interface
- [Clan Options](/options)
- [Clan Options](./reference/options/clan.md)
---

View File

@@ -4,10 +4,10 @@ This section of the site provides an overview of available options and commands
---
- [Clan Configuration Option](/options) - for defining a Clan
- Learn how to use the [Clan CLI](../reference/cli/index.md)
- Explore available [services](../services/definition.md)
- [NixOS Configuration Options](../reference/clan.core/index.md) - Additional options avilable on a NixOS machine.
- [Search Clan Option](/option-search) - for defining a Clan
---

6
flake.lock generated
View File

@@ -181,11 +181,11 @@
]
},
"locked": {
"lastModified": 1758728421,
"narHash": "sha256-ySNJ008muQAds2JemiyrWYbwbG+V7S5wg3ZVKGHSFu8=",
"lastModified": 1760120816,
"narHash": "sha256-gq9rdocpmRZCwLS5vsHozwB6b5nrOBDNc2kkEaTXHfg=",
"owner": "numtide",
"repo": "treefmt-nix",
"rev": "5eda4ee8121f97b218f7cc73f5172098d458f1d1",
"rev": "761ae7aff00907b607125b2f57338b74177697ed",
"type": "github"
},
"original": {

View File

@@ -77,6 +77,8 @@
};
};
};
# Allows downstream users to inject "unsupported" nixpkgs versions
checks.minNixpkgsVersion.ignore = true;
};
systems = import systems;
imports = [

View File

@@ -52,8 +52,6 @@
"checks/secrets/sops/groups/group/machines/machine"
"checks/syncthing/introducer/introducer_device_id"
"checks/syncthing/introducer/introducer_test_api"
"docs/site/static/asciinema-player/asciinema-player.css"
"docs/site/static/asciinema-player/asciinema-player.min.js"
"nixosModules/clanCore/vars/secret/sops/eval-tests/populated/vars/my_machine/my_generator/my_secret"
"pkgs/clan-cli/clan_cli/tests/data/gnupg.conf"
"pkgs/clan-cli/clan_cli/tests/data/password-store/.gpg-id"
@@ -94,9 +92,6 @@
"*.yaml"
"*.yml"
];
excludes = [
"*/asciinema-player/*"
];
};
treefmt.programs.mypy.directories = {
"clan-cli" = {

View File

@@ -0,0 +1,51 @@
{ lib }:
let
sanitizePath =
rootPath: path:
let
storePrefix = builtins.unsafeDiscardStringContext ("${rootPath}");
pathStr = lib.removePrefix "/" (
lib.removePrefix storePrefix (builtins.unsafeDiscardStringContext (toString path))
);
in
pathStr;
mkFunctions = rootPath: passthru: virtual_fs: {
# Some functions to override lib functions
pathExists =
path:
let
pathStr = sanitizePath rootPath path;
isPassthru = builtins.any (exclude: (builtins.match exclude pathStr) != null) passthru;
in
if isPassthru then
builtins.pathExists path
else
let
res = virtual_fs ? ${pathStr};
in
lib.trace "pathExists: '${pathStr}' -> '${lib.generators.toPretty { } res}'" res;
readDir =
path:
let
pathStr = sanitizePath rootPath path;
base = (pathStr + "/");
res = lib.mapAttrs' (name: fileInfo: {
name = lib.removePrefix base name;
value = fileInfo.type;
}) (lib.filterAttrs (n: _: lib.hasPrefix base n) virtual_fs);
isPassthru = builtins.any (exclude: (builtins.match exclude pathStr) != null) passthru;
in
if isPassthru then
builtins.readDir path
else
lib.trace "readDir: '${pathStr}' -> '${lib.generators.toPretty { } res}'" res;
};
in
{
virtual_fs,
rootPath,
# Patterns
passthru ? [ ],
}:
mkFunctions rootPath passthru virtual_fs

View File

@@ -36,6 +36,10 @@ lib.fix (
# TODO: Flatten our lib functions like this:
resolveModule = clanLib.callLib ./resolve-module { };
fs = {
inherit (builtins) pathExists readDir;
};
};
in
f

View File

@@ -149,6 +149,13 @@ let
# TODO: Add index support in nixpkgs first
# else if type.name == "listOf" then
# handleListOf meta.list
else if type.name == "either" then
# For either(oneOf) types, we skip introspection as we cannot
# determine which branch of the union was taken without more context
# This *should* be safe, as it can currently mostly be triggered through
# The `extraModules` setting of inventory modules and seems to be better
# than just aborting entirely.
{ }
else
throw "Yet Unsupported type: ${type.name}";
in

View File

@@ -699,4 +699,44 @@ in
};
};
};
test_listOf_either =
let
evaluated = eval [
{
options.extraModules = lib.mkOption {
description = "List of modules that can be strings, paths, or attrsets";
default = [ ];
type = lib.types.listOf (
lib.types.oneOf [
lib.types.str
lib.types.path
(lib.types.attrsOf lib.types.anything)
]
);
};
}
({
_file = "config.nix";
extraModules = [
"modules/common.nix"
./some/path.nix
{ config = { }; }
];
})
];
result = slib.getPrios { options = evaluated.options; };
in
{
inherit evaluated;
# Test that either types in list items return empty objects
# This is a behavioral test and not necessarily the correct
# behavior. But this is better than crashing on people directly.
expr = result.extraModules.__list;
expected = [
{ }
{ }
{ }
];
};
}

View File

@@ -133,12 +133,13 @@ in
}
)
{
# TODO: Figure out why this causes infinite recursion
inventory.machines = lib.optionalAttrs (builtins.pathExists "${directory}/machines") (
builtins.mapAttrs (_n: _v: { }) (
lib.filterAttrs (_: t: t == "directory") (builtins.readDir "${directory}/machines")
)
);
# Note: we use clanLib.fs here, so that we can override it in tests
inventory = lib.optionalAttrs (clanLib.fs.pathExists "${directory}/machines") ({
imports = lib.mapAttrsToList (name: _t: {
_file = "${directory}/machines/${name}";
machines.${name} = { };
}) ((lib.filterAttrs (_: t: t == "directory") (clanLib.fs.readDir "${directory}/machines")));
});
}
{
inventory.machines = lib.mapAttrs (_n: _: { }) config.machines;

108
lib/modules/dir_test.nix Normal file
View File

@@ -0,0 +1,108 @@
{
lib ? import <nixpkgs/lib>,
}:
let
clanLibOrig = (import ./.. { inherit lib; }).__unfix__;
clanLibWithFs =
{ virtual_fs }:
lib.fix (
lib.extends (
final: _:
let
clan-core = {
clanLib = final;
modules.clan.default = lib.modules.importApply ./clan { inherit clan-core; };
# Note: Can add other things to "clan-core"
# ... Not needed for this test
};
in
{
clan = import ../clan {
inherit lib clan-core;
};
# Override clanLib.fs for unit-testing against a virtual filesystem
fs = import ../clanTest/virtual-fs.nix { inherit lib; } {
inherit rootPath virtual_fs;
# Example of a passthru
# passthru = [
# ".*inventory\.json$"
# ];
};
}
) clanLibOrig
);
rootPath = ./.;
in
{
test_autoload_directories =
let
vclan =
(clanLibWithFs {
virtual_fs = {
"machines" = {
type = "directory";
};
"machines/foo-machine" = {
type = "directory";
};
"machines/bar-machine" = {
type = "directory";
};
};
}).clan
{ config.directory = rootPath; };
in
{
inherit vclan;
expr = {
machines = lib.attrNames vclan.config.inventory.machines;
definedInMachinesDir = map (
p: lib.hasInfix "/machines/" p
) vclan.options.inventory.valueMeta.configuration.options.machines.files;
};
expected = {
machines = [
"bar-machine"
"foo-machine"
];
definedInMachinesDir = [
true # /machines/foo-machine
true # /machines/bar-machine
false # <clan-core>/module.nix defines "machines" without members
];
};
};
# Could probably be unified with the previous test
# This is here for the sake to show that 'virtual_fs' is a test parameter
test_files_are_not_machines =
let
vclan =
(clanLibWithFs {
virtual_fs = {
"machines" = {
type = "directory";
};
"machines/foo.nix" = {
type = "file";
};
"machines/bar.nix" = {
type = "file";
};
};
}).clan
{ config.directory = rootPath; };
in
{
inherit vclan;
expr = {
machines = lib.attrNames vclan.config.inventory.machines;
};
expected = {
machines = [ ];
};
};
}

View File

@@ -12,6 +12,7 @@ let
in
#######
{
autoloading = import ./dir_test.nix { inherit lib; };
test_missing_self =
let
eval = clan {

View File

@@ -164,13 +164,25 @@
config = lib.mkIf (config.clan.core.secrets != { }) {
clan.core.facts.services = lib.mapAttrs' (
name: service:
lib.warn "clan.core.secrets.${name} is deprecated, use clan.core.facts.services.${name} instead" (
lib.nameValuePair name ({
secret = service.secrets;
public = service.facts;
generator = service.generator;
})
)
lib.warn
''
###############################################################################
# #
# clan.core.secrets.${name} clan.core.facts.services.${name} is deprecated #
# in favor of "vars" #
# #
# Refer to https://docs.clan.lol/guides/migrations/migration-facts-vars/ #
# for migration instructions. #
# #
###############################################################################
''
(
lib.nameValuePair name ({
secret = service.secrets;
public = service.facts;
generator = service.generator;
})
)
) config.clan.core.secrets;
};
}

View File

@@ -6,7 +6,17 @@
}:
{
config.warnings = lib.optionals (config.clan.core.facts.services != { }) [
"Facts are deprecated, please migrate them to vars instead, see: https://docs.clan.lol/guides/migrations/migration-facts-vars/"
''
###############################################################################
# #
# Facts are deprecated please migrate any usages to vars instead #
# #
# #
# Refer to https://docs.clan.lol/guides/migrations/migration-facts-vars/ #
# for migration instructions. #
# #
###############################################################################
''
];
options.clan.core.facts = {

View File

@@ -5,33 +5,31 @@
let
inherit (lib)
filterAttrs
flatten
mapAttrsToList
;
in
generators:
let
relevantFiles =
generator:
filterAttrs (
_name: f: f.secret && f.deploy && (f.neededFor == "users" || f.neededFor == "services")
) generator.files;
allFiles = flatten (
mapAttrsToList (
gen_name: generator:
mapAttrsToList (fname: file: {
name = fname;
generator = gen_name;
neededForUsers = file.neededFor == "users";
inherit (generator) share;
inherit (file)
owner
group
mode
restartUnits
;
}) (relevantFiles generator)
) generators
relevantFiles = filterAttrs (
_name: f: f.secret && f.deploy && (f.neededFor == "users" || f.neededFor == "services")
);
collectFiles =
generators:
builtins.concatLists (
mapAttrsToList (
gen_name: generator:
mapAttrsToList (fname: file: {
name = fname;
generator = gen_name;
neededForUsers = file.neededFor == "users";
inherit (generator) share;
inherit (file)
owner
group
mode
restartUnits
;
}) (relevantFiles generator.files)
) generators
);
in
allFiles
collectFiles

View File

@@ -113,15 +113,27 @@ mkShell {
# todo darwin support needs some work
(lib.optionalString stdenv.hostPlatform.isLinux ''
# configure playwright for storybook snapshot testing
export PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD=1
# we only want webkit as that matches what the app is rendered with
export PLAYWRIGHT_BROWSERS_PATH=${
playwright-driver.browsers.override {
withFfmpeg = false;
withFirefox = false;
withWebkit = true;
withChromium = false;
withChromiumHeadlessShell = true;
}
}
export PLAYWRIGHT_HOST_PLATFORM_OVERRIDE="ubuntu-24.04"
# stop playwright from trying to validate it has downloaded the necessary browsers
# we are providing them manually via nix
export PLAYWRIGHT_SKIP_VALIDATE_HOST_REQUIREMENTS=true
# playwright browser drivers are versioned e.g. webkit-2191
# this helps us avoid having to update the playwright js dependency everytime we update nixpkgs and vice versa
# see vitest.config.js for corresponding launch configuration
export PLAYWRIGHT_CHROMIUM_EXECUTABLE=$(find -L "$PLAYWRIGHT_BROWSERS_PATH" -type f -name "headless_shell")
'');
}

View File

@@ -4,8 +4,10 @@
importNpmLock,
clan-ts-api,
fonts,
ps,
playwright-driver,
}:
buildNpmPackage (_finalAttrs: {
buildNpmPackage (finalAttrs: {
pname = "clan-app-ui";
version = "0.0.1";
nodejs = nodejs_22;
@@ -32,36 +34,38 @@ buildNpmPackage (_finalAttrs: {
# todo figure out why this fails only inside of Nix
# Something about passing orientation in any of the Form stories is causing the browser to crash
# `npm run test-storybook-static` works fine in the devshell
#
# passthru = rec {
# storybook = buildNpmPackage {
# pname = "${finalAttrs.pname}-storybook";
# inherit (finalAttrs)
# version
# nodejs
# src
# npmDeps
# npmConfigHook
# preBuild
# ;
#
# nativeBuildInputs = finalAttrs.nativeBuildInputs ++ [
# ps
# ];
#
# npmBuildScript = "test-storybook-static";
#
# env = finalAttrs.env // {
# PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD = 1;
# PLAYWRIGHT_BROWSERS_PATH = "${playwright-driver.browsers.override {
# withChromiumHeadlessShell = true;
# }}";
# PLAYWRIGHT_HOST_PLATFORM_OVERRIDE = "ubuntu-24.04";
# };
#
# postBuild = ''
# mv storybook-static $out
# '';
# };
# };
passthru = rec {
storybook = buildNpmPackage {
pname = "${finalAttrs.pname}-storybook";
inherit (finalAttrs)
version
nodejs
src
npmDeps
npmConfigHook
;
nativeBuildInputs = finalAttrs.nativeBuildInputs ++ [
ps
];
npmBuildScript = "test-storybook-static";
env = {
PLAYWRIGHT_BROWSERS_PATH = "${playwright-driver.browsers.override {
withChromiumHeadlessShell = true;
}}";
PLAYWRIGHT_SKIP_VALIDATE_HOST_REQUIREMENTS = true;
};
preBuild = finalAttrs.preBuild + ''
export PLAYWRIGHT_CHROMIUM_EXECUTABLE=$(find -L "$PLAYWRIGHT_BROWSERS_PATH" -type f -name "headless_shell")
'';
postBuild = ''
mv storybook-static $out
'';
};
};
})

View File

@@ -53,7 +53,7 @@
"jsdom": "^26.1.0",
"knip": "^5.61.2",
"markdown-to-jsx": "^7.7.10",
"playwright": "~1.53.2",
"playwright": "~1.55.1",
"postcss": "^8.4.38",
"postcss-url": "^10.1.3",
"prettier": "^3.2.5",
@@ -6956,13 +6956,13 @@
}
},
"node_modules/playwright": {
"version": "1.53.2",
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.53.2.tgz",
"integrity": "sha512-6K/qQxVFuVQhRQhFsVZ9fGeatxirtrpPgxzBYWyZLEXJzqYwuL4fuNmfOfD5et1tJE4GScKyPNeLhZeRwuTU3A==",
"version": "1.55.1",
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.55.1.tgz",
"integrity": "sha512-cJW4Xd/G3v5ovXtJJ52MAOclqeac9S/aGGgRzLabuF8TnIb6xHvMzKIa6JmrRzUkeXJgfL1MhukP0NK6l39h3A==",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
"playwright-core": "1.53.2"
"playwright-core": "1.55.1"
},
"bin": {
"playwright": "cli.js"
@@ -6975,9 +6975,9 @@
}
},
"node_modules/playwright-core": {
"version": "1.53.2",
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.53.2.tgz",
"integrity": "sha512-ox/OytMy+2w1jcYEYlOo1Hhp8hZkLCximMTUTMBXjGUA1KoFfiSZ+DU+3a739jsPY0yoKH2TFy9S2fsJas8yAw==",
"version": "1.55.1",
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.55.1.tgz",
"integrity": "sha512-Z6Mh9mkwX+zxSlHqdr5AOcJnfp+xUWLCt9uKV18fhzA8eyxUd8NUWzAjxUh55RZKSYwDGX0cfaySdhZJGMoJ+w==",
"dev": true,
"license": "Apache-2.0",
"bin": {

View File

@@ -16,9 +16,9 @@
"knip": "knip --fix",
"storybook-build": "storybook build",
"storybook-dev": "storybook dev -p 6006",
"test-storybook": "vitest run --project storybook",
"test-storybook": "vitest run --project storybook --reporter verbose",
"test-storybook-update-snapshots": "vitest run --project storybook --update",
"test-storybook-static": "npm run storybook-build && concurrently -k -s first -n 'SB,TEST' -c 'magenta,blue' 'http-server storybook-static --port 6006 --silent' 'wait-on tcp:127.0.0.1:6006 && npm run test-storybook'"
"test-storybook-static": "npm run storybook-build && concurrently -k -s first -n 'SB,TEST' -c 'magenta,blue' 'http-server storybook-static -a 127.0.0.1 -p 6006 --silent' 'wait-on tcp:127.0.0.1:6006 && npm run test-storybook'"
},
"license": "MIT",
"devDependencies": {
@@ -48,7 +48,7 @@
"jsdom": "^26.1.0",
"knip": "^5.61.2",
"markdown-to-jsx": "^7.7.10",
"playwright": "~1.53.2",
"playwright": "~1.55.1",
"postcss": "^8.4.38",
"postcss-url": "^10.1.3",
"prettier": "^3.2.5",

View File

@@ -1,7 +1,7 @@
import type { Meta, StoryObj } from "@kachurun/storybook-solid";
import { Button, ButtonProps } from "./Button";
import { Component } from "solid-js";
import { expect, fn, waitFor } from "storybook/test";
import { expect, fn, within } from "storybook/test";
import { StoryContext } from "@kachurun/storybook-solid-vite";
const getCursorStyle = (el: Element) => window.getComputedStyle(el).cursor;
@@ -216,17 +216,11 @@ const timeout = process.env.NODE_ENV === "test" ? 500 : 2000;
export const Primary: Story = {
args: {
hierarchy: "primary",
onAction: fn(async () => {
// wait 500 ms to simulate an action
await new Promise((resolve) => setTimeout(resolve, timeout));
// randomly fail to check that the loading state still returns to normal
if (Math.random() > 0.5) {
throw new Error("Action failure");
}
}),
onClick: fn(),
},
play: async ({ canvas, step, userEvent, args }: StoryContext) => {
play: async ({ canvasElement, step, userEvent, args }: StoryContext) => {
const canvas = within(canvasElement);
const buttons = await canvas.findAllByRole("button");
for (const button of buttons) {
@@ -238,14 +232,6 @@ export const Primary: Story = {
}
await step(`Click on ${testID}`, async () => {
// check for the loader
const loaders = button.getElementsByClassName("loader");
await expect(loaders.length).toEqual(1);
// assert its width is 0 before we click
const [loader] = loaders;
await expect(loader.clientWidth).toEqual(0);
// move the mouse over the button
await userEvent.hover(button);
@@ -255,33 +241,8 @@ export const Primary: Story = {
// click the button
await userEvent.click(button);
// check the button has changed
await waitFor(
async () => {
// the action handler should have been called
await expect(args.onAction).toHaveBeenCalled();
// the button should have a loading class
await expect(button).toHaveClass("loading");
// the loader should be visible
await expect(loader.clientWidth).toBeGreaterThan(0);
// the pointer should have changed to wait
await expect(getCursorStyle(button)).toEqual("wait");
},
{ timeout: timeout + 500 },
);
// wait for the action handler to finish
await waitFor(
async () => {
// the loading class should be removed
await expect(button).not.toHaveClass("loading");
// the loader should be hidden
await expect(loader.clientWidth).toEqual(0);
// the pointer should be normal
await expect(getCursorStyle(button)).toEqual("pointer");
},
{ timeout: timeout + 500 },
);
// the click handler should have been called
await expect(args.onClick).toHaveBeenCalled();
});
}
},

View File

@@ -57,6 +57,7 @@ export const Button = (props: ButtonProps) => {
return (
<KobalteButton
role="button"
class={cx(
styles.button, // default button class
local.size != "default" && styles[local.size],

View File

@@ -11,6 +11,59 @@ import { Button } from "../Button/Button";
const meta: Meta<ModalProps> = {
title: "Components/Modal",
component: Modal,
render: (args: ModalProps) => (
<Modal
{...args}
children={
<form class="flex flex-col gap-5">
<Fieldset legend="General">
{(props: FieldsetFieldProps) => (
<>
<TextInput
{...props}
label="First Name"
size="s"
required={true}
input={{ placeholder: "Ron" }}
/>
<TextInput
{...props}
label="Last Name"
size="s"
required={true}
input={{ placeholder: "Burgundy" }}
/>
<TextArea
{...props}
label="Bio"
size="s"
input={{
placeholder: "Tell us a bit about yourself",
rows: 8,
}}
/>
<Checkbox
{...props}
size="s"
label="Accept Terms"
required={true}
/>
</>
)}
</Fieldset>
<div class="flex w-full items-center justify-end gap-4">
<Button size="s" hierarchy="secondary" onClick={close}>
Cancel
</Button>
<Button size="s" type="submit" hierarchy="primary" onClick={close}>
Save
</Button>
</div>
</form>
}
/>
),
};
export default meta;
@@ -21,50 +74,5 @@ export const Default: Story = {
args: {
title: "Example Modal",
onClose: fn(),
children: (
<form class="flex flex-col gap-5">
<Fieldset legend="General">
{(props: FieldsetFieldProps) => (
<>
<TextInput
{...props}
label="First Name"
size="s"
required={true}
input={{ placeholder: "Ron" }}
/>
<TextInput
{...props}
label="Last Name"
size="s"
required={true}
input={{ placeholder: "Burgundy" }}
/>
<TextArea
{...props}
label="Bio"
size="s"
input={{ placeholder: "Tell us a bit about yourself", rows: 8 }}
/>
<Checkbox
{...props}
size="s"
label="Accept Terms"
required={true}
/>
</>
)}
</Fieldset>
<div class="flex w-full items-center justify-end gap-4">
<Button size="s" hierarchy="secondary" onClick={close}>
Cancel
</Button>
<Button size="s" type="submit" hierarchy="primary" onClick={close}>
Save
</Button>
</div>
</form>
),
},
};

View File

@@ -7,11 +7,9 @@ import {
} from "@solidjs/router";
import { Sidebar } from "@/src/components/Sidebar/Sidebar";
import { Suspense } from "solid-js";
import { QueryClient, QueryClientProvider } from "@tanstack/solid-query";
import { addClanURI, resetStore } from "@/src/stores/clan";
import { SolidQueryDevtools } from "@tanstack/solid-query-devtools";
import { encodeBase64 } from "@/src/hooks/clan";
import { ApiClientProvider } from "@/src/hooks/ApiClient";
import {
ApiCall,
OperationArgs,
@@ -160,47 +158,47 @@ const mockFetcher = <K extends OperationNames>(
},
}) satisfies ApiCall<K>;
export const Default: Story = {
args: {},
decorators: [
(Story: StoryObj) => {
const queryClient = new QueryClient({
defaultOptions: {
queries: {
retry: false,
staleTime: Infinity,
},
},
});
Object.entries(queryData).forEach(([clanURI, clan]) => {
queryClient.setQueryData(
["clans", encodeBase64(clanURI), "details"],
clan.details,
);
const machines = clan.machines || {};
queryClient.setQueryData(
["clans", encodeBase64(clanURI), "machines"],
machines,
);
Object.entries(machines).forEach(([name, machine]) => {
queryClient.setQueryData(
["clans", encodeBase64(clanURI), "machine", name, "state"],
machine.state,
);
});
});
return (
<ApiClientProvider client={{ fetch: mockFetcher }}>
<QueryClientProvider client={queryClient}>
<Story />
</QueryClientProvider>
</ApiClientProvider>
);
},
],
};
// export const Default: Story = {
// args: {},
// decorators: [
// (Story: StoryObj) => {
// const queryClient = new QueryClient({
// defaultOptions: {
// queries: {
// retry: false,
// staleTime: Infinity,
// },
// },
// });
//
// Object.entries(queryData).forEach(([clanURI, clan]) => {
// queryClient.setQueryData(
// ["clans", encodeBase64(clanURI), "details"],
// clan.details,
// );
//
// const machines = clan.machines || {};
//
// queryClient.setQueryData(
// ["clans", encodeBase64(clanURI), "machines"],
// machines,
// );
//
// Object.entries(machines).forEach(([name, machine]) => {
// queryClient.setQueryData(
// ["clans", encodeBase64(clanURI), "machine", name, "state"],
// machine.state,
// );
// });
// });
//
// return (
// <ApiClientProvider client={{ fetch: mockFetcher }}>
// <QueryClientProvider client={queryClient}>
// <Story />
// </QueryClientProvider>
// </ApiClientProvider>
// );
// },
// ],
// };

View File

@@ -14,6 +14,7 @@ import { splitProps } from "solid-js";
import { Typography } from "@/src/components/Typography/Typography";
import { MachineTags } from "@/src/components/Form/MachineTags";
import { setValue } from "@modular-forms/solid";
import { StoryContext } from "@kachurun/storybook-solid-vite";
type Story = StoryObj<SidebarPaneProps>;
@@ -30,6 +31,13 @@ const profiles = {
const meta: Meta<SidebarPaneProps> = {
title: "Components/SidebarPane",
component: SidebarPane,
decorators: [
(
Story: StoryObj<SidebarPaneProps>,
context: StoryContext<SidebarPaneProps>,
) =>
() => <Story {...context.args} />,
],
};
export default meta;
@@ -40,133 +48,140 @@ export const Default: Story = {
onClose: () => {
console.log("closing");
},
children: (
<>
<SidebarSectionForm
title="General"
schema={v.object({
firstName: v.pipe(
v.string(),
v.nonEmpty("Please enter a first name."),
),
lastName: v.pipe(
v.string(),
v.nonEmpty("Please enter a last name."),
),
bio: v.string(),
shareProfile: v.optional(v.boolean()),
})}
initialValues={profiles.ron}
onSubmit={async () => {
console.log("saving general");
}}
>
{({ editing, Field }) => (
<div class="flex flex-col gap-3">
<Field name="firstName">
{(field, input) => (
<TextInput
{...field}
size="s"
inverted
label="First Name"
value={field.value}
required
readOnly={!editing}
orientation="horizontal"
input={input}
/>
)}
</Field>
<Divider />
<Field name="lastName">
{(field, input) => (
<TextInput
{...field}
size="s"
inverted
label="Last Name"
value={field.value}
required
readOnly={!editing}
orientation="horizontal"
input={input}
/>
)}
</Field>
<Divider />
<Field name="bio">
{(field, input) => (
<TextArea
{...field}
value={field.value}
size="s"
label="Bio"
inverted
readOnly={!editing}
orientation="horizontal"
input={{ ...input, rows: 4 }}
/>
)}
</Field>
<Field name="shareProfile" type="boolean">
{(field, input) => {
return (
<Checkbox
{...splitProps(field, ["value"])[1]}
defaultChecked={field.value}
},
// We have to provide children within a custom render function to ensure we aren't creating any reactivity outside the
// solid-js scope.
render: (args: SidebarPaneProps) => (
<SidebarPane
{...args}
children={
<>
<SidebarSectionForm
title="General"
schema={v.object({
firstName: v.pipe(
v.string(),
v.nonEmpty("Please enter a first name."),
),
lastName: v.pipe(
v.string(),
v.nonEmpty("Please enter a last name."),
),
bio: v.string(),
shareProfile: v.optional(v.boolean()),
})}
initialValues={profiles.ron}
onSubmit={async () => {
console.log("saving general");
}}
>
{({ editing, Field }) => (
<div class="flex flex-col gap-3">
<Field name="firstName">
{(field, input) => (
<TextInput
{...field}
size="s"
label="Share"
inverted
label="First Name"
value={field.value}
required
readOnly={!editing}
orientation="horizontal"
input={input}
/>
);
}}
</Field>
</div>
)}
</SidebarSectionForm>
<SidebarSectionForm
title="Tags"
schema={v.object({
tags: v.pipe(v.array(v.string()), v.nonEmpty()),
})}
initialValues={profiles.ron}
onSubmit={async (values) => {
console.log("saving tags", values);
}}
>
{({ editing, Field, formStore }) => (
<Field name="tags" type="string[]">
{(field, props) => (
<MachineTags
{...splitProps(field, ["value"])[1]}
size="s"
onChange={(newVal) => {
// Workaround for now, until we manage to use native events
setValue(formStore, field.name, newVal);
)}
</Field>
<Divider />
<Field name="lastName">
{(field, input) => (
<TextInput
{...field}
size="s"
inverted
label="Last Name"
value={field.value}
required
readOnly={!editing}
orientation="horizontal"
input={input}
/>
)}
</Field>
<Divider />
<Field name="bio">
{(field, input) => (
<TextArea
{...field}
value={field.value}
size="s"
label="Bio"
inverted
readOnly={!editing}
orientation="horizontal"
input={{ ...input, rows: 4 }}
/>
)}
</Field>
<Field name="shareProfile" type="boolean">
{(field, input) => {
return (
<Checkbox
{...splitProps(field, ["value"])[1]}
defaultChecked={field.value}
size="s"
label="Share"
inverted
readOnly={!editing}
orientation="horizontal"
input={input}
/>
);
}}
inverted
required
readOnly={!editing}
orientation="horizontal"
defaultValue={field.value}
/>
)}
</Field>
)}
</SidebarSectionForm>
<SidebarSection title="Simple">
<Typography tag="h2" hierarchy="title" size="m" inverted>
Static Content
</Typography>
<Typography hierarchy="label" size="s" inverted>
This is a non-form section with static content
</Typography>
</SidebarSection>
</>
),
},
</Field>
</div>
)}
</SidebarSectionForm>
<SidebarSectionForm
title="Tags"
schema={v.object({
tags: v.pipe(v.array(v.string()), v.nonEmpty()),
})}
initialValues={profiles.ron}
onSubmit={async (values) => {
console.log("saving tags", values);
}}
>
{({ editing, Field, formStore }) => (
<Field name="tags" type="string[]">
{(field, props) => (
<MachineTags
{...splitProps(field, ["value"])[1]}
size="s"
onChange={(newVal) => {
// Workaround for now, until we manage to use native events
setValue(formStore, field.name, newVal);
}}
inverted
required
readOnly={!editing}
orientation="horizontal"
defaultValue={field.value}
/>
)}
</Field>
)}
</SidebarSectionForm>
<SidebarSection title="Simple">
<Typography tag="h2" hierarchy="title" size="m" inverted>
Static Content
</Typography>
<Typography hierarchy="label" size="s" inverted>
This is a non-form section with static content
</Typography>
</SidebarSection>
</>
}
/>
),
};

View File

@@ -1,6 +1,5 @@
import { Meta, StoryObj } from "@kachurun/storybook-solid";
import { Toolbar, ToolbarProps } from "@/src/components/Toolbar/Toolbar";
import { Divider } from "@/src/components/Divider/Divider";
import { ToolbarButton } from "./ToolbarButton";
const meta: Meta<ToolbarProps> = {
@@ -13,61 +12,35 @@ export default meta;
type Story = StoryObj<ToolbarProps>;
export const Default: Story = {
args: {
children: (
<>
<ToolbarButton
name="select"
icon="Cursor"
description="Select my thing"
/>
<ToolbarButton
name="new-machine"
icon="NewMachine"
description="Select this thing"
/>
<Divider orientation="vertical" />
<ToolbarButton
name="modules"
icon="Modules"
selected={true}
description="Add service"
/>
<ToolbarButton name="ai" icon="AI" description="Call your AI Manager" />
</>
),
},
};
export const WithTooltip: Story = {
// We have to specify children inside a render function to avoid issues with reactivity outside a solid-js context.
// @ts-expect-error: args in storybook is not typed correctly. This is a storybook issue.
render: (args) => (
<div class="flex h-[80vh]">
<div class="mt-auto">
<Toolbar {...args} />
<Toolbar
{...args}
children={
<>
<ToolbarButton name="select" icon="Cursor" description="Select" />
<ToolbarButton
name="new-machine"
icon="NewMachine"
description="Select"
/>
<ToolbarButton
name="modules"
icon="Modules"
selected={true}
description="Select"
/>
<ToolbarButton name="ai" icon="AI" description="Select" />
</>
}
/>
</div>
</div>
),
args: {
children: (
<>
<ToolbarButton name="select" icon="Cursor" description="Select" />
<ToolbarButton
name="new-machine"
icon="NewMachine"
description="Select"
/>
<ToolbarButton
name="modules"
icon="Modules"
selected={true}
description="Select"
/>
<ToolbarButton name="ai" icon="AI" description="Select" />
</>
),
},
};

View File

@@ -1,7 +1,6 @@
import { Meta, StoryObj } from "@kachurun/storybook-solid";
import { Tooltip, TooltipProps } from "@/src/components/Tooltip/Tooltip";
import { Typography } from "@/src/components/Typography/Typography";
import { Button } from "@/src/components/Button/Button";
const meta: Meta<TooltipProps> = {
title: "Components/Tooltip",
@@ -13,6 +12,23 @@ const meta: Meta<TooltipProps> = {
</div>
),
],
render: (args: TooltipProps) => (
<div class="p-16">
<Tooltip
{...args}
children={
<Typography
hierarchy="body"
size="xs"
inverted={true}
weight="medium"
>
Your Clan is being created
</Typography>
}
/>
</div>
),
};
export default meta;
@@ -23,12 +39,6 @@ export const Default: Story = {
args: {
placement: "top",
inverted: false,
trigger: <Button hierarchy="primary">Trigger</Button>,
children: (
<Typography hierarchy="body" size="xs" inverted={true} weight="medium">
Your Clan is being created
</Typography>
),
},
};

View File

@@ -11,28 +11,35 @@ export default meta;
type Story = StoryObj<ClanSettingsModalProps>;
export const Default: Story = {
args: {
onClose: fn(),
model: {
uri: "/home/foo/my-clan",
const props: ClanSettingsModalProps = {
onClose: fn(),
model: {
uri: "/home/foo/my-clan",
details: {
name: "Sol",
description: null,
icon: null,
fieldsSchema: {
name: {
readonly: true,
reason: null,
},
description: {
readonly: false,
reason: null,
},
icon: {
readonly: false,
reason: null,
},
},
fieldsSchema: {
name: {
readonly: true,
reason: null,
readonly_members: [],
},
description: {
readonly: false,
reason: null,
readonly_members: [],
},
icon: {
readonly: false,
reason: null,
readonly_members: [],
},
},
},
};
export const Default: Story = {
args: props,
};

View File

@@ -22,9 +22,9 @@ import { Alert } from "@/src/components/Alert/Alert";
import { removeClanURI } from "@/src/stores/clan";
const schema = v.object({
name: v.pipe(v.optional(v.string())),
description: v.nullish(v.string()),
icon: v.pipe(v.nullish(v.string())),
name: v.string(),
description: v.optional(v.string()),
icon: v.optional(v.string()),
});
export interface ClanSettingsModalProps {

View File

@@ -1,15 +0,0 @@
import { Meta, StoryObj } from "@kachurun/storybook-solid";
import { CubeScene } from "./cubes";
const meta: Meta = {
title: "scene/cubes",
component: CubeScene,
};
export default meta;
type Story = StoryObj;
export const Default: Story = {
args: {},
};

View File

@@ -1,7 +1,6 @@
import { getStepStore, useStepper } from "@/src/hooks/stepper";
import {
createForm,
getError,
SubmitHandler,
valiForm,
} from "@modular-forms/solid";
@@ -304,11 +303,10 @@ const FlashProgress = () => {
const [store, set] = getStepStore<InstallStoreType>(stepSignal);
onMount(async () => {
const result = await store.flash.progress.result;
if (result.status == "success") {
console.log("Flashing Success");
const result = await store.flash?.progress?.result;
if (result?.status == "success") {
stepSignal.next();
}
stepSignal.next();
});
const handleCancel = async () => {

View File

@@ -165,23 +165,23 @@ export default meta;
type Story = StoryObj<typeof ServiceWorkflow>;
export const Default: Story = {
args: {},
};
export const SelectRoleMembers: Story = {
render: () => (
<ServiceWorkflow
handleSubmit={(instance) => {
console.log("Submitted instance:", instance);
}}
onClose={() => {
console.log("Closed");
}}
initialStep="select:members"
initialStore={{
currentRole: "peer",
}}
/>
),
};
// export const Default: Story = {
// args: {},
// };
//
// export const SelectRoleMembers: Story = {
// render: () => (
// <ServiceWorkflow
// handleSubmit={(instance) => {
// console.log("Submitted instance:", instance);
// }}
// onClose={() => {
// console.log("Closed");
// }}
// initialStep="select:members"
// initialStore={{
// currentRole: "peer",
// }}
// />
// ),
// };

View File

@@ -9,7 +9,11 @@
"esModuleInterop": true,
"jsx": "preserve",
"jsxImportSource": "solid-js",
"types": ["vite/client", "vite-plugin-solid-svg/types-component-solid"],
"types": [
"vite/client",
"vite-plugin-solid-svg/types-component-solid",
"@vitest/browser/providers/playwright"
],
"noEmit": true,
"resolveJsonModule": true,
"allowJs": true,

View File

@@ -13,6 +13,8 @@ const dirname =
import viteConfig from "./vite.config";
const browser = process.env.BROWSER || "chromium";
export default mergeConfig(
viteConfig,
defineConfig({
@@ -40,7 +42,15 @@ export default mergeConfig(
enabled: true,
headless: true,
provider: "playwright",
instances: [{ browser: "chromium" }],
instances: [
{
browser: "chromium",
launch: {
// we specify this explicitly to avoid the version matching that playwright tries to do
executablePath: process.env.PLAYWRIGHT_CHROMIUM_EXECUTABLE,
},
},
],
},
// This setup file applies Storybook project annotations for Vitest
// More info at: https://storybook.js.org/docs/api/portable-stories/portable-stories-vitest#setprojectannotations

View File

@@ -75,13 +75,14 @@ class TestFlake(Flake):
def path(self) -> Path:
return self.test_dir
def select_machine(self, machine_name: str, selector: str) -> Any:
"""Select a nix attribute for a specific machine.
def machine_selector(self, machine_name: str, selector: str) -> str:
"""Create a selector for a specific machine.
Args:
machine_name: The name of the machine
selector: The attribute selector string relative to the machine config
apply: Optional function to apply to the result
Returns:
The full selector string for the machine
"""
config = nix_config()
@@ -89,9 +90,7 @@ class TestFlake(Flake):
test_system = system
if system.endswith("-darwin"):
test_system = system.rstrip("darwin") + "linux"
full_selector = f'checks."{test_system}".{self.check_attr}.machinesCross.{system}."{machine_name}".{selector}'
return self.select(full_selector)
return f'checks."{test_system}".{self.check_attr}.machinesCross."{system}"."{machine_name}".{selector}'
# we don't want to evaluate all machines of the flake. Only the ones defined in the test
def set_machine_names(self, machine_names: list[str]) -> None:

View File

@@ -158,8 +158,10 @@ def encrypt_secret(
admin_keys = sops.ensure_admin_public_keys(flake_dir)
if not admin_keys:
# TODO double check the correct command to run
msg = "No keys found. Please run 'clan secrets add-key' to add a key."
msg = (
"No admin keys found.\n\n"
"Please run 'clan vars keygen' to generate and set up keys."
)
raise ClanError(msg)
username = next(iter(admin_keys)).username

View File

@@ -355,7 +355,10 @@ def get_public_age_key_from_private_key(privkey: str) -> str:
cmd = nix_shell(["age"], ["age-keygen", "-y"])
error_msg = "Failed to get public key for age private key. Is the key malformed?"
res = run(cmd, RunOpts(input=privkey.encode(), error_msg=error_msg))
res = run(
cmd,
RunOpts(input=privkey.encode(), error_msg=error_msg, sensitive_input=True),
)
return res.stdout.rstrip(os.linesep).rstrip()

View File

@@ -1,24 +0,0 @@
{
# Use this path to our repo root e.g. for UI test
# inputs.clan-core.url = "../../../../.";
# this placeholder is replaced by the path to nixpkgs
inputs.clan-core.url = "__CLAN_CORE__";
outputs =
{ self, clan-core }:
let
clan = clan-core.lib.clan {
inherit self;
meta.name = "test_flake_with_core_dynamic_machines";
machines =
let
machineModules = builtins.readDir (self + "/machines");
in
builtins.mapAttrs (name: _type: import (self + "/machines/${name}")) machineModules;
};
in
{
inherit (clan.config) nixosConfigurations nixosModules clanInternals;
};
}

View File

@@ -166,16 +166,16 @@ def test_generate_public_and_secret_vars(
assert shared_value.startswith("shared")
vars_text = stringify_all_vars(machine)
flake_obj = Flake(str(flake.path))
my_generator = Generator("my_generator", machine="my_machine", _flake=flake_obj)
my_generator = Generator("my_generator", machines=["my_machine"], _flake=flake_obj)
shared_generator = Generator(
"my_shared_generator",
share=True,
machine="my_machine",
machines=["my_machine"],
_flake=flake_obj,
)
dependent_generator = Generator(
"dependent_generator",
machine="my_machine",
machines=["my_machine"],
_flake=flake_obj,
)
in_repo_store = in_repo.FactStore(flake=flake_obj)
@@ -340,12 +340,12 @@ def test_generate_secret_var_sops_with_default_group(
flake_obj = Flake(str(flake.path))
first_generator = Generator(
"first_generator",
machine="my_machine",
machines=["my_machine"],
_flake=flake_obj,
)
second_generator = Generator(
"second_generator",
machine="my_machine",
machines=["my_machine"],
_flake=flake_obj,
)
in_repo_store = in_repo.FactStore(flake=flake_obj)
@@ -375,13 +375,13 @@ def test_generate_secret_var_sops_with_default_group(
first_generator_with_share = Generator(
"first_generator",
share=False,
machine="my_machine",
machines=["my_machine"],
_flake=flake_obj,
)
second_generator_with_share = Generator(
"second_generator",
share=False,
machine="my_machine",
machines=["my_machine"],
_flake=flake_obj,
)
assert sops_store.user_has_access("user2", first_generator_with_share, "my_secret")
@@ -432,7 +432,6 @@ def test_generated_shared_secret_sops(
assert check_vars(machine1.name, machine1.flake)
cli.run(["vars", "generate", "--flake", str(flake.path), "machine2"])
assert check_vars(machine2.name, machine2.flake)
assert check_vars(machine2.name, machine2.flake)
m1_sops_store = sops.SecretStore(machine1.flake)
m2_sops_store = sops.SecretStore(machine2.flake)
# Create generators with machine context for testing
@@ -513,28 +512,28 @@ def test_generate_secret_var_password_store(
"my_generator",
share=False,
files=[],
machine="my_machine",
machines=["my_machine"],
_flake=flake_obj,
)
my_generator_shared = Generator(
"my_generator",
share=True,
files=[],
machine="my_machine",
machines=["my_machine"],
_flake=flake_obj,
)
my_shared_generator = Generator(
"my_shared_generator",
share=True,
files=[],
machine="my_machine",
machines=["my_machine"],
_flake=flake_obj,
)
my_shared_generator_not_shared = Generator(
"my_shared_generator",
share=False,
files=[],
machine="my_machine",
machines=["my_machine"],
_flake=flake_obj,
)
assert store.exists(my_generator, "my_secret")
@@ -546,7 +545,7 @@ def test_generate_secret_var_password_store(
name="my_generator",
share=False,
files=[],
machine="my_machine",
machines=["my_machine"],
_flake=flake_obj,
)
assert store.get(generator, "my_secret").decode() == "hello\n"
@@ -557,7 +556,7 @@ def test_generate_secret_var_password_store(
"my_generator",
share=False,
files=[],
machine="my_machine",
machines=["my_machine"],
_flake=flake_obj,
)
var_name = "my_secret"
@@ -570,7 +569,7 @@ def test_generate_secret_var_password_store(
"my_generator2",
share=False,
files=[],
machine="my_machine",
machines=["my_machine"],
_flake=flake_obj,
)
var_name = "my_secret2"
@@ -582,7 +581,7 @@ def test_generate_secret_var_password_store(
"my_shared_generator",
share=True,
files=[],
machine="my_machine",
machines=["my_machine"],
_flake=flake_obj,
)
var_name = "my_shared_secret"
@@ -629,8 +628,8 @@ def test_generate_secret_for_multiple_machines(
in_repo_store2 = in_repo.FactStore(flake=flake_obj)
# Create generators for each machine
gen1 = Generator("my_generator", machine="machine1", _flake=flake_obj)
gen2 = Generator("my_generator", machine="machine2", _flake=flake_obj)
gen1 = Generator("my_generator", machines=["machine1"], _flake=flake_obj)
gen2 = Generator("my_generator", machines=["machine2"], _flake=flake_obj)
assert in_repo_store1.exists(gen1, "my_value")
assert in_repo_store2.exists(gen2, "my_value")
@@ -694,12 +693,12 @@ def test_prompt(
# Set up objects for testing the results
flake_obj = Flake(str(flake.path))
my_generator = Generator("my_generator", machine="my_machine", _flake=flake_obj)
my_generator = Generator("my_generator", machines=["my_machine"], _flake=flake_obj)
my_generator_with_details = Generator(
name="my_generator",
share=False,
files=[],
machine="my_machine",
machines=["my_machine"],
_flake=flake_obj,
)
@@ -784,10 +783,10 @@ def test_shared_vars_regeneration(
in_repo_store_2 = in_repo.FactStore(machine2.flake)
# Create generators with machine context for testing
child_gen_m1 = Generator(
"child_generator", share=False, machine="machine1", _flake=machine1.flake
"child_generator", share=False, machines=["machine1"], _flake=machine1.flake
)
child_gen_m2 = Generator(
"child_generator", share=False, machine="machine2", _flake=machine2.flake
"child_generator", share=False, machines=["machine2"], _flake=machine2.flake
)
# generate for machine 1
cli.run(["vars", "generate", "--flake", str(flake.path), "machine1"])
@@ -855,13 +854,13 @@ def test_multi_machine_shared_vars(
generator_m1 = Generator(
"shared_generator",
share=True,
machine="machine1",
machines=["machine1"],
_flake=machine1.flake,
)
generator_m2 = Generator(
"shared_generator",
share=True,
machine="machine2",
machines=["machine2"],
_flake=machine2.flake,
)
# generate for machine 1
@@ -917,7 +916,9 @@ def test_api_set_prompts(
)
machine = Machine(name="my_machine", flake=Flake(str(flake.path)))
store = in_repo.FactStore(machine.flake)
my_generator = Generator("my_generator", machine="my_machine", _flake=machine.flake)
my_generator = Generator(
"my_generator", machines=["my_machine"], _flake=machine.flake
)
assert store.exists(my_generator, "prompt1")
assert store.get(my_generator, "prompt1").decode() == "input1"
run_generators(
@@ -1061,10 +1062,10 @@ def test_migration(
assert "Migrated var my_generator/my_value" in caplog.text
assert "Migrated secret var my_generator/my_secret" in caplog.text
flake_obj = Flake(str(flake.path))
my_generator = Generator("my_generator", machine="my_machine", _flake=flake_obj)
my_generator = Generator("my_generator", machines=["my_machine"], _flake=flake_obj)
other_generator = Generator(
"other_generator",
machine="my_machine",
machines=["my_machine"],
_flake=flake_obj,
)
in_repo_store = in_repo.FactStore(flake=flake_obj)
@@ -1210,7 +1211,7 @@ def test_share_mode_switch_regenerates_secret(
sops_store = sops.SecretStore(flake=flake_obj)
generator_not_shared = Generator(
"my_generator", share=False, machine="my_machine", _flake=flake_obj
"my_generator", share=False, machines=["my_machine"], _flake=flake_obj
)
initial_public = in_repo_store.get(generator_not_shared, "my_value").decode()
@@ -1229,7 +1230,7 @@ def test_share_mode_switch_regenerates_secret(
# Read the new values with shared generator
generator_shared = Generator(
"my_generator", share=True, machine="my_machine", _flake=flake_obj
"my_generator", share=True, machines=["my_machine"], _flake=flake_obj
)
new_public = in_repo_store.get(generator_shared, "my_value").decode()
@@ -1264,68 +1265,117 @@ def test_cache_misses_for_vars_operations(
flake: ClanFlake,
) -> None:
"""Test that vars operations result in minimal cache misses."""
# Set up first machine with two generators
config = flake.machines["my_machine"] = create_test_machine_config()
# Set up a simple generator with a public value
my_generator = config["clan"]["core"]["vars"]["generators"]["my_generator"]
my_generator["files"]["my_value"]["secret"] = False
my_generator["script"] = 'echo -n "test_value" > "$out"/my_value'
# Set up two generators with public values
gen1 = config["clan"]["core"]["vars"]["generators"]["gen1"]
gen1["files"]["value1"]["secret"] = False
gen1["script"] = 'echo -n "test_value1" > "$out"/value1'
gen2 = config["clan"]["core"]["vars"]["generators"]["gen2"]
gen2["files"]["value2"]["secret"] = False
gen2["script"] = 'echo -n "test_value2" > "$out"/value2'
# Add a second machine with the same generator configuration
flake.machines["other_machine"] = config.copy()
flake.refresh()
monkeypatch.chdir(flake.path)
# Create a fresh machine object to ensure clean cache state
machine = Machine(name="my_machine", flake=Flake(str(flake.path)))
# Create fresh machine objects to ensure clean cache state
flake_obj = Flake(str(flake.path))
machine1 = Machine(name="my_machine", flake=flake_obj)
machine2 = Machine(name="other_machine", flake=flake_obj)
# Test 1: Running vars generate with a fresh cache should result in exactly 3 cache misses
# Expected cache misses:
# 1. One for getting the list of generators
# 2. One for getting the final script of our test generator (my_generator)
# 3. One for getting the final script of the state version generator (added by default)
# TODO: The third cache miss is undesired in tests. disable state version module for tests
# Test 1: Running vars generate for BOTH machines simultaneously should still result in exactly 2 cache misses
# Even though we have:
# - 2 machines (my_machine and other_machine)
# - 2 generators per machine (gen1 and gen2)
# We still only get 2 cache misses when generating for both machines:
# 1. One for getting the list of generators for both machines
# 2. One batched evaluation for getting all generator scripts for both machines
# The key insight: the system should batch ALL evaluations across ALL machines into a single nix eval
run_generators(
machines=[machine],
machines=[machine1, machine2],
generators=None, # Generate all
)
# Print stack traces if we have more than 3 cache misses
if machine.flake._cache_misses != 3:
machine.flake.print_cache_miss_analysis(
# Print stack traces if we have more than 2 cache misses
if flake_obj._cache_misses != 2:
flake_obj.print_cache_miss_analysis(
title="Cache miss analysis for vars generate"
)
assert machine.flake._cache_misses == 2, (
f"Expected exactly 2 cache misses for vars generate, got {machine.flake._cache_misses}"
assert flake_obj._cache_misses == 2, (
f"Expected exactly 2 cache misses for vars generate, got {flake_obj._cache_misses}"
)
# Verify the value was generated correctly
var_value = get_machine_var(machine, "my_generator/my_value")
assert var_value.printable_value == "test_value"
# Test 2: List all vars should result in exactly 1 cache miss
# Force cache invalidation (this also resets cache miss tracking)
invalidate_flake_cache(flake.path)
machine.flake.invalidate_cache()
flake_obj.invalidate_cache()
stringify_all_vars(machine)
assert machine.flake._cache_misses == 1, (
f"Expected exactly 1 cache miss for vars list, got {machine.flake._cache_misses}"
stringify_all_vars(machine1)
assert flake_obj._cache_misses == 1, (
f"Expected exactly 1 cache miss for vars list, got {flake_obj._cache_misses}"
)
# Test 3: Getting a specific var with a fresh cache should result in exactly 1 cache miss
# Force cache invalidation (this also resets cache miss tracking)
invalidate_flake_cache(flake.path)
machine.flake.invalidate_cache()
flake_obj.invalidate_cache()
var_value = get_machine_var(machine, "my_generator/my_value")
assert var_value.printable_value == "test_value"
# Only test gen1 for the get operation
var_value = get_machine_var(machine1, "gen1/value1")
assert var_value.printable_value == "test_value1"
assert machine.flake._cache_misses == 1, (
f"Expected exactly 1 cache miss for vars get with fresh cache, got {machine.flake._cache_misses}"
assert flake_obj._cache_misses == 1, (
f"Expected exactly 1 cache miss for vars get with fresh cache, got {flake_obj._cache_misses}"
)
@pytest.mark.with_core
def test_shared_generator_conflicting_definition_raises_error(
monkeypatch: pytest.MonkeyPatch,
flake_with_sops: ClanFlake,
) -> None:
"""Test that vars generation raises an error when two machines have different
definitions for the same shared generator.
"""
flake = flake_with_sops
# Create machine1 with a shared generator
machine1_config = flake.machines["machine1"] = create_test_machine_config()
shared_gen1 = machine1_config["clan"]["core"]["vars"]["generators"][
"shared_generator"
]
shared_gen1["share"] = True
shared_gen1["files"]["file1"]["secret"] = False
shared_gen1["script"] = 'echo "test" > "$out"/file1'
# Create machine2 with the same shared generator but different files
machine2_config = flake.machines["machine2"] = create_test_machine_config()
shared_gen2 = machine2_config["clan"]["core"]["vars"]["generators"][
"shared_generator"
]
shared_gen2["share"] = True
shared_gen2["files"]["file2"]["secret"] = False # Different file name
shared_gen2["script"] = 'echo "test" > "$out"/file2'
flake.refresh()
monkeypatch.chdir(flake.path)
# Attempting to generate vars for both machines should raise an error
# because they have conflicting definitions for the same shared generator
with pytest.raises(
ClanError,
match=".*differ.*",
):
cli.run(["vars", "generate", "--flake", str(flake.path)])
@pytest.mark.with_core
def test_dynamic_invalidation(
monkeypatch: pytest.MonkeyPatch,

View File

@@ -40,12 +40,15 @@ class StoreBase(ABC):
def get_machine(self, generator: "Generator") -> str:
"""Get machine name from generator, asserting it's not None for now."""
if generator.machine is None:
if generator.share:
return "__shared"
if generator.share:
return "__shared"
if not generator.machines:
msg = f"Generator '{generator.name}' has no machine associated"
raise ClanError(msg)
return generator.machine
if len(generator.machines) != 1:
msg = f"Generator '{generator.name}' has {len(generator.machines)} machines, expected exactly 1"
raise ClanError(msg)
return generator.machines[0]
# get a single fact
@abstractmethod
@@ -147,7 +150,7 @@ class StoreBase(ABC):
prev_generator = dataclasses.replace(
generator,
share=not generator.share,
machine=machine if generator.share else None,
machines=[] if not generator.share else [machine],
)
if self.exists(prev_generator, var.name):
changed_files += self.delete(prev_generator, var.name)
@@ -165,12 +168,12 @@ class StoreBase(ABC):
new_file = self._set(generator, var, value, machine)
action_str = "Migrated" if is_migration else "Updated"
log_info: Callable
if generator.machine is None:
if generator.share:
log_info = log.info
else:
from clan_lib.machines.machines import Machine # noqa: PLC0415
machine_obj = Machine(name=generator.machine, flake=self.flake)
machine_obj = Machine(name=generator.machines[0], flake=self.flake)
log_info = machine_obj.info
if self.is_secret_store:
log.info(f"{action_str} secret var {generator.name}/{var.name}\n")

View File

@@ -61,14 +61,22 @@ class Generator:
migrate_fact: str | None = None
validation_hash: str | None = None
machine: str | None = None
machines: list[str] = field(default_factory=list)
_flake: "Flake | None" = None
_public_store: "StoreBase | None" = None
_secret_store: "StoreBase | None" = None
@property
def key(self) -> GeneratorKey:
return GeneratorKey(machine=self.machine, name=self.name)
if self.share:
# must be a shared generator
machine = None
elif len(self.machines) != 1:
msg = f"Shared generator {self.name} must have exactly one machine, but has {len(self.machines)}: {', '.join(self.machines)}"
raise ClanError(msg)
else:
machine = self.machines[0]
return GeneratorKey(machine=machine, name=self.name)
def __hash__(self) -> int:
return hash(self.key)
@@ -143,7 +151,10 @@ class Generator:
files_selector = "config.clan.core.vars.generators.*.files.*.{secret,deploy,owner,group,mode,neededFor}"
flake.precache(cls.get_machine_selectors(machine_names))
generators = []
generators: list[Generator] = []
shared_generators_raw: dict[
str, tuple[str, dict, dict]
] = {} # name -> (machine_name, gen_data, files_data)
for machine_name in machine_names:
# Get all generator metadata in one select (safe fields only)
@@ -165,6 +176,38 @@ class Generator:
sec_store = machine.secret_vars_store
for gen_name, gen_data in generators_data.items():
# Check for conflicts in shared generator definitions using raw data
if gen_data["share"]:
if gen_name in shared_generators_raw:
prev_machine, prev_gen_data, prev_files_data = (
shared_generators_raw[gen_name]
)
# Compare raw data
prev_gen_files = prev_files_data.get(gen_name, {})
curr_gen_files = files_data.get(gen_name, {})
# Build list of differences with details
differences = []
if prev_gen_files != curr_gen_files:
differences.append("files")
if prev_gen_data.get("prompts") != gen_data.get("prompts"):
differences.append("prompts")
if prev_gen_data.get("dependencies") != gen_data.get(
"dependencies"
):
differences.append("dependencies")
if prev_gen_data.get("validationHash") != gen_data.get(
"validationHash"
):
differences.append("validation_hash")
if differences:
msg = f"Machines {prev_machine} and {machine_name} have different definitions for shared generator '{gen_name}' (differ in: {', '.join(differences)})"
raise ClanError(msg)
else:
shared_generators_raw[gen_name] = (
machine_name,
gen_data,
files_data,
)
# Build files from the files_data
files = []
gen_files = files_data.get(gen_name, {})
@@ -209,14 +252,27 @@ class Generator:
migrate_fact=gen_data.get("migrateFact"),
validation_hash=gen_data.get("validationHash"),
prompts=prompts,
# only set machine for machine-specific generators
# this is essential for the graph algorithms to work correctly
machine=None if share else machine_name,
# shared generators can have multiple machines, machine-specific have one
machines=[machine_name],
_flake=flake,
_public_store=pub_store,
_secret_store=sec_store,
)
generators.append(generator)
if share:
# For shared generators, check if we already created it
existing = next(
(g for g in generators if g.name == gen_name and g.share), None
)
if existing:
# Just append the machine to the existing generator
existing.machines.append(machine_name)
else:
# Add the new shared generator
generators.append(generator)
else:
# Always add per-machine generators
generators.append(generator)
# TODO: This should be done in a non-mutable way.
if include_previous_values:
@@ -245,15 +301,19 @@ class Generator:
return sec_store.get(self, prompt.name).decode()
return None
def final_script_selector(self, machine_name: str) -> str:
if self._flake is None:
msg = "Flake cannot be None"
raise ClanError(msg)
return self._flake.machine_selector(
machine_name, f'config.clan.core.vars.generators."{self.name}".finalScript'
)
def final_script(self, machine: "Machine") -> Path:
if self._flake is None:
msg = "Flake cannot be None"
raise ClanError(msg)
output = Path(
machine.select(
f'config.clan.core.vars.generators."{self.name}".finalScript',
),
)
output = Path(self._flake.select(self.final_script_selector(machine.name)))
if tmp_store := nix_test_store():
output = tmp_store.joinpath(*output.parts[1:])
return output

View File

@@ -49,28 +49,28 @@ def test_required_generators() -> None:
gen_1 = Generator(
name="gen_1",
dependencies=[],
machine=machine_name,
machines=[machine_name],
_public_store=public_store,
_secret_store=secret_store,
)
gen_2 = Generator(
name="gen_2",
dependencies=[gen_1.key],
machine=machine_name,
machines=[machine_name],
_public_store=public_store,
_secret_store=secret_store,
)
gen_2a = Generator(
name="gen_2a",
dependencies=[gen_2.key],
machine=machine_name,
machines=[machine_name],
_public_store=public_store,
_secret_store=secret_store,
)
gen_2b = Generator(
name="gen_2b",
dependencies=[gen_2.key],
machine=machine_name,
machines=[machine_name],
_public_store=public_store,
_secret_store=secret_store,
)
@@ -118,21 +118,22 @@ def test_shared_generator_invalidates_multiple_machines_dependents() -> None:
shared_gen = Generator(
name="shared_gen",
dependencies=[],
machine=None, # Shared generator
share=True, # Mark as shared generator
machines=[machine_1, machine_2], # Shared across both machines
_public_store=public_store,
_secret_store=secret_store,
)
gen_1 = Generator(
name="gen_1",
dependencies=[shared_gen.key],
machine=machine_1,
machines=[machine_1],
_public_store=public_store,
_secret_store=secret_store,
)
gen_2 = Generator(
name="gen_2",
dependencies=[shared_gen.key],
machine=machine_2,
machines=[machine_2],
_public_store=public_store,
_secret_store=secret_store,
)

View File

@@ -294,6 +294,8 @@ class RunOpts:
# This is needed for GUI applications
graphical_perm: bool = False
trace: bool = True
# Mark input as sensitive to prevent it from being logged (e.g., private keys, passwords)
sensitive_input: bool = False
def cmd_with_root(cmd: list[str], graphical: bool = False) -> list[str]:
@@ -349,7 +351,10 @@ def run(
if cmdlog.isEnabledFor(logging.DEBUG) and options.trace:
if options.input and isinstance(options.input, bytes):
if any(
# Always redact sensitive input (e.g., private keys, passwords)
if options.sensitive_input:
filtered_input = "<<REDACTED>>"
elif any(
not ch.isprintable() for ch in options.input.decode("ascii", "replace")
):
filtered_input = "<<binary_blob>>"

View File

@@ -1132,6 +1132,20 @@ class Flake:
return self._cache.select(selector)
def machine_selector(self, machine_name: str, selector: str) -> str:
"""Create a selector for a specific machine.
Args:
machine_name: The name of the machine
selector: The attribute selector string relative to the machine config
Returns:
The full selector string for the machine
"""
config = nix_config()
system = config["system"]
return f'clanInternals.machines."{system}"."{machine_name}".{selector}'
def select_machine(self, machine_name: str, selector: str) -> Any:
"""Select a nix attribute for a specific machine.
@@ -1141,11 +1155,7 @@ class Flake:
apply: Optional function to apply to the result
"""
config = nix_config()
system = config["system"]
full_selector = f'clanInternals.machines."{system}"."{machine_name}".{selector}'
return self.select(full_selector)
return self.select(self.machine_selector(machine_name, selector))
def list_machines(
self,

View File

@@ -119,6 +119,9 @@ def run_machine_hardware_info_init(
if opts.debug:
cmd += ["--debug"]
# Add nix options to nixos-anywhere
cmd.extend(opts.machine.flake.nix_options or [])
cmd += [target_host.target]
cmd = nix_shell(
["nixos-anywhere"],

View File

@@ -136,92 +136,123 @@ def networks_from_flake(flake: Flake) -> dict[str, Network]:
return networks
@contextmanager
def get_best_remote(machine: "Machine") -> Iterator["Remote"]:
"""Context manager that yields the best remote connection for a machine following this priority:
1. If machine has targetHost in inventory, return a direct connection
2. Return the highest priority network where machine is reachable
3. If no network works, try to get targetHost from machine nixos config
class BestRemoteContext:
"""Class-based context manager for establishing and maintaining network connections."""
Args:
machine: Machine instance to connect to
def __init__(self, machine: "Machine") -> None:
self.machine = machine
self._network_ctx: Any = None
self._remote: Remote | None = None
Yields:
Remote object for connecting to the machine
def __enter__(self) -> "Remote":
"""Establish the best remote connection for a machine following this priority:
1. If machine has targetHost in inventory, return a direct connection
2. Return the highest priority network where machine is reachable
3. If no network works, try to get targetHost from machine nixos config
Raises:
ClanError: If no connection method works
Returns:
Remote object for connecting to the machine
"""
# Step 1: Check if targetHost is set in inventory
inv_machine = machine.get_inv_machine()
target_host = inv_machine.get("deploy", {}).get("targetHost")
Raises:
ClanError: If no connection method works
if target_host:
log.debug(f"Using targetHost from inventory for {machine.name}: {target_host}")
# Create a direct network with just this machine
remote = Remote.from_ssh_uri(machine_name=machine.name, address=target_host)
yield remote
return
"""
# Step 1: Check if targetHost is set in inventory
inv_machine = self.machine.get_inv_machine()
target_host = inv_machine.get("deploy", {}).get("targetHost")
# Step 2: Try existing networks by priority
try:
networks = networks_from_flake(machine.flake)
if target_host:
log.debug(
f"Using targetHost from inventory for {self.machine.name}: {target_host}"
)
self._remote = Remote.from_ssh_uri(
machine_name=self.machine.name, address=target_host
)
return self._remote
sorted_networks = sorted(networks.items(), key=lambda x: -x[1].priority)
# Step 2: Try existing networks by priority
try:
networks = networks_from_flake(self.machine.flake)
sorted_networks = sorted(networks.items(), key=lambda x: -x[1].priority)
for network_name, network in sorted_networks:
if machine.name not in network.peers:
continue
for network_name, network in sorted_networks:
if self.machine.name not in network.peers:
continue
# Check if network is running and machine is reachable
log.debug(f"trying to connect via {network_name}")
if network.is_running():
try:
ping_time = network.ping(machine.name)
if ping_time is not None:
log.info(
f"Machine {machine.name} reachable via {network_name} network",
)
yield network.remote(machine.name)
return
except ClanError as e:
log.debug(f"Failed to reach {machine.name} via {network_name}: {e}")
else:
try:
log.debug(f"Establishing connection for network {network_name}")
with network.module.connection(network) as connected_network:
ping_time = connected_network.ping(machine.name)
log.debug(f"trying to connect via {network_name}")
if network.is_running():
try:
ping_time = network.ping(self.machine.name)
if ping_time is not None:
log.info(
f"Machine {machine.name} reachable via {network_name} network after connection",
f"Machine {self.machine.name} reachable via {network_name} network",
)
yield connected_network.remote(machine.name)
return
except ClanError as e:
log.debug(
f"Failed to establish connection to {machine.name} via {network_name}: {e}",
)
except (ImportError, AttributeError, KeyError) as e:
log.debug(f"Failed to use networking modules to determine machines remote: {e}")
self._remote = remote = network.remote(self.machine.name)
return remote
except ClanError as e:
log.debug(
f"Failed to reach {self.machine.name} via {network_name}: {e}"
)
else:
try:
log.debug(f"Establishing connection for network {network_name}")
# Enter the network context and keep it alive
self._network_ctx = network.module.connection(network)
connected_network = self._network_ctx.__enter__()
ping_time = connected_network.ping(self.machine.name)
if ping_time is not None:
log.info(
f"Machine {self.machine.name} reachable via {network_name} network after connection",
)
self._remote = remote = connected_network.remote(
self.machine.name
)
return remote
# Ping failed, clean up this connection attempt
self._network_ctx.__exit__(None, None, None)
self._network_ctx = None
except ClanError as e:
# Clean up failed connection attempt
if self._network_ctx is not None:
self._network_ctx.__exit__(None, None, None)
self._network_ctx = None
log.debug(
f"Failed to establish connection to {self.machine.name} via {network_name}: {e}",
)
except (ImportError, AttributeError, KeyError) as e:
log.debug(
f"Failed to use networking modules to determine machines remote: {e}"
)
# Step 3: Try targetHost from machine nixos config
target_host = machine.select('config.clan.core.networking."targetHost"')
if target_host:
log.debug(
f"Using targetHost from machine config for {machine.name}: {target_host}",
)
# Check if reachable
remote = Remote.from_ssh_uri(
machine_name=machine.name,
address=target_host,
)
yield remote
return
# Step 3: Try targetHost from machine nixos config
target_host = self.machine.select('config.clan.core.networking."targetHost"')
if target_host:
log.debug(
f"Using targetHost from machine config for {self.machine.name}: {target_host}",
)
self._remote = Remote.from_ssh_uri(
machine_name=self.machine.name,
address=target_host,
)
return self._remote
# No connection method found
msg = f"Could not find any way to connect to machine '{machine.name}'. No targetHost configured and machine not reachable via any network."
raise ClanError(msg)
# No connection method found
msg = f"Could not find any way to connect to machine '{self.machine.name}'. No targetHost configured and machine not reachable via any network."
raise ClanError(msg)
def __exit__(
self,
exc_type: type[BaseException] | None,
exc_val: BaseException | None,
exc_tb: object,
) -> None:
"""Clean up network connection if one was established."""
if self._network_ctx is not None:
self._network_ctx.__exit__(exc_type, exc_val, exc_tb)
def get_best_remote(machine: "Machine") -> BestRemoteContext:
return BestRemoteContext(machine)
def get_network_overview(networks: dict[str, Network]) -> dict:

View File

@@ -93,21 +93,21 @@ def _ensure_healthy(
if generators is None:
generators = Generator.get_machine_generators([machine.name], machine.flake)
pub_healtcheck_msg = machine.public_vars_store.health_check(
public_health_check_msg = machine.public_vars_store.health_check(
machine.name,
generators,
)
sec_healtcheck_msg = machine.secret_vars_store.health_check(
secret_health_check_msg = machine.secret_vars_store.health_check(
machine.name,
generators,
)
if pub_healtcheck_msg or sec_healtcheck_msg:
if public_health_check_msg or secret_health_check_msg:
msg = f"Health check failed for machine {machine.name}:\n"
if pub_healtcheck_msg:
msg += f"Public vars store: {pub_healtcheck_msg}\n"
if sec_healtcheck_msg:
msg += f"Secret vars store: {sec_healtcheck_msg}"
if public_health_check_msg:
msg += f"Public vars store: {public_health_check_msg}\n"
if secret_health_check_msg:
msg += f"Secret vars store: {secret_health_check_msg}"
raise ClanError(msg)
@@ -177,13 +177,25 @@ def run_generators(
for machine in machines:
_ensure_healthy(machine=machine)
# get the flake via any machine (they are all the same)
flake = machines[0].flake
def get_generator_machine(generator: Generator) -> Machine:
if generator.share:
# return first machine if generator is shared
return machines[0]
return Machine(name=generator.machines[0], flake=flake)
# preheat the select cache, to reduce repeated calls during execution
selectors = []
for generator in generator_objects:
machine = get_generator_machine(generator)
selectors.append(generator.final_script_selector(machine.name))
flake.precache(selectors)
# execute generators
for generator in generator_objects:
machine = (
machines[0]
if generator.machine is None
else Machine(name=generator.machine, flake=machines[0].flake)
)
machine = get_generator_machine(generator)
if check_can_migrate(machine, generator):
migrate_files(machine, generator)
else:

View File

@@ -0,0 +1,71 @@
{ self, inputs, ... }:
{
perSystem =
{ pkgs, self', ... }:
let
# Simply evaluated options (JSON)
# { clanCore = «derivation JSON»; clanModules = { ${name} = «derivation JSON» }; }
jsonDocs = pkgs.callPackage ./get-module-docs.nix {
inherit (self) clanModules;
clan-core = self;
inherit pkgs;
};
# clan service options
clanModulesViaService = pkgs.writeText "info.json" (builtins.toJSON jsonDocs.clanModulesViaService);
# Simply evaluated options (JSON)
renderOptions =
pkgs.runCommand "render-options"
{
# TODO: ruff does not splice properly in nativeBuildInputs
depsBuildBuild = [ pkgs.ruff ];
nativeBuildInputs = [
pkgs.python3
pkgs.mypy
self'.packages.clan-cli
];
}
''
install -D -m755 ${./generate}/__init__.py $out/bin/render-options
patchShebangs --build $out/bin/render-options
ruff format --check --diff $out/bin/render-options
ruff check --line-length 88 $out/bin/render-options
mypy --strict $out/bin/render-options
'';
module-docs =
pkgs.runCommand "rendered"
{
buildInputs = [
pkgs.python3
self'.packages.clan-cli
];
}
''
export CLAN_CORE_PATH=${
inputs.nixpkgs.lib.fileset.toSource {
root = ../..;
fileset = ../../clanModules;
}
}
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_SERVICE=${clanModulesViaService}
export CLAN_SERVICE_INTERFACE=${self'.legacyPackages.clan-service-module-interface}/share/doc/nixos/options.json
export CLAN_OPTIONS_PATH=${self'.legacyPackages.clan-options}/share/doc/nixos/options.json
mkdir $out
# The python script will place mkDocs files in the output directory
exec python3 ${renderOptions}/bin/render-options
'';
in
{
packages = {
inherit module-docs;
};
};
}

View File

@@ -2,12 +2,14 @@
{
imports = [
./clan-cli/flake-module.nix
./clan-vm-manager/flake-module.nix
./installer/flake-module.nix
./icon-update/flake-module.nix
./clan-core-flake/flake-module.nix
./clan-app/flake-module.nix
./clan-cli/flake-module.nix
./clan-core-flake/flake-module.nix
./clan-vm-manager/flake-module.nix
./icon-update/flake-module.nix
./installer/flake-module.nix
./option-search/flake-module.nix
./docs-from-code/flake-module.nix
./testing/flake-module.nix
];

View File

@@ -24,7 +24,7 @@
serviceModules = self.clan.modules;
baseHref = "/options/";
baseHref = "/option-search/";
getRoles =
module:
@@ -118,7 +118,7 @@
_file = "docs flake-module";
imports = [
{ _module.args = { inherit clanLib; }; }
(import ../../../lib/modules/inventoryClass/roles-interface.nix {
(import ../../lib/modules/inventoryClass/roles-interface.nix {
nestedSettingsOption = mkOption {
type = types.raw;
description = ''
@@ -201,7 +201,7 @@
# };
packages = {
docs-options =
option-search =
if privateInputs ? nuschtos then
privateInputs.nuschtos.packages.${pkgs.stdenv.hostPlatform.system}.mkMultiSearch {
inherit baseHref;