Compare commits

..

3 Commits

Author SHA1 Message Date
a-kenji
cb2054ff10 s 2025-10-08 00:20:29 +02:00
a-kenji
841a8edbdd s 2025-10-07 23:32:52 +02:00
a-kenji
27d9a805d9 WIP: Fix carriage return 2025-10-07 09:16:22 +02:00
68 changed files with 934 additions and 1473 deletions

View File

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

View File

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

18
devFlake/flake.lock generated
View File

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

2
docs/.gitignore vendored
View File

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

View File

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

41
docs/main.py Normal file
View File

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

View File

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

View File

@@ -1,5 +1,8 @@
{ inputs, ... }: { inputs, self, ... }:
{ {
imports = [
./options/flake-module.nix
];
perSystem = perSystem =
{ {
config, config,
@@ -7,7 +10,83 @@
pkgs, 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: { devShells.docs = self'.packages.docs.overrideAttrs (_old: {
nativeBuildInputs = [ nativeBuildInputs = [
# Run: htmlproofer --disable-external # Run: htmlproofer --disable-external
@@ -26,20 +105,22 @@
docs = pkgs.python3.pkgs.callPackage ./default.nix { docs = pkgs.python3.pkgs.callPackage ./default.nix {
inherit (self'.packages) inherit (self'.packages)
clan-cli-docs clan-cli-docs
option-search docs-options
inventory-api-docs inventory-api-docs
clan-lib-openapi clan-lib-openapi
module-docs
; ;
inherit (inputs) nixpkgs; 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; }; deploy-docs = pkgs.callPackage ./deploy-docs.nix { inherit (config.packages) docs; };
inherit module-docs;
}; };
checks.docs-integrity = checks.docs-integrity =
pkgs.runCommand "docs-integrity" pkgs.runCommand "docs-integrity"
{ {
nativeBuildInputs = [ pkgs.html-proofer ]; nativeBuildInputs = [ pkgs.html-proofer ];
LANG = "C.UTF-8";
} }
'' ''
# External links should be avoided in the docs, because they often break # External links should be avoided in the docs, because they often break

View File

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

View File

@@ -1,9 +0,0 @@
# 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 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. 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 transferring your NixOS configurations over is easy. Once you have a working setup and understand the concepts transfering your NixOS configurations over is easy.
## Back up your existing configuration ## Back up your existing configuration
Before you start, it is strongly recommended to back up your existing 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 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 your configuration changes, it is also a good idea to follow the migration
guide in a separate branch until everything works as expected. guide in a separte branch until everything works as expected.
## Starting Point ## Starting Point

View File

@@ -67,59 +67,6 @@ nix build .#checks.x86_64-linux.{test-attr-name}
``` ```
(replace `{test-attr-name}` with the name of the test) (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 ### Debugging VM tests
The following techniques can be used to debug a VM test: The following techniques can be used to debug a VM test:

View File

@@ -1,213 +0,0 @@
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` ## Option 1: Follow `clan-core`
- **Pros**: - **Pros**:
- Recommended for most users. - Recommended for most users.
- Verified by our CI and widely used by others. - Verified by our CI and widely used by others.
- **Cons**: - **Cons**:
- Coupled to version bumps in `clan-core`. - Coupled to version bumps in `clan-core`.
- Upstream features and packages may take longer to land. - Upstream features and packages may take longer to land.
Example: Example:
@@ -24,10 +24,10 @@ inputs = {
## Option 2: Use Your Own `nixpkgs` Version ## Option 2: Use Your Own `nixpkgs` Version
- **Pros**: - **Pros**:
- Faster access to new upstream features and packages. - Faster access to new upstream features and packages.
- **Cons**: - **Cons**:
- Recommended for advanced users. - Recommended for advanced users.
- Not covered by our CI — youre on the frontier. - Not covered by our CI — youre on the frontier.
Example: 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 In the inventory we the assign machines to a type, e.g. by using tags
```nix title="flake.nix" ```nix title="flake.nix"
instances.machine-type = { instnaces.machine-type = {
module.input = "self"; module.input = "self";
module.name = "@pinpox/machine-type"; module.name = "@pinpox/machine-type";
roles.desktop.tags.desktop = { }; roles.desktop.tags.desktop = { };
@@ -303,4 +303,3 @@ instances.machine-type = {
- [Reference Documentation for Service Authors](../../reference/options/clan_service.md) - [Reference Documentation for Service Authors](../../reference/options/clan_service.md)
- [Migration Guide from ClanModules to ClanServices](../../guides/migrations/migrate-inventory-services.md) - [Migration Guide from ClanModules to ClanServices](../../guides/migrations/migrate-inventory-services.md)
- [Decision that lead to ClanServices](../../decisions/01-Clan-Modules.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 command line interface
- [Clan Options](./reference/options/clan.md) - [Clan Options](/options)
--- ---

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) - Learn how to use the [Clan CLI](../reference/cli/index.md)
- Explore available [services](../services/definition.md) - Explore available [services](../services/definition.md)
- [NixOS Configuration Options](../reference/clan.core/index.md) - Additional options avilable on a NixOS machine. - [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": { "locked": {
"lastModified": 1760120816, "lastModified": 1758728421,
"narHash": "sha256-gq9rdocpmRZCwLS5vsHozwB6b5nrOBDNc2kkEaTXHfg=", "narHash": "sha256-ySNJ008muQAds2JemiyrWYbwbG+V7S5wg3ZVKGHSFu8=",
"owner": "numtide", "owner": "numtide",
"repo": "treefmt-nix", "repo": "treefmt-nix",
"rev": "761ae7aff00907b607125b2f57338b74177697ed", "rev": "5eda4ee8121f97b218f7cc73f5172098d458f1d1",
"type": "github" "type": "github"
}, },
"original": { "original": {

View File

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

View File

@@ -52,6 +52,8 @@
"checks/secrets/sops/groups/group/machines/machine" "checks/secrets/sops/groups/group/machines/machine"
"checks/syncthing/introducer/introducer_device_id" "checks/syncthing/introducer/introducer_device_id"
"checks/syncthing/introducer/introducer_test_api" "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" "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/gnupg.conf"
"pkgs/clan-cli/clan_cli/tests/data/password-store/.gpg-id" "pkgs/clan-cli/clan_cli/tests/data/password-store/.gpg-id"
@@ -92,6 +94,9 @@
"*.yaml" "*.yaml"
"*.yml" "*.yml"
]; ];
excludes = [
"*/asciinema-player/*"
];
}; };
treefmt.programs.mypy.directories = { treefmt.programs.mypy.directories = {
"clan-cli" = { "clan-cli" = {

View File

@@ -1,51 +0,0 @@
{ 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,10 +36,6 @@ lib.fix (
# TODO: Flatten our lib functions like this: # TODO: Flatten our lib functions like this:
resolveModule = clanLib.callLib ./resolve-module { }; resolveModule = clanLib.callLib ./resolve-module { };
fs = {
inherit (builtins) pathExists readDir;
};
}; };
in in
f f

View File

@@ -149,13 +149,6 @@ let
# TODO: Add index support in nixpkgs first # TODO: Add index support in nixpkgs first
# else if type.name == "listOf" then # else if type.name == "listOf" then
# handleListOf meta.list # 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 else
throw "Yet Unsupported type: ${type.name}"; throw "Yet Unsupported type: ${type.name}";
in in

View File

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

View File

@@ -1,108 +0,0 @@
{
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,7 +12,6 @@ let
in in
####### #######
{ {
autoloading = import ./dir_test.nix { inherit lib; };
test_missing_self = test_missing_self =
let let
eval = clan { eval = clan {

View File

@@ -164,25 +164,13 @@
config = lib.mkIf (config.clan.core.secrets != { }) { config = lib.mkIf (config.clan.core.secrets != { }) {
clan.core.facts.services = lib.mapAttrs' ( clan.core.facts.services = lib.mapAttrs' (
name: service: name: service:
lib.warn lib.warn "clan.core.secrets.${name} is deprecated, use clan.core.facts.services.${name} instead" (
'' lib.nameValuePair name ({
############################################################################### secret = service.secrets;
# # public = service.facts;
# clan.core.secrets.${name} clan.core.facts.services.${name} is deprecated # generator = service.generator;
# 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; ) config.clan.core.secrets;
}; };
} }

View File

@@ -6,17 +6,7 @@
}: }:
{ {
config.warnings = lib.optionals (config.clan.core.facts.services != { }) [ 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 = { options.clan.core.facts = {

View File

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

View File

@@ -113,27 +113,15 @@ mkShell {
# todo darwin support needs some work # todo darwin support needs some work
(lib.optionalString stdenv.hostPlatform.isLinux '' (lib.optionalString stdenv.hostPlatform.isLinux ''
# configure playwright for storybook snapshot testing # configure playwright for storybook snapshot testing
# we only want webkit as that matches what the app is rendered with export PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD=1
export PLAYWRIGHT_BROWSERS_PATH=${ export PLAYWRIGHT_BROWSERS_PATH=${
playwright-driver.browsers.override { playwright-driver.browsers.override {
withFfmpeg = false; withFfmpeg = false;
withFirefox = false; withFirefox = false;
withWebkit = true;
withChromium = false; withChromium = false;
withChromiumHeadlessShell = true; 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,10 +4,8 @@
importNpmLock, importNpmLock,
clan-ts-api, clan-ts-api,
fonts, fonts,
ps,
playwright-driver,
}: }:
buildNpmPackage (finalAttrs: { buildNpmPackage (_finalAttrs: {
pname = "clan-app-ui"; pname = "clan-app-ui";
version = "0.0.1"; version = "0.0.1";
nodejs = nodejs_22; nodejs = nodejs_22;
@@ -34,38 +32,36 @@ buildNpmPackage (finalAttrs: {
# todo figure out why this fails only inside of Nix # 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 # 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 # `npm run test-storybook-static` works fine in the devshell
#
passthru = rec { # passthru = rec {
storybook = buildNpmPackage { # storybook = buildNpmPackage {
pname = "${finalAttrs.pname}-storybook"; # pname = "${finalAttrs.pname}-storybook";
inherit (finalAttrs) # inherit (finalAttrs)
version # version
nodejs # nodejs
src # src
npmDeps # npmDeps
npmConfigHook # npmConfigHook
; # preBuild
# ;
nativeBuildInputs = finalAttrs.nativeBuildInputs ++ [ #
ps # nativeBuildInputs = finalAttrs.nativeBuildInputs ++ [
]; # ps
# ];
npmBuildScript = "test-storybook-static"; #
# npmBuildScript = "test-storybook-static";
env = { #
PLAYWRIGHT_BROWSERS_PATH = "${playwright-driver.browsers.override { # env = finalAttrs.env // {
withChromiumHeadlessShell = true; # PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD = 1;
}}"; # PLAYWRIGHT_BROWSERS_PATH = "${playwright-driver.browsers.override {
PLAYWRIGHT_SKIP_VALIDATE_HOST_REQUIREMENTS = true; # withChromiumHeadlessShell = true;
}; # }}";
# PLAYWRIGHT_HOST_PLATFORM_OVERRIDE = "ubuntu-24.04";
preBuild = finalAttrs.preBuild + '' # };
export PLAYWRIGHT_CHROMIUM_EXECUTABLE=$(find -L "$PLAYWRIGHT_BROWSERS_PATH" -type f -name "headless_shell") #
''; # postBuild = ''
# mv storybook-static $out
postBuild = '' # '';
mv storybook-static $out # };
''; # };
};
};
}) })

View File

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

View File

@@ -16,9 +16,9 @@
"knip": "knip --fix", "knip": "knip --fix",
"storybook-build": "storybook build", "storybook-build": "storybook build",
"storybook-dev": "storybook dev -p 6006", "storybook-dev": "storybook dev -p 6006",
"test-storybook": "vitest run --project storybook --reporter verbose", "test-storybook": "vitest run --project storybook",
"test-storybook-update-snapshots": "vitest run --project storybook --update", "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 -a 127.0.0.1 -p 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 --port 6006 --silent' 'wait-on tcp:127.0.0.1:6006 && npm run test-storybook'"
}, },
"license": "MIT", "license": "MIT",
"devDependencies": { "devDependencies": {
@@ -48,7 +48,7 @@
"jsdom": "^26.1.0", "jsdom": "^26.1.0",
"knip": "^5.61.2", "knip": "^5.61.2",
"markdown-to-jsx": "^7.7.10", "markdown-to-jsx": "^7.7.10",
"playwright": "~1.55.1", "playwright": "~1.53.2",
"postcss": "^8.4.38", "postcss": "^8.4.38",
"postcss-url": "^10.1.3", "postcss-url": "^10.1.3",
"prettier": "^3.2.5", "prettier": "^3.2.5",

View File

@@ -1,7 +1,7 @@
import type { Meta, StoryObj } from "@kachurun/storybook-solid"; import type { Meta, StoryObj } from "@kachurun/storybook-solid";
import { Button, ButtonProps } from "./Button"; import { Button, ButtonProps } from "./Button";
import { Component } from "solid-js"; import { Component } from "solid-js";
import { expect, fn, within } from "storybook/test"; import { expect, fn, waitFor } from "storybook/test";
import { StoryContext } from "@kachurun/storybook-solid-vite"; import { StoryContext } from "@kachurun/storybook-solid-vite";
const getCursorStyle = (el: Element) => window.getComputedStyle(el).cursor; const getCursorStyle = (el: Element) => window.getComputedStyle(el).cursor;
@@ -216,11 +216,17 @@ const timeout = process.env.NODE_ENV === "test" ? 500 : 2000;
export const Primary: Story = { export const Primary: Story = {
args: { args: {
hierarchy: "primary", hierarchy: "primary",
onClick: fn(), 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");
}
}),
}, },
play: async ({ canvasElement, step, userEvent, args }: StoryContext) => { play: async ({ canvas, step, userEvent, args }: StoryContext) => {
const canvas = within(canvasElement);
const buttons = await canvas.findAllByRole("button"); const buttons = await canvas.findAllByRole("button");
for (const button of buttons) { for (const button of buttons) {
@@ -232,6 +238,14 @@ export const Primary: Story = {
} }
await step(`Click on ${testID}`, async () => { 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 // move the mouse over the button
await userEvent.hover(button); await userEvent.hover(button);
@@ -241,8 +255,33 @@ export const Primary: Story = {
// click the button // click the button
await userEvent.click(button); await userEvent.click(button);
// the click handler should have been called // check the button has changed
await expect(args.onClick).toHaveBeenCalled(); 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 },
);
}); });
} }
}, },

View File

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

View File

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

View File

@@ -14,7 +14,6 @@ import { splitProps } from "solid-js";
import { Typography } from "@/src/components/Typography/Typography"; import { Typography } from "@/src/components/Typography/Typography";
import { MachineTags } from "@/src/components/Form/MachineTags"; import { MachineTags } from "@/src/components/Form/MachineTags";
import { setValue } from "@modular-forms/solid"; import { setValue } from "@modular-forms/solid";
import { StoryContext } from "@kachurun/storybook-solid-vite";
type Story = StoryObj<SidebarPaneProps>; type Story = StoryObj<SidebarPaneProps>;
@@ -31,13 +30,6 @@ const profiles = {
const meta: Meta<SidebarPaneProps> = { const meta: Meta<SidebarPaneProps> = {
title: "Components/SidebarPane", title: "Components/SidebarPane",
component: SidebarPane, component: SidebarPane,
decorators: [
(
Story: StoryObj<SidebarPaneProps>,
context: StoryContext<SidebarPaneProps>,
) =>
() => <Story {...context.args} />,
],
}; };
export default meta; export default meta;
@@ -48,140 +40,133 @@ export const Default: Story = {
onClose: () => { onClose: () => {
console.log("closing"); console.log("closing");
}, },
}, children: (
// We have to provide children within a custom render function to ensure we aren't creating any reactivity outside the <>
// solid-js scope. <SidebarSectionForm
render: (args: SidebarPaneProps) => ( title="General"
<SidebarPane schema={v.object({
{...args} firstName: v.pipe(
children={ v.string(),
<> v.nonEmpty("Please enter a first name."),
<SidebarSectionForm ),
title="General" lastName: v.pipe(
schema={v.object({ v.string(),
firstName: v.pipe( v.nonEmpty("Please enter a last name."),
v.string(), ),
v.nonEmpty("Please enter a first name."), bio: v.string(),
), shareProfile: v.optional(v.boolean()),
lastName: v.pipe( })}
v.string(), initialValues={profiles.ron}
v.nonEmpty("Please enter a last name."), onSubmit={async () => {
), console.log("saving general");
bio: v.string(), }}
shareProfile: v.optional(v.boolean()), >
})} {({ editing, Field }) => (
initialValues={profiles.ron} <div class="flex flex-col gap-3">
onSubmit={async () => { <Field name="firstName">
console.log("saving general"); {(field, input) => (
}} <TextInput
> {...field}
{({ 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}
size="s"
label="Share"
inverted
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" size="s"
onChange={(newVal) => {
// Workaround for now, until we manage to use native events
setValue(formStore, field.name, newVal);
}}
inverted inverted
label="First Name"
value={field.value}
required required
readOnly={!editing} readOnly={!editing}
orientation="horizontal" orientation="horizontal"
defaultValue={field.value} input={input}
/> />
)} )}
</Field> </Field>
)} <Divider />
</SidebarSectionForm> <Field name="lastName">
<SidebarSection title="Simple"> {(field, input) => (
<Typography tag="h2" hierarchy="title" size="m" inverted> <TextInput
Static Content {...field}
</Typography> size="s"
<Typography hierarchy="label" size="s" inverted> inverted
This is a non-form section with static content label="Last Name"
</Typography> value={field.value}
</SidebarSection> 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}
/>
);
}}
</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,5 +1,6 @@
import { Meta, StoryObj } from "@kachurun/storybook-solid"; import { Meta, StoryObj } from "@kachurun/storybook-solid";
import { Toolbar, ToolbarProps } from "@/src/components/Toolbar/Toolbar"; import { Toolbar, ToolbarProps } from "@/src/components/Toolbar/Toolbar";
import { Divider } from "@/src/components/Divider/Divider";
import { ToolbarButton } from "./ToolbarButton"; import { ToolbarButton } from "./ToolbarButton";
const meta: Meta<ToolbarProps> = { const meta: Meta<ToolbarProps> = {
@@ -12,35 +13,61 @@ export default meta;
type Story = StoryObj<ToolbarProps>; type Story = StoryObj<ToolbarProps>;
export const Default: Story = { export const Default: Story = {
// We have to specify children inside a render function to avoid issues with reactivity outside a solid-js context. 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 = {
// @ts-expect-error: args in storybook is not typed correctly. This is a storybook issue. // @ts-expect-error: args in storybook is not typed correctly. This is a storybook issue.
render: (args) => ( render: (args) => (
<div class="flex h-[80vh]"> <div class="flex h-[80vh]">
<div class="mt-auto"> <div class="mt-auto">
<Toolbar <Toolbar {...args} />
{...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>
</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,6 +1,7 @@
import { Meta, StoryObj } from "@kachurun/storybook-solid"; import { Meta, StoryObj } from "@kachurun/storybook-solid";
import { Tooltip, TooltipProps } from "@/src/components/Tooltip/Tooltip"; import { Tooltip, TooltipProps } from "@/src/components/Tooltip/Tooltip";
import { Typography } from "@/src/components/Typography/Typography"; import { Typography } from "@/src/components/Typography/Typography";
import { Button } from "@/src/components/Button/Button";
const meta: Meta<TooltipProps> = { const meta: Meta<TooltipProps> = {
title: "Components/Tooltip", title: "Components/Tooltip",
@@ -12,23 +13,6 @@ const meta: Meta<TooltipProps> = {
</div> </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; export default meta;
@@ -39,6 +23,12 @@ export const Default: Story = {
args: { args: {
placement: "top", placement: "top",
inverted: false, 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,35 +11,28 @@ export default meta;
type Story = StoryObj<ClanSettingsModalProps>; type Story = StoryObj<ClanSettingsModalProps>;
const props: ClanSettingsModalProps = { export const Default: Story = {
onClose: fn(), args: {
model: { onClose: fn(),
uri: "/home/foo/my-clan", model: {
details: { uri: "/home/foo/my-clan",
name: "Sol", name: "Sol",
description: null, description: null,
icon: null, icon: null,
}, fieldsSchema: {
fieldsSchema: { name: {
name: { readonly: true,
readonly: true, reason: null,
reason: null, },
readonly_members: [], description: {
}, readonly: false,
description: { reason: null,
readonly: false, },
reason: null, icon: {
readonly_members: [], readonly: false,
}, reason: null,
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"; import { removeClanURI } from "@/src/stores/clan";
const schema = v.object({ const schema = v.object({
name: v.string(), name: v.pipe(v.optional(v.string())),
description: v.optional(v.string()), description: v.nullish(v.string()),
icon: v.optional(v.string()), icon: v.pipe(v.nullish(v.string())),
}); });
export interface ClanSettingsModalProps { export interface ClanSettingsModalProps {

View File

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

View File

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

View File

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

View File

@@ -13,8 +13,6 @@ const dirname =
import viteConfig from "./vite.config"; import viteConfig from "./vite.config";
const browser = process.env.BROWSER || "chromium";
export default mergeConfig( export default mergeConfig(
viteConfig, viteConfig,
defineConfig({ defineConfig({
@@ -42,15 +40,7 @@ export default mergeConfig(
enabled: true, enabled: true,
headless: true, headless: true,
provider: "playwright", provider: "playwright",
instances: [ instances: [{ browser: "chromium" }],
{
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 // This setup file applies Storybook project annotations for Vitest
// More info at: https://storybook.js.org/docs/api/portable-stories/portable-stories-vitest#setprojectannotations // More info at: https://storybook.js.org/docs/api/portable-stories/portable-stories-vitest#setprojectannotations

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -61,22 +61,14 @@ class Generator:
migrate_fact: str | None = None migrate_fact: str | None = None
validation_hash: str | None = None validation_hash: str | None = None
machines: list[str] = field(default_factory=list) machine: str | None = None
_flake: "Flake | None" = None _flake: "Flake | None" = None
_public_store: "StoreBase | None" = None _public_store: "StoreBase | None" = None
_secret_store: "StoreBase | None" = None _secret_store: "StoreBase | None" = None
@property @property
def key(self) -> GeneratorKey: def key(self) -> GeneratorKey:
if self.share: return GeneratorKey(machine=self.machine, name=self.name)
# 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: def __hash__(self) -> int:
return hash(self.key) return hash(self.key)
@@ -151,10 +143,7 @@ class Generator:
files_selector = "config.clan.core.vars.generators.*.files.*.{secret,deploy,owner,group,mode,neededFor}" files_selector = "config.clan.core.vars.generators.*.files.*.{secret,deploy,owner,group,mode,neededFor}"
flake.precache(cls.get_machine_selectors(machine_names)) flake.precache(cls.get_machine_selectors(machine_names))
generators: list[Generator] = [] generators = []
shared_generators_raw: dict[
str, tuple[str, dict, dict]
] = {} # name -> (machine_name, gen_data, files_data)
for machine_name in machine_names: for machine_name in machine_names:
# Get all generator metadata in one select (safe fields only) # Get all generator metadata in one select (safe fields only)
@@ -176,38 +165,6 @@ class Generator:
sec_store = machine.secret_vars_store sec_store = machine.secret_vars_store
for gen_name, gen_data in generators_data.items(): 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 # Build files from the files_data
files = [] files = []
gen_files = files_data.get(gen_name, {}) gen_files = files_data.get(gen_name, {})
@@ -252,27 +209,14 @@ class Generator:
migrate_fact=gen_data.get("migrateFact"), migrate_fact=gen_data.get("migrateFact"),
validation_hash=gen_data.get("validationHash"), validation_hash=gen_data.get("validationHash"),
prompts=prompts, prompts=prompts,
# shared generators can have multiple machines, machine-specific have one # only set machine for machine-specific generators
machines=[machine_name], # this is essential for the graph algorithms to work correctly
machine=None if share else machine_name,
_flake=flake, _flake=flake,
_public_store=pub_store, _public_store=pub_store,
_secret_store=sec_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. # TODO: This should be done in a non-mutable way.
if include_previous_values: if include_previous_values:
@@ -301,19 +245,15 @@ class Generator:
return sec_store.get(self, prompt.name).decode() return sec_store.get(self, prompt.name).decode()
return None 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: def final_script(self, machine: "Machine") -> Path:
if self._flake is None: if self._flake is None:
msg = "Flake cannot be None" msg = "Flake cannot be None"
raise ClanError(msg) raise ClanError(msg)
output = Path(self._flake.select(self.final_script_selector(machine.name))) output = Path(
machine.select(
f'config.clan.core.vars.generators."{self.name}".finalScript',
),
)
if tmp_store := nix_test_store(): if tmp_store := nix_test_store():
output = tmp_store.joinpath(*output.parts[1:]) output = tmp_store.joinpath(*output.parts[1:])
return output return output

View File

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

View File

@@ -74,6 +74,9 @@ def handle_io(
) # wlist is a list of file descriptors to be monitored for write events ) # wlist is a list of file descriptors to be monitored for write events
stdout_buf = b"" stdout_buf = b""
stderr_buf = b"" stderr_buf = b""
# Buffers for incomplete lines (no trailing newline yet)
stdout_line_buf = ""
stderr_line_buf = ""
start = time.time() start = time.time()
# Function to handle file descriptors # Function to handle file descriptors
@@ -85,6 +88,40 @@ def handle_io(
rlist.remove(fd) rlist.remove(fd)
return b"" return b""
# Function to process output with proper carriage return handling
def process_output(
chunk: bytes, line_buf: str, extra: dict[str, str], cmdlog_func: Any
) -> str:
"""Process output chunk, handling carriage returns properly.
Returns the updated line buffer (incomplete lines).
"""
if not chunk:
return line_buf
# Decode the chunk and append to line buffer
decoded = chunk.decode("utf-8", "replace")
line_buf += decoded
# Split by newlines to get complete lines
lines = line_buf.split("\n")
# The last element might be an incomplete line
line_buf = lines[-1]
complete_lines = lines[:-1]
# Process each complete line
for line in complete_lines:
if "\r" in line:
# Handle carriage return: only keep the last segment after final \r
# This is what would be visible on a terminal
visible_line = line.split("\r")[-1]
if visible_line: # Only log non-empty lines
cmdlog_func(visible_line, extra=extra)
elif line: # Only log non-empty lines
cmdlog_func(line, extra=extra)
return line_buf
# Extra information passed to the logger # Extra information passed to the logger
stdout_extra = {} stdout_extra = {}
stderr_extra = {} stderr_extra = {}
@@ -126,9 +163,9 @@ def handle_io(
# If Log.STDOUT is set, log the stdout output # If Log.STDOUT is set, log the stdout output
if ret and log in [Log.STDOUT, Log.BOTH]: if ret and log in [Log.STDOUT, Log.BOTH]:
lines = ret.decode("utf-8", "replace").rstrip("\n").rstrip().split("\n") stdout_line_buf = process_output(
for line in lines: ret, stdout_line_buf, stdout_extra, cmdlog.info
cmdlog.info(line, extra=stdout_extra) )
# If stdout file is set, stream the stdout output # If stdout file is set, stream the stdout output
if ret and stdout: if ret and stdout:
@@ -143,9 +180,9 @@ def handle_io(
# If Log.STDERR is set, log the stderr output # If Log.STDERR is set, log the stderr output
if ret and log in [Log.STDERR, Log.BOTH]: if ret and log in [Log.STDERR, Log.BOTH]:
lines = ret.decode("utf-8", "replace").rstrip("\n").rstrip().split("\n") stderr_line_buf = process_output(
for line in lines: ret, stderr_line_buf, stderr_extra, cmdlog.info
cmdlog.info(line, extra=stderr_extra) )
# If stderr file is set, stream the stderr output # If stderr file is set, stream the stderr output
if ret and stderr: if ret and stderr:
@@ -173,6 +210,24 @@ def handle_io(
process.stdin.close() process.stdin.close()
else: else:
wlist.remove(process.stdin) wlist.remove(process.stdin)
# Flush any remaining buffered lines at the end
if stdout_line_buf and log in [Log.STDOUT, Log.BOTH]:
if "\r" in stdout_line_buf:
visible_line = stdout_line_buf.split("\r")[-1]
if visible_line:
cmdlog.info(visible_line, extra=stdout_extra)
elif stdout_line_buf:
cmdlog.info(stdout_line_buf, extra=stdout_extra)
if stderr_line_buf and log in [Log.STDERR, Log.BOTH]:
if "\r" in stderr_line_buf:
visible_line = stderr_line_buf.split("\r")[-1]
if visible_line:
cmdlog.info(visible_line, extra=stderr_extra)
elif stderr_line_buf:
cmdlog.info(stderr_line_buf, extra=stderr_extra)
return stdout_buf.decode("utf-8", "replace"), stderr_buf.decode("utf-8", "replace") return stdout_buf.decode("utf-8", "replace"), stderr_buf.decode("utf-8", "replace")
@@ -294,8 +349,6 @@ class RunOpts:
# This is needed for GUI applications # This is needed for GUI applications
graphical_perm: bool = False graphical_perm: bool = False
trace: bool = True 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]: def cmd_with_root(cmd: list[str], graphical: bool = False) -> list[str]:
@@ -351,10 +404,7 @@ def run(
if cmdlog.isEnabledFor(logging.DEBUG) and options.trace: if cmdlog.isEnabledFor(logging.DEBUG) and options.trace:
if options.input and isinstance(options.input, bytes): if options.input and isinstance(options.input, bytes):
# Always redact sensitive input (e.g., private keys, passwords) if any(
if options.sensitive_input:
filtered_input = "<<REDACTED>>"
elif any(
not ch.isprintable() for ch in options.input.decode("ascii", "replace") not ch.isprintable() for ch in options.input.decode("ascii", "replace")
): ):
filtered_input = "<<binary_blob>>" filtered_input = "<<binary_blob>>"

View File

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

View File

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

View File

@@ -136,123 +136,92 @@ def networks_from_flake(flake: Flake) -> dict[str, Network]:
return networks return networks
class BestRemoteContext: @contextmanager
"""Class-based context manager for establishing and maintaining network connections.""" 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
def __init__(self, machine: "Machine") -> None: Args:
self.machine = machine machine: Machine instance to connect to
self._network_ctx: Any = None
self._remote: Remote | None = None
def __enter__(self) -> "Remote": Yields:
"""Establish the best remote connection for a machine following this priority: Remote object for connecting to the machine
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
Returns: Raises:
Remote object for connecting to the machine ClanError: If no connection method works
Raises: """
ClanError: If no connection method works # Step 1: Check if targetHost is set in inventory
inv_machine = machine.get_inv_machine()
target_host = inv_machine.get("deploy", {}).get("targetHost")
""" if target_host:
# Step 1: Check if targetHost is set in inventory log.debug(f"Using targetHost from inventory for {machine.name}: {target_host}")
inv_machine = self.machine.get_inv_machine() # Create a direct network with just this machine
target_host = inv_machine.get("deploy", {}).get("targetHost") remote = Remote.from_ssh_uri(machine_name=machine.name, address=target_host)
yield remote
return
if target_host: # Step 2: Try existing networks by priority
log.debug( try:
f"Using targetHost from inventory for {self.machine.name}: {target_host}" networks = networks_from_flake(machine.flake)
)
self._remote = Remote.from_ssh_uri(
machine_name=self.machine.name, address=target_host
)
return self._remote
# Step 2: Try existing networks by priority sorted_networks = sorted(networks.items(), key=lambda x: -x[1].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: for network_name, network in sorted_networks:
if self.machine.name not in network.peers: if machine.name not in network.peers:
continue continue
log.debug(f"trying to connect via {network_name}") # Check if network is running and machine is reachable
if network.is_running(): log.debug(f"trying to connect via {network_name}")
try: if network.is_running():
ping_time = network.ping(self.machine.name) 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)
if ping_time is not None: if ping_time is not None:
log.info( log.info(
f"Machine {self.machine.name} reachable via {network_name} network", f"Machine {machine.name} reachable via {network_name} network after connection",
) )
self._remote = remote = network.remote(self.machine.name) yield connected_network.remote(machine.name)
return remote return
except ClanError as e: except ClanError as e:
log.debug( log.debug(
f"Failed to reach {self.machine.name} via {network_name}: {e}" f"Failed to establish connection to {machine.name} via {network_name}: {e}",
) )
else: except (ImportError, AttributeError, KeyError) as e:
try: log.debug(f"Failed to use networking modules to determine machines remote: {e}")
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 # Step 3: Try targetHost from machine nixos config
target_host = self.machine.select('config.clan.core.networking."targetHost"') target_host = machine.select('config.clan.core.networking."targetHost"')
if target_host: if target_host:
log.debug( log.debug(
f"Using targetHost from machine config for {self.machine.name}: {target_host}", f"Using targetHost from machine config for {machine.name}: {target_host}",
) )
self._remote = Remote.from_ssh_uri( # Check if reachable
machine_name=self.machine.name, remote = Remote.from_ssh_uri(
address=target_host, machine_name=machine.name,
) address=target_host,
return self._remote )
yield remote
return
# No connection method found # 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." 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) 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: def get_network_overview(networks: dict[str, Network]) -> dict:

View File

@@ -93,21 +93,21 @@ def _ensure_healthy(
if generators is None: if generators is None:
generators = Generator.get_machine_generators([machine.name], machine.flake) generators = Generator.get_machine_generators([machine.name], machine.flake)
public_health_check_msg = machine.public_vars_store.health_check( pub_healtcheck_msg = machine.public_vars_store.health_check(
machine.name, machine.name,
generators, generators,
) )
secret_health_check_msg = machine.secret_vars_store.health_check( sec_healtcheck_msg = machine.secret_vars_store.health_check(
machine.name, machine.name,
generators, generators,
) )
if public_health_check_msg or secret_health_check_msg: if pub_healtcheck_msg or sec_healtcheck_msg:
msg = f"Health check failed for machine {machine.name}:\n" msg = f"Health check failed for machine {machine.name}:\n"
if public_health_check_msg: if pub_healtcheck_msg:
msg += f"Public vars store: {public_health_check_msg}\n" msg += f"Public vars store: {pub_healtcheck_msg}\n"
if secret_health_check_msg: if sec_healtcheck_msg:
msg += f"Secret vars store: {secret_health_check_msg}" msg += f"Secret vars store: {sec_healtcheck_msg}"
raise ClanError(msg) raise ClanError(msg)
@@ -177,25 +177,13 @@ def run_generators(
for machine in machines: for machine in machines:
_ensure_healthy(machine=machine) _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 # execute generators
for generator in generator_objects: for generator in generator_objects:
machine = get_generator_machine(generator) machine = (
machines[0]
if generator.machine is None
else Machine(name=generator.machine, flake=machines[0].flake)
)
if check_can_migrate(machine, generator): if check_can_migrate(machine, generator):
migrate_files(machine, generator) migrate_files(machine, generator)
else: else:

View File

@@ -1,71 +0,0 @@
{ 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,14 +2,12 @@
{ {
imports = [ imports = [
./clan-app/flake-module.nix
./clan-cli/flake-module.nix ./clan-cli/flake-module.nix
./clan-core-flake/flake-module.nix
./clan-vm-manager/flake-module.nix ./clan-vm-manager/flake-module.nix
./icon-update/flake-module.nix
./installer/flake-module.nix ./installer/flake-module.nix
./option-search/flake-module.nix ./icon-update/flake-module.nix
./docs-from-code/flake-module.nix ./clan-core-flake/flake-module.nix
./clan-app/flake-module.nix
./testing/flake-module.nix ./testing/flake-module.nix
]; ];