Compare commits

..

61 Commits

Author SHA1 Message Date
Glen Huang
271b6fe7fc docs-site: fix intersection observer 2025-10-16 21:25:57 +08:00
Glen Huang
b899f95cf6 docs-site: implement tabs 2025-10-16 21:25:43 +08:00
Glen Huang
f9fe1b8913 docs-site: fix link migration 2025-10-16 18:57:35 +08:00
Glen Huang
fc8a65c388 docs-site: refactor admonition 2025-10-16 18:57:17 +08:00
Glen Huang
75f722bc79 docs-site: remove animation 2025-10-16 18:31:41 +08:00
Glen Huang
38f3ea6dad docs-site: add toc animation
The animation doesn't work perfectly. Because it's
time-bases, as a user scrolls down, a heading can
animate to a target location that is no longer
where the heading should be.

We can solve this problem by makinig the animation
scroll-based, but that is also not ideal. During
an animation, elements can fade in and out, at a
perticular scroll position the heading might not
be very eligible.
2025-10-16 18:24:03 +08:00
Glen Huang
9c5b0ed077 docs-site: fix scrolling when toggling global nav 2025-10-14 15:49:18 +08:00
Glen Huang
0dad11ffcf docs-site: add enter animation 2025-10-14 13:06:04 +08:00
Glen Huang
9144f5a3cd docs-site: fix toc 2025-10-14 10:35:22 +08:00
Glen Huang
f66b96c102 docs-site: prevent scrolling when global bar menu is displayed 2025-10-13 23:06:05 +08:00
Glen Huang
7d3972b993 docs-site: prettier global bar 2025-10-13 22:53:38 +08:00
Glen Huang
d61a042b76 docs-site: fix toc update on scroll 2025-10-13 22:44:05 +08:00
Glen Huang
2f05eccace docs-site: fix link migration 2025-10-10 15:27:06 +08:00
Glen Huang
8779dc07f0 docs-site: refactor admonition 2025-10-10 14:01:22 +08:00
Glen Huang
ae6eb1a822 docs-site: fix md 2025-10-10 13:30:25 +08:00
Glen Huang
57c91c3da3 docs-site: fix shiki highlighting 2025-10-10 13:30:10 +08:00
Glen Huang
c5a8765a65 docs-site: fix toc 2025-10-10 13:20:35 +08:00
Glen Huang
5ec14e51d4 docs-site: implement prev and next 2025-10-10 12:47:58 +08:00
Glen Huang
a4cc333533 docs-site: implement site search 2025-10-10 11:10:52 +08:00
Glen Huang
5299fe7259 site: fix nav 2025-10-10 10:59:54 +08:00
Glen Huang
e6a9bcbb69 site: fix admonition 2025-10-10 10:18:23 +08:00
Glen Huang
b46f841257 site: fix nav 2025-10-10 10:01:11 +08:00
Glen Huang
14847ba846 site: fix rendering 2025-10-10 09:58:49 +08:00
Glen Huang
6eb4c4c1e9 site: implement toggling toc 2025-10-10 07:51:11 +08:00
Glen Huang
520c926d6d site: implement heading to toc movement 2025-10-10 07:36:32 +08:00
Johannes Kirschbauer
1205f74f87 docs-site: fix devshell and nix build 2025-10-08 14:53:18 +02:00
Glen Huang
9b392b66ee docs: move sites to pkgs/docs-site 2025-10-08 14:53:18 +02:00
Glen Huang
4e37f53b7a site: show code line numbers conditionally 2025-10-08 14:53:18 +02:00
Glen Huang
8eec4c89c5 site: implement global bar nav toggling 2025-10-08 14:53:18 +02:00
Glen Huang
9812d4114f site: render docs nav both in global nav and docs sidebar 2025-10-08 14:53:18 +02:00
Glen Huang
6d622f7f68 site: use postcss-preset-env and cssnano 2025-10-08 14:53:18 +02:00
Glen Huang
c62995f91f site: use svelte auto adapter 2025-10-08 14:53:18 +02:00
Glen Huang
7f0e6d74e6 site: rename nav link type 2025-10-08 14:53:18 +02:00
Glen Huang
bf46ea1ebb site: refactor doc utils 2025-10-08 14:53:18 +02:00
Glen Huang
4ba722dd36 site: refactor link mgration 2025-10-08 14:53:18 +02:00
Glen Huang
61baf0f6c3 site: parse markdown only once 2025-10-08 14:53:18 +02:00
Johannes Kirschbauer
c252dd7b47 site: automatically transform mkDocs links 2025-10-08 14:53:18 +02:00
Johannes Kirschbauer
4aa01a63dc site: add autogenerated section support 2025-10-08 14:53:18 +02:00
Glen Huang
8030b64cdb site: add mobile support 2025-10-08 14:53:18 +02:00
Glen Huang
cbe7e27f91 site: add link support to doc nav bar 2025-10-08 14:53:18 +02:00
Glen Huang
d1e59fedb1 site: add toggle support to docs navLink 2025-10-08 14:53:18 +02:00
Glen Huang
b3dd1c4a46 site: implemenet docs navLink 2025-10-08 14:53:18 +02:00
Glen Huang
6614138fb8 site: use three column layout 2025-10-08 14:53:18 +02:00
Johannes Kirschbauer
92f87e169c site: copy content to new site 2025-10-08 14:53:18 +02:00
Glen Huang
a451946ab4 site: remove admonition ul styles 2025-10-08 14:53:18 +02:00
Glen Huang
c7a1d7ce29 site: use three column layout 2025-10-08 14:53:18 +02:00
Glen Huang
0e06ce3cca site: extract the markdown plugin into its own file 2025-10-08 14:53:18 +02:00
Glen Huang
1bb1b966d6 site: extract md css into files 2025-10-08 14:53:18 +02:00
Glen Huang
db98d106a1 site: change md export 2025-10-08 14:53:18 +02:00
Johannes Kirschbauer
a40c6884d9 site: admonitions custom title with icons 2025-10-08 14:53:18 +02:00
Glen Huang
5cac9e7704 site: add shiki highlighting 2025-10-08 14:53:18 +02:00
Johannes Kirschbauer
808491c71c site: slug, autolink headings to toc 2025-10-08 14:53:18 +02:00
Glen Huang
68afbb564e site: add code line numbers 2025-10-08 14:53:18 +02:00
Glen Huang
11d851e934 site: add shiki 2025-10-08 14:53:18 +02:00
Johannes Kirschbauer
d825a6b8c0 site: init toc 2025-10-08 14:53:18 +02:00
Glen Huang
3187ad3f5b site: fix page not updating bug 2025-10-08 14:53:18 +02:00
Glen Huang
84ab04fc06 site: add frontmatter parsing support 2025-10-08 14:53:18 +02:00
Johannes Kirschbauer
7112f608a7 site: wrap with nix 2025-10-08 14:53:18 +02:00
Glen Huang
70523f75fa site: render markdown file as string 2025-10-08 14:53:18 +02:00
Glen Huang
25db58ce11 site: use trailing slash for urls 2025-10-08 14:53:18 +02:00
Glen Huang
d92623f07e site: init 2025-10-08 14:53:18 +02:00
144 changed files with 19361 additions and 869 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") { };
in
{
imports = filter pathExists [
./devshell/flake-module.nix
./flash/flake-module.nix
./installation/flake-module.nix
./update/flake-module.nix
./morph/flake-module.nix
./nixos-documentation/flake-module.nix
./dont-depend-on-repo-root.nix
# clan core submodule tests
../nixosModules/clanCore/machine-id/tests/flake-module.nix
../nixosModules/clanCore/postgresql/tests/flake-module.nix
../nixosModules/clanCore/state-version/tests/flake-module.nix
];
imports =
let
clanCoreModulesDir = ../nixosModules/clanCore;
getClanCoreTestModules =
let
moduleNames = attrNames (builtins.readDir clanCoreModulesDir);
testPaths = map (
moduleName: clanCoreModulesDir + "/${moduleName}/tests/flake-module.nix"
) moduleNames;
in
filter pathExists testPaths;
in
getClanCoreTestModules
++ filter pathExists [
./devshell/flake-module.nix
./flash/flake-module.nix
./installation/flake-module.nix
./update/flake-module.nix
./morph/flake-module.nix
./nixos-documentation/flake-module.nix
./dont-depend-on-repo-root.nix
];
flake.check = genAttrs [ "x86_64-linux" "aarch64-darwin" ] (
system:
let
@@ -111,7 +120,7 @@ in
) (self.darwinConfigurations or { })
// lib.mapAttrs' (n: lib.nameValuePair "package-${n}") (
if system == "aarch64-darwin" then
lib.filterAttrs (n: _: n != "docs" && n != "deploy-docs" && n != "option-search") packagesToBuild
lib.filterAttrs (n: _: n != "docs" && n != "deploy-docs" && n != "docs-options") packagesToBuild
else
packagesToBuild
)

View File

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

18
devFlake/flake.lock generated
View File

@@ -3,10 +3,10 @@
"clan-core-for-checks": {
"flake": false,
"locked": {
"lastModified": 1760213549,
"narHash": "sha256-XosVRUEcdsoEdRtXyz9HrRc4Dt9Ke+viM5OVF7tLK50=",
"lastModified": 1759915474,
"narHash": "sha256-ef7awwmx2onWuA83FNE29B3tTZ+tQxEWLD926ckMiF8=",
"ref": "main",
"rev": "9c8797e77031d8d472d057894f18a53bdc9bbe1e",
"rev": "81e15cab34f9ae00b6f2df5f2e53ee07cd3a0af3",
"shallow": true,
"type": "git",
"url": "https://git.clan.lol/clan/clan-core"
@@ -105,11 +105,11 @@
},
"nixpkgs-dev": {
"locked": {
"lastModified": 1760161054,
"narHash": "sha256-PO3cKHFIQEPI0dr/SzcZwG50cHXfjoIqP2uS5W78OXg=",
"lastModified": 1759860509,
"narHash": "sha256-c7eJvqAlWLhwNc9raHkQ7mvoFbHLUO/cLMrww1ds4Zg=",
"owner": "NixOS",
"repo": "nixpkgs",
"rev": "e18d8ec6fafaed55561b7a1b54eb1c1ce3ffa2c5",
"rev": "b574dcadf3fb578dee8d104b565bd745a5a9edc0",
"type": "github"
},
"original": {
@@ -208,11 +208,11 @@
"nixpkgs": []
},
"locked": {
"lastModified": 1760120816,
"narHash": "sha256-gq9rdocpmRZCwLS5vsHozwB6b5nrOBDNc2kkEaTXHfg=",
"lastModified": 1758728421,
"narHash": "sha256-ySNJ008muQAds2JemiyrWYbwbG+V7S5wg3ZVKGHSFu8=",
"owner": "numtide",
"repo": "treefmt-nix",
"rev": "761ae7aff00907b607125b2f57338b74177697ed",
"rev": "5eda4ee8121f97b218f7cc73f5172098d458f1d1",
"type": "github"
},
"original": {

2
docs/.gitignore vendored
View File

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

View File

@@ -180,7 +180,7 @@ nav:
- services/official/zerotier.md
- services/community.md
- Search Clan Options: "/option-search"
- Search Clan Options: "/options"
docs_dir: site
site_dir: out

View File

@@ -5,7 +5,7 @@
clan-lib-openapi,
roboto,
fira-code,
option-search,
docs-options,
...
}:
let
@@ -51,9 +51,9 @@ pkgs.stdenv.mkDerivation {
chmod -R +w ./site
echo "Generated API documentation in './site/reference/' "
rm -rf ./site/option-search
cp -r ${option-search} ./site/option-search
chmod -R +w ./site/option-search
rm -rf ./site/options
cp -r ${docs-options} ./site/options
chmod -R +w ./site/options
# Link to fonts
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 =
{
config,
@@ -7,7 +10,74 @@
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
'';
module-docs =
pkgs.runCommand "rendered"
{
buildInputs = [
pkgs.python3
self'.packages.clan-cli
];
}
''
export CLAN_CORE_PATH=${
inputs.nixpkgs.lib.fileset.toSource {
root = ../..;
fileset = ../../clanModules;
}
}
export CLAN_CORE_DOCS=${jsonDocs.clanCore}/share/doc/nixos/options.json
# A file that contains the links to all clanModule docs
export CLAN_MODULES_VIA_SERVICE=${clanModulesViaService}
export CLAN_SERVICE_INTERFACE=${self'.legacyPackages.clan-service-module-interface}/share/doc/nixos/options.json
export CLAN_OPTIONS_PATH=${self'.legacyPackages.clan-options}/share/doc/nixos/options.json
mkdir $out
# The python script will place mkDocs files in the output directory
exec python3 ${renderOptions}/bin/render-options
'';
in
{
legacyPackages = {
inherit
jsonDocs
clanModulesViaService
;
};
devShells.docs = self'.packages.docs.overrideAttrs (_old: {
nativeBuildInputs = [
# Run: htmlproofer --disable-external
@@ -26,14 +96,15 @@
docs = pkgs.python3.pkgs.callPackage ./default.nix {
inherit (self'.packages)
clan-cli-docs
option-search
docs-options
inventory-api-docs
clan-lib-openapi
module-docs
;
inherit (inputs) nixpkgs;
inherit module-docs;
};
deploy-docs = pkgs.callPackage ./deploy-docs.nix { inherit (config.packages) docs; };
inherit module-docs;
};
checks.docs-integrity =
pkgs.runCommand "docs-integrity"

View File

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

View File

@@ -105,7 +105,7 @@ def render_option(
read_only = option.get("readOnly")
res = f"""
{"#" * level} {sanitize(name) if short_head is None else sanitize(short_head)} {"{: #" + sanitize_anchor(name) + "}" if level > 1 else ""}
{"#" * level} {sanitize(name) if short_head is None else sanitize(short_head)}
"""
@@ -431,7 +431,7 @@ def produce_inventory_docs() -> None:
output = """# Inventory Submodule
This provides an overview of the available options of the `inventory` model.
It can be set via the `inventory` attribute of the [`clan`](../../reference/options/clan_inventory.md) function, or via the [`clan.inventory`](../../reference/options/clan_inventory.md) attribute of flake-parts.
It can be set via the `inventory` attribute of the [`clan`](../../reference/options/clan.md) function, or via the [`clan.inventory`](../../reference/options/clan_inventory.md) attribute of flake-parts.
"""
# Inventory options are already included under the clan attribute

View File

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

View File

@@ -38,8 +38,8 @@ See the complete [list](../guides/inventory/autoincludes.md) of auto-loaded file
### Configuring a machine
!!! Note
The option: `inventory.machines.<name>` is used to define metadata about the machine
That includes for example `deploy.targethost` `machineClass` or `tags`
The option: `inventory.machines.<name>` is used to define metadata about the machine
That includes for example `deploy.targethost` `machineClass` or `tags`
The option: `machines.<name>` is used to add extra *nixosConfiguration* to a machine
@@ -71,7 +71,7 @@ This example demonstrates what is needed based on a machine called `jon`:
```
1. Tags can be used to automatically add this machine to services later on. - You dont need to set this now.
2. Add your *ssh key* here - That will ensure you can always login to your machine via *ssh* in case something goes wrong.
2. Add your _ssh key_ here - That will ensure you can always login to your machine via _ssh_ in case something goes wrong.
### (Optional) Create a `configuration.nix`
@@ -99,8 +99,8 @@ git mv ./machines/jon ./machines/<your-machine-name>
Since your Clan configuration lives inside a Git repository, remember:
* Only files tracked by Git (`git add`) are recognized.
* Whenever you add, rename, or remove files, run:
- Only files tracked by Git (`git add`) are recognized.
- Whenever you add, rename, or remove files, run:
```bash
git add ./machines/<your-machine-name>

View File

@@ -288,7 +288,7 @@ of their type.
In the inventory we the assign machines to a type, e.g. by using tags
```nix title="flake.nix"
instances.machine-type = {
instnaces.machine-type = {
module.input = "self";
module.name = "@pinpox/machine-type";
roles.desktop.tags.desktop = { };

View File

@@ -122,7 +122,7 @@ hide:
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)
- Explore available [services](../services/definition.md)
- [NixOS Configuration Options](../reference/clan.core/index.md) - Additional options avilable on a NixOS machine.
- [Search Clan Option](/option-search) - for defining a Clan
---

6
flake.lock generated
View File

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

View File

@@ -93,6 +93,7 @@
./clanServices/flake-module.nix
./devShell.nix
./docs/nix/flake-module.nix
./site/flake-module.nix
./flakeModules/demo_iso.nix
./flakeModules/flake-module.nix
./lib/filter-clan-core/flake-module.nix

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:
resolveModule = clanLib.callLib ./resolve-module { };
fs = {
inherit (builtins) pathExists readDir;
};
};
in
f

View File

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

View File

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

View File

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

View File

@@ -5,31 +5,33 @@
let
inherit (lib)
filterAttrs
flatten
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
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
(lib.optionalString stdenv.hostPlatform.isLinux ''
# 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=${
playwright-driver.browsers.override {
withFfmpeg = false;
withFirefox = false;
withWebkit = true;
withChromium = false;
withChromiumHeadlessShell = true;
}
}
# 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")
export PLAYWRIGHT_HOST_PLATFORM_OVERRIDE="ubuntu-24.04"
'');
}

View File

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

View File

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

View File

@@ -16,9 +16,9 @@
"knip": "knip --fix",
"storybook-build": "storybook build",
"storybook-dev": "storybook dev -p 6006",
"test-storybook": "vitest run --project storybook --reporter verbose",
"test-storybook": "vitest run --project storybook",
"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",
"devDependencies": {
@@ -48,7 +48,7 @@
"jsdom": "^26.1.0",
"knip": "^5.61.2",
"markdown-to-jsx": "^7.7.10",
"playwright": "~1.55.1",
"playwright": "~1.53.2",
"postcss": "^8.4.38",
"postcss-url": "^10.1.3",
"prettier": "^3.2.5",

View File

@@ -1,7 +1,7 @@
import type { Meta, StoryObj } from "@kachurun/storybook-solid";
import { Button, ButtonProps } from "./Button";
import { Component } from "solid-js";
import { expect, fn, within } from "storybook/test";
import { expect, fn, waitFor } from "storybook/test";
import { StoryContext } from "@kachurun/storybook-solid-vite";
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 = {
args: {
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) => {
const canvas = within(canvasElement);
play: async ({ canvas, step, userEvent, args }: StoryContext) => {
const buttons = await canvas.findAllByRole("button");
for (const button of buttons) {
@@ -232,6 +238,14 @@ export const Primary: Story = {
}
await step(`Click on ${testID}`, async () => {
// check for the loader
const loaders = button.getElementsByClassName("loader");
await expect(loaders.length).toEqual(1);
// assert its width is 0 before we click
const [loader] = loaders;
await expect(loader.clientWidth).toEqual(0);
// move the mouse over the button
await userEvent.hover(button);
@@ -241,8 +255,33 @@ export const Primary: Story = {
// click the button
await userEvent.click(button);
// the click handler should have been called
await expect(args.onClick).toHaveBeenCalled();
// check the button has changed
await waitFor(
async () => {
// the action handler should have been called
await expect(args.onAction).toHaveBeenCalled();
// the button should have a loading class
await expect(button).toHaveClass("loading");
// the loader should be visible
await expect(loader.clientWidth).toBeGreaterThan(0);
// the pointer should have changed to wait
await expect(getCursorStyle(button)).toEqual("wait");
},
{ timeout: timeout + 500 },
);
// wait for the action handler to finish
await waitFor(
async () => {
// the loading class should be removed
await expect(button).not.toHaveClass("loading");
// the loader should be hidden
await expect(loader.clientWidth).toEqual(0);
// the pointer should be normal
await expect(getCursorStyle(button)).toEqual("pointer");
},
{ timeout: timeout + 500 },
);
});
}
},

View File

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

View File

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

View File

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

View File

@@ -14,7 +14,6 @@ import { splitProps } from "solid-js";
import { Typography } from "@/src/components/Typography/Typography";
import { MachineTags } from "@/src/components/Form/MachineTags";
import { setValue } from "@modular-forms/solid";
import { StoryContext } from "@kachurun/storybook-solid-vite";
type Story = StoryObj<SidebarPaneProps>;
@@ -31,13 +30,6 @@ const profiles = {
const meta: Meta<SidebarPaneProps> = {
title: "Components/SidebarPane",
component: SidebarPane,
decorators: [
(
Story: StoryObj<SidebarPaneProps>,
context: StoryContext<SidebarPaneProps>,
) =>
() => <Story {...context.args} />,
],
};
export default meta;
@@ -48,140 +40,133 @@ export const Default: Story = {
onClose: () => {
console.log("closing");
},
},
// We have to provide children within a custom render function to ensure we aren't creating any reactivity outside the
// solid-js scope.
render: (args: SidebarPaneProps) => (
<SidebarPane
{...args}
children={
<>
<SidebarSectionForm
title="General"
schema={v.object({
firstName: v.pipe(
v.string(),
v.nonEmpty("Please enter a first name."),
),
lastName: v.pipe(
v.string(),
v.nonEmpty("Please enter a last name."),
),
bio: v.string(),
shareProfile: v.optional(v.boolean()),
})}
initialValues={profiles.ron}
onSubmit={async () => {
console.log("saving general");
}}
>
{({ editing, Field }) => (
<div class="flex flex-col gap-3">
<Field name="firstName">
{(field, input) => (
<TextInput
{...field}
size="s"
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]}
children: (
<>
<SidebarSectionForm
title="General"
schema={v.object({
firstName: v.pipe(
v.string(),
v.nonEmpty("Please enter a first name."),
),
lastName: v.pipe(
v.string(),
v.nonEmpty("Please enter a last name."),
),
bio: v.string(),
shareProfile: v.optional(v.boolean()),
})}
initialValues={profiles.ron}
onSubmit={async () => {
console.log("saving general");
}}
>
{({ editing, Field }) => (
<div class="flex flex-col gap-3">
<Field name="firstName">
{(field, input) => (
<TextInput
{...field}
size="s"
onChange={(newVal) => {
// Workaround for now, until we manage to use native events
setValue(formStore, field.name, newVal);
}}
inverted
label="First Name"
value={field.value}
required
readOnly={!editing}
orientation="horizontal"
defaultValue={field.value}
input={input}
/>
)}
</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>
</>
}
/>
),
<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"
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 { Toolbar, ToolbarProps } from "@/src/components/Toolbar/Toolbar";
import { Divider } from "@/src/components/Divider/Divider";
import { ToolbarButton } from "./ToolbarButton";
const meta: Meta<ToolbarProps> = {
@@ -12,35 +13,61 @@ export default meta;
type Story = StoryObj<ToolbarProps>;
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.
render: (args) => (
<div class="flex h-[80vh]">
<div class="mt-auto">
<Toolbar
{...args}
children={
<>
<ToolbarButton name="select" icon="Cursor" description="Select" />
<ToolbarButton
name="new-machine"
icon="NewMachine"
description="Select"
/>
<ToolbarButton
name="modules"
icon="Modules"
selected={true}
description="Select"
/>
<ToolbarButton name="ai" icon="AI" description="Select" />
</>
}
/>
<Toolbar {...args} />
</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 { Tooltip, TooltipProps } from "@/src/components/Tooltip/Tooltip";
import { Typography } from "@/src/components/Typography/Typography";
import { Button } from "@/src/components/Button/Button";
const meta: Meta<TooltipProps> = {
title: "Components/Tooltip",
@@ -12,23 +13,6 @@ const meta: Meta<TooltipProps> = {
</div>
),
],
render: (args: TooltipProps) => (
<div class="p-16">
<Tooltip
{...args}
children={
<Typography
hierarchy="body"
size="xs"
inverted={true}
weight="medium"
>
Your Clan is being created
</Typography>
}
/>
</div>
),
};
export default meta;
@@ -39,6 +23,12 @@ export const Default: Story = {
args: {
placement: "top",
inverted: false,
trigger: <Button hierarchy="primary">Trigger</Button>,
children: (
<Typography hierarchy="body" size="xs" inverted={true} weight="medium">
Your Clan is being created
</Typography>
),
},
};

View File

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

View File

@@ -22,9 +22,9 @@ import { Alert } from "@/src/components/Alert/Alert";
import { removeClanURI } from "@/src/stores/clan";
const schema = v.object({
name: v.string(),
description: v.optional(v.string()),
icon: v.optional(v.string()),
name: v.pipe(v.optional(v.string())),
description: v.nullish(v.string()),
icon: v.pipe(v.nullish(v.string())),
});
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 {
createForm,
getError,
SubmitHandler,
valiForm,
} from "@modular-forms/solid";
@@ -303,10 +304,11 @@ const FlashProgress = () => {
const [store, set] = getStepStore<InstallStoreType>(stepSignal);
onMount(async () => {
const result = await store.flash?.progress?.result;
if (result?.status == "success") {
stepSignal.next();
const result = await store.flash.progress.result;
if (result.status == "success") {
console.log("Flashing Success");
}
stepSignal.next();
});
const handleCancel = async () => {

View File

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

View File

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

View File

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

View File

@@ -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")
vars_text = stringify_all_vars(machine)
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(
"my_shared_generator",
share=True,
machines=["my_machine"],
machine="my_machine",
_flake=flake_obj,
)
dependent_generator = Generator(
"dependent_generator",
machines=["my_machine"],
machine="my_machine",
_flake=flake_obj,
)
in_repo_store = in_repo.FactStore(flake=flake_obj)
@@ -340,12 +340,12 @@ def test_generate_secret_var_sops_with_default_group(
flake_obj = Flake(str(flake.path))
first_generator = Generator(
"first_generator",
machines=["my_machine"],
machine="my_machine",
_flake=flake_obj,
)
second_generator = Generator(
"second_generator",
machines=["my_machine"],
machine="my_machine",
_flake=flake_obj,
)
in_repo_store = in_repo.FactStore(flake=flake_obj)
@@ -375,13 +375,13 @@ def test_generate_secret_var_sops_with_default_group(
first_generator_with_share = Generator(
"first_generator",
share=False,
machines=["my_machine"],
machine="my_machine",
_flake=flake_obj,
)
second_generator_with_share = Generator(
"second_generator",
share=False,
machines=["my_machine"],
machine="my_machine",
_flake=flake_obj,
)
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)
cli.run(["vars", "generate", "--flake", str(flake.path), "machine2"])
assert check_vars(machine2.name, machine2.flake)
assert check_vars(machine2.name, machine2.flake)
m1_sops_store = sops.SecretStore(machine1.flake)
m2_sops_store = sops.SecretStore(machine2.flake)
# Create generators with machine context for testing
@@ -512,28 +513,28 @@ def test_generate_secret_var_password_store(
"my_generator",
share=False,
files=[],
machines=["my_machine"],
machine="my_machine",
_flake=flake_obj,
)
my_generator_shared = Generator(
"my_generator",
share=True,
files=[],
machines=["my_machine"],
machine="my_machine",
_flake=flake_obj,
)
my_shared_generator = Generator(
"my_shared_generator",
share=True,
files=[],
machines=["my_machine"],
machine="my_machine",
_flake=flake_obj,
)
my_shared_generator_not_shared = Generator(
"my_shared_generator",
share=False,
files=[],
machines=["my_machine"],
machine="my_machine",
_flake=flake_obj,
)
assert store.exists(my_generator, "my_secret")
@@ -545,7 +546,7 @@ def test_generate_secret_var_password_store(
name="my_generator",
share=False,
files=[],
machines=["my_machine"],
machine="my_machine",
_flake=flake_obj,
)
assert store.get(generator, "my_secret").decode() == "hello\n"
@@ -556,7 +557,7 @@ def test_generate_secret_var_password_store(
"my_generator",
share=False,
files=[],
machines=["my_machine"],
machine="my_machine",
_flake=flake_obj,
)
var_name = "my_secret"
@@ -569,7 +570,7 @@ def test_generate_secret_var_password_store(
"my_generator2",
share=False,
files=[],
machines=["my_machine"],
machine="my_machine",
_flake=flake_obj,
)
var_name = "my_secret2"
@@ -581,7 +582,7 @@ def test_generate_secret_var_password_store(
"my_shared_generator",
share=True,
files=[],
machines=["my_machine"],
machine="my_machine",
_flake=flake_obj,
)
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)
# Create generators for each machine
gen1 = Generator("my_generator", machines=["machine1"], _flake=flake_obj)
gen2 = Generator("my_generator", machines=["machine2"], _flake=flake_obj)
gen1 = Generator("my_generator", machine="machine1", _flake=flake_obj)
gen2 = Generator("my_generator", machine="machine2", _flake=flake_obj)
assert in_repo_store1.exists(gen1, "my_value")
assert in_repo_store2.exists(gen2, "my_value")
@@ -693,12 +694,12 @@ def test_prompt(
# Set up objects for testing the results
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(
name="my_generator",
share=False,
files=[],
machines=["my_machine"],
machine="my_machine",
_flake=flake_obj,
)
@@ -783,10 +784,10 @@ def test_shared_vars_regeneration(
in_repo_store_2 = in_repo.FactStore(machine2.flake)
# Create generators with machine context for testing
child_gen_m1 = Generator(
"child_generator", share=False, machines=["machine1"], _flake=machine1.flake
"child_generator", share=False, machine="machine1", _flake=machine1.flake
)
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
cli.run(["vars", "generate", "--flake", str(flake.path), "machine1"])
@@ -854,13 +855,13 @@ def test_multi_machine_shared_vars(
generator_m1 = Generator(
"shared_generator",
share=True,
machines=["machine1"],
machine="machine1",
_flake=machine1.flake,
)
generator_m2 = Generator(
"shared_generator",
share=True,
machines=["machine2"],
machine="machine2",
_flake=machine2.flake,
)
# generate for machine 1
@@ -916,9 +917,7 @@ def test_api_set_prompts(
)
machine = Machine(name="my_machine", flake=Flake(str(flake.path)))
store = in_repo.FactStore(machine.flake)
my_generator = Generator(
"my_generator", machines=["my_machine"], _flake=machine.flake
)
my_generator = Generator("my_generator", machine="my_machine", _flake=machine.flake)
assert store.exists(my_generator, "prompt1")
assert store.get(my_generator, "prompt1").decode() == "input1"
run_generators(
@@ -1062,10 +1061,10 @@ def test_migration(
assert "Migrated var my_generator/my_value" in caplog.text
assert "Migrated secret var my_generator/my_secret" in caplog.text
flake_obj = Flake(str(flake.path))
my_generator = Generator("my_generator", machines=["my_machine"], _flake=flake_obj)
my_generator = Generator("my_generator", machine="my_machine", _flake=flake_obj)
other_generator = Generator(
"other_generator",
machines=["my_machine"],
machine="my_machine",
_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)
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()
@@ -1230,7 +1229,7 @@ def test_share_mode_switch_regenerates_secret(
# Read the new values with 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()

View File

@@ -40,15 +40,12 @@ class StoreBase(ABC):
def get_machine(self, generator: "Generator") -> str:
"""Get machine name from generator, asserting it's not None for now."""
if generator.share:
return "__shared"
if not generator.machines:
if generator.machine is None:
if generator.share:
return "__shared"
msg = f"Generator '{generator.name}' has no machine associated"
raise ClanError(msg)
if len(generator.machines) != 1:
msg = f"Generator '{generator.name}' has {len(generator.machines)} machines, expected exactly 1"
raise ClanError(msg)
return generator.machines[0]
return generator.machine
# get a single fact
@abstractmethod
@@ -150,7 +147,7 @@ class StoreBase(ABC):
prev_generator = dataclasses.replace(
generator,
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):
changed_files += self.delete(prev_generator, var.name)
@@ -168,12 +165,12 @@ class StoreBase(ABC):
new_file = self._set(generator, var, value, machine)
action_str = "Migrated" if is_migration else "Updated"
log_info: Callable
if generator.share:
if generator.machine is None:
log_info = log.info
else:
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
if self.is_secret_store:
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
validation_hash: str | None = None
machines: list[str] = field(default_factory=list)
machine: str | None = None
_flake: "Flake | None" = None
_public_store: "StoreBase | None" = None
_secret_store: "StoreBase | None" = None
@property
def key(self) -> GeneratorKey:
if self.share:
# must be a shared generator
machine = None
elif len(self.machines) != 1:
msg = f"Shared generator {self.name} must have exactly one machine, but has {len(self.machines)}: {', '.join(self.machines)}"
raise ClanError(msg)
else:
machine = self.machines[0]
return GeneratorKey(machine=machine, name=self.name)
return GeneratorKey(machine=self.machine, name=self.name)
def __hash__(self) -> int:
return hash(self.key)
@@ -151,7 +143,7 @@ class Generator:
files_selector = "config.clan.core.vars.generators.*.files.*.{secret,deploy,owner,group,mode,neededFor}"
flake.precache(cls.get_machine_selectors(machine_names))
generators: list[Generator] = []
generators = []
shared_generators_raw: dict[
str, tuple[str, dict, dict]
] = {} # name -> (machine_name, gen_data, files_data)
@@ -252,27 +244,15 @@ class Generator:
migrate_fact=gen_data.get("migrateFact"),
validation_hash=gen_data.get("validationHash"),
prompts=prompts,
# shared generators can have multiple machines, machine-specific have one
machines=[machine_name],
# only set machine for machine-specific generators
# this is essential for the graph algorithms to work correctly
machine=None if share else machine_name,
_flake=flake,
_public_store=pub_store,
_secret_store=sec_store,
)
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)
generators.append(generator)
# TODO: This should be done in a non-mutable way.
if include_previous_values:

View File

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

View File

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

View File

@@ -93,21 +93,21 @@ def _ensure_healthy(
if generators is None:
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,
generators,
)
secret_health_check_msg = machine.secret_vars_store.health_check(
sec_healtcheck_msg = machine.secret_vars_store.health_check(
machine.name,
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"
if public_health_check_msg:
msg += f"Public vars store: {public_health_check_msg}\n"
if secret_health_check_msg:
msg += f"Secret vars store: {secret_health_check_msg}"
if pub_healtcheck_msg:
msg += f"Public vars store: {pub_healtcheck_msg}\n"
if sec_healtcheck_msg:
msg += f"Secret vars store: {sec_healtcheck_msg}"
raise ClanError(msg)
@@ -181,10 +181,10 @@ def run_generators(
flake = machines[0].flake
def get_generator_machine(generator: Generator) -> Machine:
if generator.share:
# return first machine if generator is shared
if generator.machine is None:
# return first machine if generator is not tied to a specific one
return machines[0]
return Machine(name=generator.machines[0], flake=flake)
return Machine(name=generator.machine, flake=flake)
# preheat the select cache, to reduce repeated calls during execution
selectors = []

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;
};
};
}

8
pkgs/docs-site/.envrc Normal file
View File

@@ -0,0 +1,8 @@
# shellcheck shell=bash
source_up
mapfile -d '' -t nix_files < <(find ./nix -name "*.nix" -print0)
watch_file "${nix_files[@]}"
# Because we depend on nixpkgs sources, uploading to builders takes a long time
use flake .#docs-site --builders ''

29
pkgs/docs-site/.gitignore vendored Normal file
View File

@@ -0,0 +1,29 @@
node_modules
# Output
.output
.vercel
.netlify
.wrangler
/.svelte-kit
/build
/static/pagefind
# Env
.env
.env.*
!.env.example
!.env.test
# Vite
vite.config.js.timestamp-*
vite.config.ts.timestamp-*
# Generated docs
src/routes/docs/reference/options
src/routes/docs/reference/clan.core
src/routes/docs/services/official
# Icons and other assets
static/icons

1
pkgs/docs-site/.npmrc Normal file
View File

@@ -0,0 +1 @@
engine-strict=true

View File

@@ -0,0 +1,6 @@
{
"plugins": {
"postcss-preset-env": {},
"cssnano": { "preset": "default" }
}
}

View File

@@ -0,0 +1,6 @@
# Package Managers
package-lock.json
pnpm-lock.yaml
yarn.lock
bun.lock
bun.lockb

View File

@@ -0,0 +1,11 @@
{
"plugins": ["prettier-plugin-svelte"],
"overrides": [
{
"files": "*.svelte",
"options": {
"parser": "svelte"
}
}
]
}

38
pkgs/docs-site/README.md Normal file
View File

@@ -0,0 +1,38 @@
# sv
Everything you need to build a Svelte project, powered by [`sv`](https://github.com/sveltejs/cli).
## Creating a project
If you're seeing this, you've probably already done this step. Congrats!
```sh
# create a new project in the current directory
npx sv create
# create a new project in my-app
npx sv create my-app
```
## Developing
Once you've created a project and installed dependencies with `npm install` (or `pnpm install` or `yarn`), start a development server:
```sh
npm run dev
# or start the server and open the app in a new browser tab
npm run dev -- --open
```
## Building
To create a production version of your app:
```sh
npm run build
```
You can preview the production build with `npm run preview`.
> To deploy your app, you may need to install an [adapter](https://svelte.dev/docs/kit/adapters) for your target environment.

View File

@@ -0,0 +1,33 @@
{
buildNpmPackage,
importNpmLock,
nodejs_latest,
module-docs,
}:
buildNpmPackage {
pname = "clan-site";
version = "0.0.1";
nodejs = nodejs_latest;
src = ./.;
npmDeps = importNpmLock {
npmRoot = ./.;
};
npmConfigHook = importNpmLock.npmConfigHook;
preBuild = ''
# Copy generated reference docs
mkdir -p src/routes/docs/reference
cp -r ${module-docs}/reference/* src/routes/docs/reference
mkdir -p src/routes/docs/services
cp -r ${module-docs}/services/* src/routes/docs/services
chmod +w -R src/routes/docs/reference
mkdir -p static/icons
cp -af ${../clan-app/ui/icons}/* ./static/icons
chmod +w -R static/icons
'';
}

View File

@@ -0,0 +1,12 @@
{
perSystem =
{ pkgs, self', ... }:
{
packages.docs-site = pkgs.callPackage ./default.nix { inherit (self'.packages) module-docs; };
devShells.docs-site = pkgs.mkShell {
shellHook = self'.packages.docs-site.preBuild;
inputsFrom = [ self'.packages.docs-site ];
};
};
}

10128
pkgs/docs-site/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,56 @@
{
"name": "clan-site",
"private": true,
"version": "0.0.1",
"type": "module",
"scripts": {
"dev": "vite dev",
"build": "vite build && pagefind --site build",
"preview": "vite preview",
"prepare": "svelte-kit sync || echo ''",
"check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json",
"check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch",
"format": "prettier --write .",
"lint": "prettier --check ."
},
"devDependencies": {
"@fontsource-variable/geist": "^5.2.8",
"@shikijs/rehype": "^3.13.0",
"@shikijs/transformers": "^3.13.0",
"@sveltejs/adapter-static": "^3.0.10",
"@sveltejs/kit": "^2.43.2",
"@sveltejs/vite-plugin-svelte": "^6.2.0",
"@types/node": "^24.7.0",
"cssnano": "^7.1.1",
"github-slugger": "^2.0.0",
"hast": "^0.0.2",
"hast-util-heading-rank": "^3.0.0",
"hast-util-to-string": "^3.0.1",
"hastscript": "^9.0.1",
"mdast": "^2.3.2",
"mdast-util-from-markdown": "^2.0.2",
"mdast-util-to-hast": "^13.2.0",
"mdast-util-toc": "^7.1.0",
"pagefind": "^1.4.0",
"postcss-preset-env": "^10.4.0",
"prettier": "^3.6.2",
"prettier-plugin-svelte": "^3.4.0",
"rehype-autolink-headings": "^7.1.0",
"rehype-stringify": "^10.0.1",
"remark": "^15.0.1",
"remark-directive": "^4.0.0",
"remark-gfm": "^4.0.1",
"remark-rehype": "^11.1.2",
"remark-stringify": "^11.0.0",
"remark-toc": "^9.0.0",
"svelte": "^5.39.5",
"svelte-check": "^4.3.2",
"typescript": "^5.9.2",
"unified": "^11.0.5",
"unist-util-visit": "^5.0.0",
"vfile": "^6.0.3",
"vfile-matter": "^5.0.1",
"vite": "^7.1.7",
"vite-plugin-pagefind": "^1.0.7"
}
}

13
pkgs/docs-site/src/app.d.ts vendored Normal file
View File

@@ -0,0 +1,13 @@
// See https://svelte.dev/docs/kit/types#app.d.ts
// for information about these interfaces
declare global {
namespace App {
// interface Error {}
// interface Locals {}
// interface PageData {}
// interface PageState {}
// interface Platform {}
}
}
export {};

View File

@@ -0,0 +1,14 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<script>
document.documentElement.classList.add("js");
</script>
%sveltekit.head%
</head>
<body data-sveltekit-preload-data="hover">
<div style="display: contents">%sveltekit.body%</div>
</body>
</html>

View File

@@ -0,0 +1,27 @@
import type { RawNavItem } from "$lib";
export default {
navItems: [
{
label: "Getting Started",
items: ["/getting-started/add-machines"],
},
{
label: "Reference",
items: [
{
label: "Overview",
slug: "/reference/overview",
},
{
label: "Options",
autogenerate: { directory: "/reference/options" },
},
],
},
{
label: "Test",
link: "/test/overview",
},
] as RawNavItem[],
};

View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="107" height="128" viewBox="0 0 107 128"><title>svelte-logo</title><path d="M94.157 22.819c-10.4-14.885-30.94-19.297-45.792-9.835L22.282 29.608A29.92 29.92 0 0 0 8.764 49.65a31.5 31.5 0 0 0 3.108 20.231 30 30 0 0 0-4.477 11.183 31.9 31.9 0 0 0 5.448 24.116c10.402 14.887 30.942 19.297 45.791 9.835l26.083-16.624A29.92 29.92 0 0 0 98.235 78.35a31.53 31.53 0 0 0-3.105-20.232 30 30 0 0 0 4.474-11.182 31.88 31.88 0 0 0-5.447-24.116" style="fill:#ff3e00"/><path d="M45.817 106.582a20.72 20.72 0 0 1-22.237-8.243 19.17 19.17 0 0 1-3.277-14.503 18 18 0 0 1 .624-2.435l.49-1.498 1.337.981a33.6 33.6 0 0 0 10.203 5.098l.97.294-.09.968a5.85 5.85 0 0 0 1.052 3.878 6.24 6.24 0 0 0 6.695 2.485 5.8 5.8 0 0 0 1.603-.704L69.27 76.28a5.43 5.43 0 0 0 2.45-3.631 5.8 5.8 0 0 0-.987-4.371 6.24 6.24 0 0 0-6.698-2.487 5.7 5.7 0 0 0-1.6.704l-9.953 6.345a19 19 0 0 1-5.296 2.326 20.72 20.72 0 0 1-22.237-8.243 19.17 19.17 0 0 1-3.277-14.502 17.99 17.99 0 0 1 8.13-12.052l26.081-16.623a19 19 0 0 1 5.3-2.329 20.72 20.72 0 0 1 22.237 8.243 19.17 19.17 0 0 1 3.277 14.503 18 18 0 0 1-.624 2.435l-.49 1.498-1.337-.98a33.6 33.6 0 0 0-10.203-5.1l-.97-.294.09-.968a5.86 5.86 0 0 0-1.052-3.878 6.24 6.24 0 0 0-6.696-2.485 5.8 5.8 0 0 0-1.602.704L37.73 51.72a5.42 5.42 0 0 0-2.449 3.63 5.79 5.79 0 0 0 .986 4.372 6.24 6.24 0 0 0 6.698 2.486 5.8 5.8 0 0 0 1.602-.704l9.952-6.342a19 19 0 0 1 5.295-2.328 20.72 20.72 0 0 1 22.237 8.242 19.17 19.17 0 0 1 3.277 14.503 18 18 0 0 1-8.13 12.053l-26.081 16.622a19 19 0 0 1-5.3 2.328" style="fill:#fff"/></svg>

After

Width:  |  Height:  |  Size: 1.5 KiB

View File

@@ -0,0 +1,296 @@
import config from "~/config";
import type {
Markdown,
Frontmatter as MarkdownFrontmatter,
Heading as MarkdownHeading,
} from "./markdown";
export type Article = Markdown & {
path: string;
frontmatter: Frontmatter;
toc: Heading[];
};
export type Frontmatter = MarkdownFrontmatter & {
previous?: ArticleSibling;
next?: ArticleSibling;
};
export type ArticleSibling = {
label: string;
link: string;
};
export type Heading = MarkdownHeading;
export class Docs {
#allArticles: Record<string, () => Promise<Markdown>> = {};
#loadedArticles: Record<string, Article> = {};
navItems: NavItem[] = [];
async init() {
this.#allArticles = Object.fromEntries(
Object.entries(import.meta.glob<Markdown>("../routes/docs/**/*.md")).map(
([key, fn]) => [key.slice("../routes/docs".length, -".md".length), fn],
),
);
this.navItems = await Promise.all(
config.navItems.map((navItem) => this.#normalizeNavItem(navItem)),
);
return this;
}
async getArticle(path: string): Promise<Article | null> {
const article = this.#loadedArticles[path];
if (article) {
return article;
}
const loadArticle = this.#allArticles[path];
if (!loadArticle) {
return null;
}
return this.#normalizeArticle(await loadArticle(), path);
}
async getArticles(paths: string[]): Promise<(Article | null)[]> {
return await Promise.all(paths.map((path) => this.getArticle(path)));
}
async #normalizeNavItem(navItem: RawNavItem): Promise<NavItem> {
if (typeof navItem === "string") {
const article = await this.getArticle(navItem);
if (!article) {
throw new Error(`Doc not found: ${navItem}`);
}
return {
label: article.frontmatter.title,
link: navItem,
external: false,
};
}
if ("items" in navItem) {
return {
...navItem,
collapsed: !!navItem.collapsed,
badge: normalizeBadge(navItem.badge),
items: await Promise.all(
navItem.items.map((navItem) => this.#normalizeNavItem(navItem)),
),
};
}
if ("slug" in navItem) {
const article = await this.getArticle(navItem.slug);
if (!article) {
throw new Error(`Doc not found: ${navItem.slug}`);
}
return {
label: navItem.label ?? article.frontmatter.title,
link: navItem.slug,
badge: normalizeBadge(navItem.badge),
external: false,
};
}
if ("autogenerate" in navItem) {
const paths = Object.keys(this.#allArticles).filter((path) =>
path.startsWith(navItem.autogenerate.directory + "/"),
);
const articles = (await this.getArticles(paths)) as Article[];
let titleMissing = false;
// Check frontmatter for title
for (const article of articles) {
if (!article.frontmatter.title) {
console.error(`Missing # title in doc: ${article.path}`);
titleMissing = true;
}
}
if (titleMissing) throw new Error("Aborting due to errors.");
articles.sort((a, b) => {
const orderA = a.frontmatter.order;
const orderB = b.frontmatter.order;
if (orderA != null && orderB != null) {
return orderA - orderB;
}
if (orderA != null) {
return -1;
}
if (orderB != null) {
return 1;
}
const titleA = a.frontmatter.title ?? a.path;
const titleB = a.frontmatter.title ?? a.path;
return titleA.localeCompare(titleB);
});
const items = await Promise.all(
articles.map((article) =>
this.#normalizeNavItem({
label: article.frontmatter.title,
link: article.path,
}),
),
);
return {
label:
navItem.label ?? navItem.autogenerate.directory.split("/").at(-1),
items,
collapsed: !!navItem.collapsed,
badge: normalizeBadge(navItem.badge),
};
}
return {
...navItem,
badge: normalizeBadge(navItem.badge),
external: /^(https?:)?\/\//.test(navItem.link),
};
}
#normalizeArticle(md: Markdown, path: string): Article {
let index = -1;
const navLinks: NavLink[] = [];
let previous: ArticleSibling | undefined;
let next: ArticleSibling | undefined;
visitNavItems(this.navItems, (navItem) => {
if (!("link" in navItem)) {
return;
}
if (index != -1) {
next = {
label: navItem.label,
link: navItem.link,
};
return false;
}
if (navItem.link != path) {
navLinks.push(navItem);
return;
}
index = navLinks.length;
navLinks.push(navItem);
if (index != 0) {
const navLink = navLinks[index - 1];
previous = {
label: navLink.label,
link: navLink.link,
};
}
});
return {
...md,
path,
frontmatter: {
...md.frontmatter,
previous,
next,
},
toc: md.toc,
};
}
}
export function visit<T extends { children: T[] }>(
items: T[],
fn: (item: T, parents: T[]) => false | void,
): void {
_visit(items, [], fn);
}
function _visit<T extends { children: T[] }>(
items: T[],
parents: T[],
fn: (item: T, parents: T[]) => false | void,
): false | void {
for (const item of items) {
if (fn(item, parents) === false) {
return false;
}
if (_visit(item.children, [...parents, item], fn) === false) {
return false;
}
}
}
export type RawNavItem =
| string
| {
label: string;
items: RawNavItem[];
collapsed?: boolean;
badge?: RawBadge;
}
| {
label: string;
autogenerate: { directory: string };
collapsed?: boolean;
badge?: RawBadge;
}
| {
label?: string;
slug: string;
badge?: RawBadge;
}
| {
label: string;
link: string;
badge?: RawBadge;
};
export type NavItem = NavGroup | NavLink;
export type NavGroup = {
label: string;
items: NavItem[];
collapsed: boolean;
badge?: Badge;
};
export type NavLink = {
label: string;
link: string;
badge?: Badge;
external: boolean;
};
export type RawBadge = string | Badge;
export type Badge = {
text: string;
variant: "caution" | "normal";
};
function normalizeBadge(badge: RawBadge | undefined): Badge | undefined {
if (!badge) {
return undefined;
}
if (typeof badge === "string") {
return {
text: badge,
variant: "normal",
};
}
return badge;
}
function visitNavItems(
navItems: NavItem[],
visit: (navItem: NavItem, parents: NavItem[]) => false | void,
): void {
_visitNavItems(navItems, [], visit);
}
function _visitNavItems(
navItems: NavItem[],
parents: NavItem[],
visit: (heading: NavItem, parents: NavItem[]) => false | void,
): false | void {
for (const navItem of navItems) {
if (visit(navItem, parents) === false) {
return false;
}
if ("items" in navItem) {
if (
_visitNavItems(navItem.items, [...parents, navItem], visit) === false
) {
return false;
}
}
}
}

View File

@@ -0,0 +1 @@
export * from "./docs";

View File

@@ -0,0 +1,81 @@
.md-admonition {
border-left: 4px solid;
padding: 1rem;
margin: 1rem 0;
.md-admonition-title {
text-transform: capitalize;
font-weight: 600;
display: flex;
align-items: center;
justify-content: start;
gap: 0.5rem;
}
.md-admonition-icon {
display: inline-flex;
align-items: center;
justify-content: center;
width: 1.25rem;
height: 1.25rem;
flex-shrink: 0;
&::before {
content: "";
display: block;
width: 1rem;
height: 1rem;
background-color: currentColor;
}
}
}
/* TODO: Adjust styling */
.md-admonition.is-note {
border-left-color: #3b82f6;
background-color: #eff6ff;
.md-admonition-title {
color: #1e40af;
}
.md-admonition-icon::before {
mask: url("/icons/info.svg") no-repeat center;
mask-size: contain;
}
}
.md-admonition.is-important {
border-left-color: #facc15;
background-color: #fffbeb;
.md-admonition-title {
color: #b45309;
}
.md-admonition-icon::before {
mask: url("/icons/attention.svg") no-repeat center;
mask-size: contain;
}
}
.md-admonition.is-danger {
border-left-color: #ef4444;
background-color: #fef2f2;
.md-admonition-title {
color: #b91c1c;
}
.md-admonition-icon::before {
mask: url("/icons/warning-filled.svg") no-repeat center;
mask-size: contain;
}
}
.md-admonition.is-tip {
border-left-color: #10b981;
background-color: #ecfdf5;
.md-admonition-title {
color: #065f46;
}
.md-admonition-icon::before {
mask: url("/icons/heart.svg") no-repeat center;
mask-size: contain;
}
}

View File

@@ -0,0 +1,15 @@
export type Markdown = {
content: string;
frontmatter: Frontmatter;
toc: Heading[];
};
export type Frontmatter = Record<string, any> & {
title: string;
};
export type Heading = {
id: string;
content: string;
children: Heading[];
};

View File

@@ -0,0 +1,14 @@
@import url("./shiki.css");
@import url("./admonition.css");
@import url("./tabs.css");
code {
font-family:
ui-monospace, "Cascadia Code", "Source Code Pro", Menlo, Consolas,
"DejaVu Sans Mono", monospace;
font-weight: normal;
}
pre {
overflow: auto;
}

View File

@@ -0,0 +1,81 @@
.shiki {
margin: 0 -15px;
padding: 15px 0;
background-color: var(--shiki-light-bg);
&,
& span {
color: var(--shiki-light);
font-style: var(--shiki-light-font-style);
font-weight: var(--shiki-light-font-weight);
text-decoration: var(--shiki-light-text-decoration);
}
@media (prefers-color-scheme: dark) {
&,
& span,
html.dark &,
html.dark & span {
color: var(--shiki-dark);
background-color: var(--shiki-dark-bg);
font-style: var(--shiki-dark-font-style);
font-weight: var(--shiki-dark-font-weight);
text-decoration: var(--shiki-dark-text-decoration);
}
}
code {
display: block;
}
.line {
width: 100%;
padding: 0 15px;
display: inline-block;
}
/* line numbers */
&.line-numbers code {
counter-reset: step;
counter-increment: step 0;
}
&.line-numbers .line::before {
content: counter(step);
counter-increment: step;
width: 1rem;
margin-right: 1em;
display: inline-block;
text-align: right;
color: rgba(115, 138, 148, 0.4);
}
/* indent guides */
.indent {
display: inline-block;
position: relative;
left: var(--indent-offset);
}
.indent:empty {
height: 1lh;
vertical-align: bottom;
}
.indent::before {
content: "";
position: absolute;
opacity: 0.15;
width: 1px;
height: 100%;
background-color: currentColor;
}
/* diff */
.line.diff.remove {
background-color: #db0a0a41;
}
.line.diff.add {
background-color: #0adb4954;
}
.line.highlighted {
background-color: #00000024;
}
}

View File

@@ -0,0 +1,50 @@
.md-tabs-bar {
display: none;
gap: 7px;
align-items: flex-end;
}
.md-tabs-tab {
padding: 8px 0;
}
.md-tabs-container {
margin: 20px 0;
}
.js {
.md-tabs-bar {
display: flex;
}
.md-tabs-container {
margin: 0;
> .md-tabs-tab {
display: none;
}
}
.md-tabs {
margin: 20px 0;
}
.md-tabs-tab {
background: #d7dadf;
padding: 8px 18px;
border-top-left-radius: 8px;
border-top-right-radius: 8px;
cursor: pointer;
&.is-active {
background: #eff1f5;
.md-tabs.is-singleton & {
padding: 8px 16px;
border-top-left-radius: 5px;
border-top-right-radius: 5px;
flex: 1;
border-bottom: 1px solid #d8dbe1;
}
}
}
.md-tabs-content {
display: none;
margin: 0 var(--pageMargin);
&.is-active {
display: block;
}
}
}

View File

@@ -0,0 +1,87 @@
import { unified } from "unified";
import { VFile } from "vfile";
import remarkRehype from "remark-rehype";
import rehypeStringify from "rehype-stringify";
import rehypeShiki from "@shikijs/rehype";
import remarkGfm from "remark-gfm";
import remarkDirective from "remark-directive";
import rehypeAutolinkHeadings from "rehype-autolink-headings";
import {
transformerNotationDiff,
transformerNotationHighlight,
transformerRenderIndentGuides,
transformerMetaHighlight,
} from "@shikijs/transformers";
import type { PluginOption } from "vite";
import rehypeTocSlug from "./rehype-toc-slug";
import transformerLineNumbers from "./shiki-transformer-line-numbers";
import remarkParse from "./remark-parse";
import remarkAdmonition from "./remark-admonition";
import remarkTabs from "./remark-tabs";
import rehypeWrapHeadings from "./rehype-wrap-headings";
import remarkLinkMigration from "./link-migration";
export type Options = {
codeLightTheme?: string;
codeDarkTheme?: string;
minLineNumberLines?: number;
tocMaxDepth?: number;
};
export default function ({
codeLightTheme = "catppuccin-latte",
codeDarkTheme = "catppuccin-macchiato",
minLineNumberLines = 4,
tocMaxDepth = 3,
}: Options = {}): PluginOption {
return {
name: "markdown-loader",
async transform(code, id) {
if (id.slice(-3) !== ".md") return;
const file = await unified()
.use(remarkParse)
.use(remarkLinkMigration)
.use(remarkGfm)
.use(remarkDirective)
.use(remarkAdmonition)
.use(remarkTabs)
.use(remarkRehype)
.use(rehypeTocSlug, {
tocMaxDepth,
})
.use(rehypeWrapHeadings)
.use(rehypeAutolinkHeadings)
.use(rehypeShiki, {
defaultColor: false,
themes: {
light: codeLightTheme,
dark: codeDarkTheme,
},
transformers: [
transformerNotationDiff({
matchAlgorithm: "v3",
}),
transformerNotationHighlight(),
transformerRenderIndentGuides(),
transformerMetaHighlight(),
transformerLineNumbers({
minLines: minLineNumberLines,
}),
],
})
.use(rehypeStringify)
.process(
new VFile({
path: id,
value: code,
}),
);
return `
export const content = ${JSON.stringify(String(file))};
export const frontmatter = ${JSON.stringify(file.data.matter)};
export const toc = ${JSON.stringify(file.data.toc)};`;
},
};
}

View File

@@ -0,0 +1,26 @@
import { visit } from "unist-util-visit";
import type { Nodes } from "mdast";
/**
* Rewrites relative links in mkDocs files to point to /docs/...
*
* For this to work the relative link must start at the docs root
*/
export default function remarkLinkMigration() {
return (tree: Nodes) => {
visit(tree, ["link", "definition"], (node) => {
if (node.type !== "link" && node.type !== "definition") {
return;
}
// Skip external links, links pointing to /docs already and anchors
if (!node.url || /^(https?:)?\/\/|mailto:|^#/.test(node.url)) return;
// Remove repeated leading ../ or ./
const cleanUrl = node.url.replace(/^\.\.?|((\.\.?)\/)+|\.md$/g, "");
if (!cleanUrl.startsWith("/")) {
throw new Error(`invalid doc link: ${cleanUrl}`);
}
node.url = `/docs${cleanUrl}`;
});
};
}

View File

@@ -0,0 +1,74 @@
import { VFile } from "vfile";
import type { Nodes } from "hast";
import { toString } from "hast-util-to-string";
import GithubSlugger from "github-slugger";
import { visit } from "unist-util-visit";
import { headingRank } from "hast-util-heading-rank";
import type { Heading } from "..";
const startingRank = 1;
/**
* Adds `id`s to headings and extract out a toc
*/
export default function rehypeTocSlug({
tocMaxDepth,
}: {
tocMaxDepth: number;
}) {
return (tree: Nodes, file: VFile) => {
const slugger = new GithubSlugger();
const toc: Heading[] = [];
let h1Exist = false;
const parentHeadings: Heading[] = [];
const frontmatter: Record<string, any> = file.data.matter
? file.data.matter
: {};
frontmatter.title = "";
visit(tree, "element", (node) => {
const rank = headingRank(node);
if (!rank) return;
let { id } = node.properties;
if (id) {
console.error(
`WARNING: h${rank} has an existing id, it will be overwritten with an auto-generated one: ${file.path}`,
);
}
const content = toString(node);
id = node.properties.id = slugger.slug(content);
if (parentHeadings.length > tocMaxDepth) {
return;
}
if (rank == 1) {
if (h1Exist) {
console.error(
`WARNING: only one "# title" is allowed, ignoring the rest: ${file.path}`,
);
return;
}
h1Exist = true;
frontmatter.title = content;
}
const heading = { id, content, children: [] };
const currentRank = parentHeadings.length - 1 + startingRank;
if (rank > currentRank) {
(parentHeadings.at(-1)?.children ?? toc).push(heading);
parentHeadings.push(heading);
} else if (rank == currentRank) {
(parentHeadings.at(-2)?.children ?? toc).push(heading);
parentHeadings.pop();
parentHeadings.push(heading);
} else {
const i = rank - startingRank - 1;
(parentHeadings?.[i].children ?? toc).push(heading);
while (parentHeadings.length > i + 1) {
parentHeadings.pop();
}
parentHeadings.push(heading);
}
});
file.data.toc = toc;
};
}

View File

@@ -0,0 +1,21 @@
import { visit } from "unist-util-visit";
import { headingRank } from "hast-util-heading-rank";
import type { Nodes } from "hast";
export default function rehypeWrapHeadings() {
return (tree: Nodes) => {
visit(tree, "element", (node) => {
if (!headingRank(node)) {
return;
}
node.children = [
{
type: "element",
tagName: "span",
properties: {},
children: node.children,
},
];
});
};
}

View File

@@ -0,0 +1,53 @@
import { visit } from "unist-util-visit";
import type { Paragraph, Text, Root } from "mdast";
const names = ["note", "important", "danger", "tip"];
// Adapted from https://github.com/remarkjs/remark-directive
export default function remarkAdmonition() {
return (tree: Root) => {
visit(tree, (node) => {
if (node.type != "containerDirective" || !names.includes(node.name)) {
return;
}
const data = (node.data ||= {});
data.hName = "div";
data.hProperties = {
className: `md-admonition is-${node.name}`,
};
let title: string;
if (node.children?.[0].data?.directiveLabel) {
const p = node.children.shift() as Paragraph;
title = (p.children[0] as Text).value;
} else {
title = node.name;
}
node.children = [
{
type: "paragraph",
data: {
hName: "div",
hProperties: { className: ["md-admonition-title"] },
},
children: [
{
type: "text",
data: {
hName: "span",
hProperties: { className: ["md-admonition-icon"] },
},
value: "",
},
{
type: "text",
value: title,
},
],
},
...node.children,
];
});
};
}

View File

@@ -0,0 +1,24 @@
import type { Data, Processor } from "unified";
import { matter } from "vfile-matter";
import {
fromMarkdown,
type Extension as MarkdownExtension,
} from "mdast-util-from-markdown";
import type { Extension } from "micromark-util-types";
export default function remarkParse(this: Processor) {
const self = this;
this.parser = (document, file) => {
matter(file, { strip: true });
// FIXME: fromMarkdown has a broken type definition, fix it and upstream
const extensions = (self.data("micromarkExtensions" as unknown as Data) ||
[]) as unknown as Extension[];
const mdastExtensions = (self.data(
"fromMarkdownExtensions" as unknown as Data,
) || []) as unknown as MarkdownExtension[];
return fromMarkdown(String(file), {
extensions,
mdastExtensions,
});
};
}

View File

@@ -0,0 +1,93 @@
import { visit } from "unist-util-visit";
import type { Paragraph, Root, Text } from "mdast";
export default function remarkTabs() {
return (tree: Root) => {
visit(tree, (node) => {
if (node.type != "containerDirective" || node.name != "tabs") {
return;
}
const data = (node.data ||= {});
data.hName = "div";
data.hProperties = {
className: "md-tabs",
};
let tabIndex = 0;
let tabTitles: string[] = [];
for (const [i, child] of node.children.entries()) {
if (child.type != "containerDirective" || child.name != "tab") {
continue;
}
let tabTitle: string;
if (child.children?.[0].data?.directiveLabel) {
const p = child.children.shift() as Paragraph;
tabTitle = (p.children[0] as Text).value;
} else {
tabTitle = "(empty)";
}
tabTitles.push(tabTitle);
node.children[i] = {
type: "containerDirective",
name: "",
data: {
hName: "div",
hProperties: {
className: "md-tabs-container",
},
},
children: [
{
type: "paragraph",
data: {
hName: "div",
hProperties: {
className: `md-tabs-tab ${tabIndex == 0 ? "is-active" : ""}`,
},
},
children: [{ type: "text", value: tabTitle }],
},
{
type: "containerDirective",
name: "",
data: {
hName: "div",
hProperties: {
className: `md-tabs-content ${tabIndex == 0 ? "is-active" : ""}`,
},
},
children: child.children,
},
],
};
tabIndex++;
}
if (tabTitles.length === 1) {
data.hProperties.className += " is-singleton";
}
// Add tab bar for when js is enabled
node.children = [
{
type: "paragraph",
data: {
hName: "div",
hProperties: {
className: "md-tabs-bar",
},
},
children: tabTitles.map((tabTitle, tabIndex) => ({
type: "text",
data: {
hName: "div",
hProperties: {
className: `md-tabs-tab ${tabIndex == 0 ? "is-active" : ""}`,
},
},
value: tabTitle,
})),
},
...node.children,
];
});
};
}

View File

@@ -0,0 +1,26 @@
import type { Element } from "hast";
export default function transformerLineNumbers({
minLines,
}: {
minLines: number;
}) {
return {
pre(pre: Element) {
const code = pre.children?.[0] as Element | undefined;
if (!code) {
return;
}
const lines = code.children.reduce((lines, node) => {
if (node.type !== "element" || node.properties.class != "line") {
return lines;
}
return lines + 1;
}, 0);
if (lines < minLines) {
return;
}
pre.properties.class += " line-numbers";
},
};
}

View File

@@ -0,0 +1,168 @@
<script lang="ts">
import favicon from "$lib/assets/favicon.svg";
import type { NavItem } from "$lib";
import { onNavigate } from "$app/navigation";
import { onMount } from "svelte";
import type {
Pagefind,
PagefindSearchFragment,
} from "vite-plugin-pagefind/types";
import "./global.css";
const { data, children } = $props();
const docs = $derived(data.docs);
let menuOpen = $state(false);
onNavigate(() => {
menuOpen = false;
query = "";
document.documentElement.classList.remove("no-scroll");
});
let pagefind: Pagefind | undefined;
let query = $state("");
let searchResults: PagefindSearchFragment[] = $state([]);
onMount(async () => {
// @ts-expect-error
pagefind = await import("/pagefind/pagefind.js");
pagefind!.init();
});
$effect(() => {
(async () => {
query;
const search = await pagefind?.debouncedSearch(query);
if (search) {
searchResults = await Promise.all(
search.results.slice(0, 5).map((r) => r.data()),
);
}
})();
});
function toggleMenu() {
menuOpen = !menuOpen;
window.scrollTo({ top: 0 });
document.documentElement.classList.toggle("no-scroll", menuOpen);
}
</script>
<svelte:head>
<link rel="icon" href={favicon} />
</svelte:head>
<div class="global-bar">
<span class="logo">Clan Docs</span>
<nav>
<div class="search">
<input type="search" bind:value={query} />
{#if searchResults.length > 0}
<ul>
{#each searchResults as searchResult}
<li class="search-result">
<div class="search-result-title">
<a href={searchResult.url.slice(0, -".html".length)}
>{searchResult.meta.title}</a
>
</div>
<div class="search-result-excerpt">
{@html searchResult.excerpt}
</div>
</li>
{/each}
</ul>
{/if}
</div>
<div class={["menu", menuOpen && "open"]}>
<button onclick={toggleMenu}>Menu</button>
<ul>
{@render navItems(docs.navItems)}
</ul>
</div>
</nav>
</div>
<main>
{@render children?.()}
</main>
{#snippet navItems(items: NavItem[])}
{#each items as item}
{@render navItem(item)}
{/each}
{/snippet}
{#snippet navItem(item: NavItem)}
{#if "items" in item}
<li>
<details open={!item.collapsed}>
<summary><span class="label group">{item.label}</span></summary>
<ul>
{@render navItems(item.items)}
</ul>
</details>
</li>
{:else}
<li>
<a href={item.link}>{item.label}</a>
</li>
{/if}
{/snippet}
<style>
.global-bar {
height: var(--globalBarHeight);
display: flex;
justify-content: space-between;
align-items: center;
padding: 0 var(--pageMargin);
color: var(--fgInvertedColor);
background: var(--bgInvertedColor);
}
.search {
& > ul {
position: fixed;
z-index: 10;
left: 0;
top: var(--globalBarHeight);
width: 100vw;
height: 100vh;
background: #fff;
}
}
.search-result {
padding: 15px;
border-bottom: 1px solid #a3a3a3;
}
.search-result-title {
padding: 0 0 15px;
}
.search-result-excerpt {
color: #666;
}
.menu {
color: var(--fgColor);
& > ul {
visibility: hidden;
position: fixed;
left: 0;
z-index: 10;
top: var(--globalBarHeight);
width: 100vw;
height: 100vh;
background: #fff;
}
&.open > ul {
visibility: visible;
}
li {
padding-left: 1em;
}
}
nav {
display: flex;
align-items: center;
}
ul {
list-style: none;
padding: 0;
margin: 0;
}
</style>

View File

@@ -0,0 +1,9 @@
import { Docs } from "$lib";
export const prerender = true;
export async function load() {
return {
docs: await new Docs().init(),
};
}

View File

@@ -0,0 +1 @@
<h1>Welcome to Clan</h1>

View File

@@ -0,0 +1,338 @@
<script lang="ts">
import "$lib/markdown/main.css";
import { visit, type Heading as ArticleHeading } from "$lib/docs";
import { onMount } from "svelte";
const { data } = $props();
type Heading = ArticleHeading & {
index: number;
scrolledPast: number;
element: Element;
children: Heading[];
};
let nextHeadingIndex = 0;
const headings = $derived(normalizeHeadings(data.toc));
let tocOpen = $state(false);
let tocEl: HTMLElement;
let contentEl: HTMLElement;
let currentHeading: Heading | null = $state(null);
let observer: IntersectionObserver | undefined;
const defaultTocContent = "Table of contents";
const currentTocContent = $derived.by(() => {
if (tocOpen) {
return defaultTocContent;
}
return currentHeading?.content || defaultTocContent;
});
$effect(() => {
// Make sure the effect is triggered on content change
data.content;
observer?.disconnect();
observer = new IntersectionObserver(onIntersectionChange, {
threshold: 1,
rootMargin: `${-tocEl.offsetHeight}px 0px 0px`,
});
const els = contentEl.querySelectorAll("h1,h2,h3,h4,h5,h6");
for (const el of els) {
observer.observe(el);
}
});
onMount(() => {
const onClick = (ev: MouseEvent) => {
const targetTabEl = (ev.target as HTMLElement).closest(".md-tabs-tab");
if (!targetTabEl || targetTabEl.classList.contains(".is-active")) {
return;
}
const tabsEl = targetTabEl.closest(".md-tabs")!;
const tabEls = tabsEl.querySelectorAll(".md-tabs-tab")!;
const tabIndex = Array.from(tabEls).indexOf(targetTabEl);
if (tabIndex == -1) {
return;
}
const tabContentEls = tabsEl.querySelectorAll(".md-tabs-content");
const tabContentEl = tabContentEls[tabIndex];
if (!tabContentEl) {
return;
}
tabEls.forEach((tabEl) => tabEl.classList.remove("is-active"));
targetTabEl.classList.add("is-active");
tabContentEls.forEach((tabContentEl) =>
tabContentEl.classList.remove("is-active"),
);
tabContentEl.classList.add("is-active");
};
document.addEventListener("click", onClick);
return () => {
document.removeEventListener("click", onClick);
};
});
function normalizeHeadings(headings: ArticleHeading[]): Heading[] {
return headings.map((heading) => ({
...heading,
index: nextHeadingIndex++,
scrolledPast: 0,
children: normalizeHeadings(heading.children),
})) as Heading[];
}
async function onIntersectionChange(entries: IntersectionObserverEntry[]) {
// Record each heading's scrolledPast
for (const entry of entries) {
visit(headings, (heading) => {
if (heading.id != entry.target.id) {
return;
}
heading.element = entry.target;
heading.scrolledPast =
entry.intersectionRatio < 1 &&
entry.boundingClientRect.top < entry.rootBounds!.top
? entry.rootBounds!.top - entry.boundingClientRect.top
: 0;
return false;
})!;
}
let last: Heading | null = null;
let current: Heading | null = null;
// Find the last heading with scrolledPast > 0
visit(headings, (heading) => {
if (last && last.scrolledPast > 0 && heading.scrolledPast === 0) {
current = last;
return false;
}
last = heading;
});
currentHeading = current;
}
function scrollToHeading(ev: Event, heading: Heading) {
ev.preventDefault();
heading.element.scrollIntoView({
behavior: "smooth",
});
tocOpen = false;
}
function scrollToTop(ev: Event) {
ev.preventDefault();
window.scrollTo({
top: 0,
behavior: "smooth",
});
tocOpen = false;
}
</script>
<div class="container">
<div class="toc">
<h2 class="toc-title" bind:this={tocEl}>
<button class="toc-label" onclick={() => (tocOpen = !tocOpen)}>
<span>
{currentTocContent}
</span>
<svg
fill="none"
height="24"
stroke="currentColor"
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
viewBox="0 0 24 24"
width="18"
xmlns="http://www.w3.org/2000/svg"
><polyline points="9 18 15 12 9 6" /></svg
>
</button>
</h2>
{#if tocOpen}
<ul class="toc-menu">
<li>
<a href={`#${headings[0].id}`} onclick={scrollToTop}
>{headings[0].content}</a
>
</li>
{@render tocLinks(headings[0].children)}
</ul>
{/if}
</div>
<div class="content" bind:this={contentEl}>
{@html data.content}
</div>
<footer>
{#if data.frontmatter.previous}
<a class="pointer previous" href={data.frontmatter.previous.link}>
<div class="pointer-arrow">&lt;</div>
<div>
<div class="pointer-label">Previous</div>
<div class="pointer-title">{data.frontmatter.previous.label}</div>
</div>
</a>
{:else}
<div class="pointer previous"></div>
{/if}
{#if data.frontmatter.next}
<a class="pointer next" href={data.frontmatter.next.link}>
<div>
<div class="pointer-label">Next</div>
<div class="pointer-title">{data.frontmatter.next.label}</div>
</div>
<div class="pointer-arrow">&gt;</div>
</a>
{:else}
<div class="pointer previous"></div>
{/if}
</footer>
</div>
{#snippet tocLinks(headings: Heading[])}
{#each headings as heading}
{@render tocLink(heading)}
{/each}
{/snippet}
{#snippet tocLink(heading: Heading)}
<li>
<a
href={`#${heading.id}`}
onclick={(ev) => {
scrollToHeading(ev, heading);
}}>{heading.content}</a
>
{#if heading.children.length != 0}
<ul>
{@render tocLinks(heading.children)}
</ul>
{/if}
</li>
{/snippet}
<style>
.toc {
position: sticky;
top: 0;
z-index: 1;
h2 {
margin: 0;
font-size: 16px;
font-weight: normal;
padding: 15px 20px;
background: #e5e5e5;
}
}
button {
padding: 0;
background: transparent;
border: 0;
font: inherit;
color: inherit;
}
.toc-title {
display: flex;
justify-content: space-between;
}
.toc-label {
display: flex;
gap: 3px;
align-items: center;
}
.toc-menu {
position: absolute;
top: 100%;
left: 0;
right: 0;
margin: 0;
padding: 15px 20px;
background: #fff;
list-style: none;
box-shadow: 0 3px 5px #00000020;
ul {
list-style: none;
padding: 0 15px;
}
li {
padding: 3px 0;
}
}
.content {
padding: 0 15px;
width: 100vw;
:global {
& :is(h1, h2, h3, h4, h5, h6) {
margin-left: calc(-1 * var(--pageMargin));
display: flex;
align-items: center;
&.is-scrolledPast {
opacity: 0;
}
&.is-ghost {
position: fixed;
z-index: 1;
margin: 0;
left: 0;
> span {
transform-origin: left top;
}
}
a {
text-decoration: none;
}
.icon {
display: flex;
align-items: center;
}
.icon::before {
content: "🔗";
font-size: 14px;
visibility: hidden;
}
&:hover {
.icon::before {
visibility: visible;
}
&.is-ghost {
.icon::before {
visibility: hidden;
}
}
}
}
}
}
footer {
display: flex;
justify-content: space-between;
gap: 15px;
margin: 20px 15px;
}
.pointer {
display: flex;
align-items: center;
flex: 1;
box-shadow: 0 2px 5px #00000030;
border-radius: 8px;
padding: 20px;
gap: 10px;
text-decoration: none;
color: inherit;
}
.pointer:empty {
box-shadow: none;
}
.pointer.next {
text-align: right;
justify-content: end;
}
.pointer-title {
font-size: 26px;
}
.pointer-label {
font-size: 16px;
}
</style>

View File

@@ -0,0 +1,10 @@
import { error } from "@sveltejs/kit";
export async function load({ params, parent }) {
const { docs } = await parent();
const article = await docs.getArticle(`/${params.path}`);
if (!article) {
error(404, "");
}
return article;
}

View File

@@ -0,0 +1,548 @@
## Status
Accepted
## Context
Current state as of writing:
To define a service in Clan, you need to define two things:
- `clanModule` - defined by module authors
- `inventory` - defined by users
The `clanModule` is currently a plain NixOS module. It is conditionally imported into each machine depending on the `service` and `role`.
A `role` is a function of a machine within a service. For example in the `backup` service there are `client` and `server` roles.
The `inventory` contains the settings for the user/consumer of the module. It describes what `services` run on each machine and with which `roles`.
Additionally any `service` can be instantiated multiple times.
This ADR proposes that we change how to write a `clanModule`. The `inventory` should get a new attribute called `instances` that allow for configuration of these modules.
### Status Quo
In this example the user configures 2 instances of the `networking` service:
The *user* defines
```nix
{
inventory.services = {
# anything inside an instance is instance specific
networking."instance1" = {
roles.client.tags = [ "all" ];
machines.foo.config = { ... /* machine specific settings */ };
# this will not apply to `clients` outside of `instance1`
roles.client.config = { ... /* client specific settings */ };
};
networking."instance2" = {
roles.server.tags = [ "all" ];
config = { ... /* applies to every machine that runs this instance */ };
};
};
}
```
The *module author* defines:
```nix
# networking/roles/client.nix
{ config, ... }:
let
instances = config.clan.inventory.services.networking or { };
serviceConfig = config.clan.networking;
in {
## Set some nixos options
}
```
### Problems
Problems with the current way of writing clanModules:
1. No way to retrieve the config of a single service instance, together with its name.
2. Directly exporting a single, anonymous nixosModule without any intermediary attribute layers doesn't leave room for exporting other inventory resources such as potentially `vars` or `homeManagerConfig`.
3. Can't access multiple config instances individually.
Example:
```nix
inventory = {
services = {
network.c-base = {
instanceConfig.ips = {
mors = "172.139.0.2";
};
};
network.gg23 = {
instanceConfig.ips = {
mors = "10.23.0.2";
};
};
};
};
```
This doesn't work because all instance configs are applied to the same namespace. So this results in a conflict currently.
Resolving this problem means that new inventory modules cannot be plain nixos modules anymore. If they are configured via `instances` / `instanceConfig` they cannot be configured without using the inventory. (There might be ways to inject instanceConfig but that requires knowledge of inventory internals)
4. Writing modules for multiple instances is cumbersome. Currently the clanModule author has to write one or multiple `fold` operations for potentially every nixos option to define how multiple service instances merge into every single one option. The new idea behind this adr is to pull the common fold function into the outer context provide it as a common helper. (See the example below. `perInstance` analog to the well known `perSystem` of flake-parts)
5. Each role has a different interface. We need to render that interface into json-schema which includes creating an unnecessary test machine currently. Defining the interface at a higher level (outside of any machine context) allows faster evaluation and an isolation by design from any machine.
This allows rendering the UI (options tree) of a service by just knowing the service and the corresponding roles without creating a dummy machine.
6. The interface of defining config is wrong. It is possible to define config that applies to multiple machine at once. It is possible to define config that applies to
a machine as a hole. But this is wrong behavior because the options exist at the role level. So config must also always exist at the role level.
Currently we merge options and config together but that may produce conflicts. Those module system conflicts are very hard to foresee since they depend on what roles exist at runtime.
## Proposed Change
We will create a new module class which is defined by `_class = "clan.service"` ([documented here](https://nixos.org/manual/nixpkgs/stable/#module-system-lib-evalModules-param-class)).
Existing clan modules will still work by continuing to be plain NixOS modules. All new modules can set `_class = "clan.service";` to use the proposed features.
In short the change introduces a new module class that makes the currently necessary folding of `clan.service`s `instances` and `roles` a common operation. The module author can define the inner function of the fold operations which is called a `clan.service` module.
There are the following attributes of such a module:
### `roles.<roleName>.interface`
Each role can have a different interface for how to be configured.
I.e.: A `client` role might have different options than a `server` role.
This attribute should be used to define `options`. (Not `config` !)
The end-user defines the corresponding `config`.
This submodule will be evaluated for each `instance role` combination and passed as argument into `perInstance`.
This submodules `options` will be evaluated to build the UI for that module dynamically.
### **Result attributes**
Some common result attributes are produced by modules of this proposal, those will be referenced later in this document but are commonly defined as:
- `nixosModule` A single nixos module. (`{config, ...}:{ environment.systemPackages = []; }`)
- `services.<serviceName>` An attribute set of `_class = clan.service`. Which contain the same thing as this whole ADR proposes.
- `vars` To be defined. Reserved for now.
### `roles.<roleName>.perInstance`
This acts like a function that maps over all `service instances` of a given `role`.
It produces the previously defined **result attributes**.
I.e. This allows to produce multiple `nixosModules` one for every instance of the service.
Hence making multiple `service instances` convenient by leveraging the module-system merge behavior.
### `perMachine`
This acts like a function that maps over all `machines` of a given `service`.
It produces the previously defined **result attributes**.
I.e. this allows to produce exactly one `nixosModule` per `service`.
Making it easy to set nixos-options only once if they have a one-to-one relation to a service being enabled.
Note: `lib.mkIf` can be used on i.e. `roleName` to make the scope more specific.
### `services.<serviceName>`
This allows to define nested services.
i.e the *service* `backup` might define a nested *service* `ssh` which sets up an ssh connection.
This can be defined in `perMachine` and `perInstance`
- For Every `instance` a given `service` may add multiple nested `services`.
- A given `service` may add a static set of nested `services`; Even if there are multiple instances of the same given service.
Q: Why is this not a top-level attribute?
A: Because nested service definitions may also depend on a `role` which must be resolved depending on `machine` and `instance`. The top-level module doesn't know anything about machines. Keeping the service layer machine agnostic allows us to build the UI for a module without adding any machines. (One of the problems with the current system)
```
zerotier/default.nix
```
```nix
# Some example module
{
_class = "clan.service";
# Analog to flake-parts 'perSystem' only that it takes instance
# The exact arguments will be specified and documented along with the actual implementation.
roles.client.perInstance = {
# attrs : settings of that instance
settings,
# string : name of the instance
instanceName,
# { name :: string , roles :: listOf string; }
machine,
# { {roleName} :: { machines :: listOf string; } }
roles,
...
}:
{
# Return a nixos module for every instance.
# The module author must be aware that this may return multiple modules (one for every instance) which are merged natively
nixosModule = {
config.debug."${instanceName}-client" = instanceConfig;
};
};
# Function that is called once for every machine with the role "client"
# Receives at least the following parameters:
#
# machine :: { name :: String, roles :: listOf string; }
# Name of the machine
#
# instances :: { instanceName :: { roleName :: { machines :: [ string ]; }}}
# Resolved roles
# Same type as currently in `clan.inventory.services.<ServiceName>.<InstanceName>.roles`
#
# The exact arguments will be specified and documented along with the actual implementation.
perMachine = {machine, instances, ... }: {
nixosModule =
{ lib, ... }:
{
# Some shared code should be put into a shared file
# Which is then imported into all/some roles
imports = [
../shared.nix
] ++
(lib.optional (builtins.elem "client" machine.roles)
{
options.debug = lib.mkOption {
type = lib.types.attrsOf lib.types.raw;
};
});
};
};
}
```
## Inventory.instances
This document also proposes to add a new attribute to the inventory that allow for exclusive configuration of the new modules.
This allows to better separate the new and the old way of writing and configuring modules. Keeping the new implementation more focussed and keeping existing technical debt out from the beginning.
The following thoughts went into this:
- Getting rid of `<serviceName>`: Using only the attribute name (plain string) is not sufficient for defining the source of the service module. Encoding meta information into it would also require some extensible format specification and parser.
- removing instanceConfig and machineConfig: There is no such config. Service configuration must always be role specific, because the options are defined on the role.
- renaming `config` to `settings` or similar. Since `config` is a module system internal name.
- Tags and machines should be an attribute set to allow setting `settings` on that level instead.
```nix
{
inventory.instances = {
"instance1" = {
# Allows to define where the module should be imported from.
module = {
input = "clan-core";
name = "borgbackup";
};
# settings that apply to all client machines
roles.client.settings = {};
# settings that apply to the client service of machine with name <machineName>
# There might be a server service that takes different settings on the same machine!
roles.client.machines.<machineName>.settings = {};
# settings that apply to all client-instances with tag <tagName>
roles.client.tags.<tagName>.settings = {};
};
"instance2" = {
# ...
};
};
}
```
## Iteration note
We want to implement the system as described. Once we have sufficient data on real world use-cases and modules we might revisit this document along with the updated implementation.
## Real world example
The following module demonstrates the idea in the example of *borgbackup*.
```nix
{
_class = "clan.service";
# Define the 'options' of 'settings' see argument of perInstance
roles.server.interface =
{ lib, ... }:
{
options = lib.mkOption {
type = lib.types.str;
default = "/var/lib/borgbackup";
description = ''
The directory where the borgbackup repositories are stored.
'';
};
};
roles.server.perInstance =
{
instanceName,
settings,
roles,
...
}:
{
nixosModule =
{ config, lib, ... }:
let
dir = config.clan.core.settings.directory;
machineDir = dir + "/vars/per-machine/";
allClients = roles.client.machines;
in
{
# services.borgbackup is a native nixos option
config.services.borgbackup.repos =
let
borgbackupIpMachinePath = machine: machineDir + machine + "/borgbackup/borgbackup.ssh.pub/value";
machinesMaybeKey = builtins.map (
machine:
let
fullPath = borgbackupIpMachinePath machine;
in
if builtins.pathExists fullPath then
machine
else
lib.warn ''
Machine ${machine} does not have a borgbackup key at ${fullPath},
run `clan vars generate ${machine}` to generate it.
'' null
) allClients;
machinesWithKey = lib.filter (x: x != null) machinesMaybeKey;
hosts = builtins.map (machine: {
name = instanceName + machine;
value = {
path = "${settings.directory}/${machine}";
authorizedKeys = [ (builtins.readFile (borgbackupIpMachinePath machine)) ];
};
}) machinesWithKey;
in
if (builtins.listToAttrs hosts) != [ ] then builtins.listToAttrs hosts else { };
};
};
roles.client.interface =
{ lib, ... }:
{
# There might be a better interface now. This is just how clan borgbackup was configured in the 'old' way
options.destinations = lib.mkOption {
type = lib.types.attrsOf (
lib.types.submodule (
{ name, ... }:
{
options = {
name = lib.mkOption {
type = lib.types.strMatching "^[a-zA-Z0-9._-]+$";
default = name;
description = "the name of the backup job";
};
repo = lib.mkOption {
type = lib.types.str;
description = "the borgbackup repository to backup to";
};
rsh = lib.mkOption {
type = lib.types.nullOr lib.types.str;
default = null;
defaultText = "ssh -i \${config.clan.core.vars.generators.borgbackup.files.\"borgbackup.ssh\".path} -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null";
description = "the rsh to use for the backup";
};
};
}
)
);
default = { };
description = ''
destinations where the machine should be backed up to
'';
};
options.exclude = lib.mkOption {
type = lib.types.listOf lib.types.str;
example = [ "*.pyc" ];
default = [ ];
description = ''
Directories/Files to exclude from the backup.
Use * as a wildcard.
'';
};
};
roles.client.perInstance =
{
instanceName,
roles,
machine,
settings,
...
}:
{
nixosModule =
{
config,
lib,
pkgs,
...
}:
let
allServers = roles.server.machines;
# machineName = config.clan.core.settings.machine.name;
# cfg = config.clan.borgbackup;
preBackupScript = ''
declare -A preCommandErrors
${lib.concatMapStringsSep "\n" (
state:
lib.optionalString (state.preBackupCommand != null) ''
echo "Running pre-backup command for ${state.name}"
if ! /run/current-system/sw/bin/${state.preBackupCommand}; then
preCommandErrors["${state.name}"]=1
fi
''
) (lib.attrValues config.clan.core.state)}
if [[ ''${preCommandErrors[@]} -gt 0 ]]; then
echo "pre-backup commands failed for the following services:"
for state in "''${!preCommandErrors[@]}"; do
echo " $state"
done
exit 1
fi
'';
destinations =
let
destList = builtins.map (serverName: {
name = "${instanceName}-${serverName}";
value = {
repo = "borg@${serverName}:/var/lib/borgbackup/${machine.name}";
rsh = "ssh -i ${
config.clan.core.vars.generators."borgbackup-${instanceName}".files."borgbackup.ssh".path
} -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -o IdentitiesOnly=Yes";
} // settings.destinations.${serverName};
}) allServers;
in
(builtins.listToAttrs destList);
in
{
config = {
# Derived from the destinations
systemd.services = lib.mapAttrs' (
_: dest:
lib.nameValuePair "borgbackup-job-${instanceName}-${dest.name}" {
# since borgbackup mounts the system read-only, we need to run in a ExecStartPre script, so we can generate additional files.
serviceConfig.ExecStartPre = [
''+${pkgs.writeShellScript "borgbackup-job-${dest.name}-pre-backup-commands" preBackupScript}''
];
}
) destinations;
services.borgbackup.jobs = lib.mapAttrs (_destinationName: dest: {
paths = lib.unique (
lib.flatten (map (state: state.folders) (lib.attrValues config.clan.core.state))
);
exclude = settings.exclude;
repo = dest.repo;
environment.BORG_RSH = dest.rsh;
compression = "auto,zstd";
startAt = "*-*-* 01:00:00";
persistentTimer = true;
encryption = {
mode = "repokey";
passCommand = "cat ${config.clan.core.vars.generators."borgbackup-${instanceName}".files."borgbackup.repokey".path}";
};
prune.keep = {
within = "1d"; # Keep all archives from the last day
daily = 7;
weekly = 4;
monthly = 0;
};
}) destinations;
environment.systemPackages = [
(pkgs.writeShellApplication {
name = "borgbackup-create";
runtimeInputs = [ config.systemd.package ];
text = ''
${lib.concatMapStringsSep "\n" (dest: ''
systemctl start borgbackup-job-${dest.name}
'') (lib.attrValues destinations)}
'';
})
(pkgs.writeShellApplication {
name = "borgbackup-list";
runtimeInputs = [ pkgs.jq ];
text = ''
(${
lib.concatMapStringsSep "\n" (
dest:
# we need yes here to skip the changed url verification
''echo y | /run/current-system/sw/bin/borg-job-${dest.name} list --json | jq '[.archives[] | {"name": ("${dest.name}::${dest.repo}::" + .name)}]' ''
) (lib.attrValues destinations)
}) | jq -s 'add // []'
'';
})
(pkgs.writeShellApplication {
name = "borgbackup-restore";
runtimeInputs = [ pkgs.gawk ];
text = ''
cd /
IFS=':' read -ra FOLDER <<< "''${FOLDERS-}"
job_name=$(echo "$NAME" | awk -F'::' '{print $1}')
backup_name=''${NAME#"$job_name"::}
if [[ ! -x /run/current-system/sw/bin/borg-job-"$job_name" ]]; then
echo "borg-job-$job_name not found: Backup name is invalid" >&2
exit 1
fi
echo y | /run/current-system/sw/bin/borg-job-"$job_name" extract "$backup_name" "''${FOLDER[@]}"
'';
})
];
# every borgbackup instance adds its own vars
clan.core.vars.generators."borgbackup-${instanceName}" = {
files."borgbackup.ssh.pub".secret = false;
files."borgbackup.ssh" = { };
files."borgbackup.repokey" = { };
migrateFact = "borgbackup";
runtimeInputs = [
pkgs.coreutils
pkgs.openssh
pkgs.xkcdpass
];
script = ''
ssh-keygen -t ed25519 -N "" -f $out/borgbackup.ssh
xkcdpass -n 4 -d - > $out/borgbackup.repokey
'';
};
};
};
};
perMachine = {
nixosModule =
{ ... }:
{
clan.core.backups.providers.borgbackup = {
list = "borgbackup-list";
create = "borgbackup-create";
restore = "borgbackup-restore";
};
};
};
}
```
## Prior-art
- https://github.com/NixOS/nixops
- https://github.com/infinisil/nixus

View File

@@ -0,0 +1,112 @@
## Status
Accepted
## Context
In the long term we envision the clan application will consist of the following user facing tools in the long term.
- `CLI`
- `TUI`
- `Desktop Application`
- `REST-API`
- `Mobile Application`
We might not be sure whether all of those will exist but the architecture should be generic such that those are possible without major changes of the underlying system.
## Decision
This leads to the conclusion that we should do `library` centric development.
With the current `clan` python code being a library that can be imported to create various tools ontop of it.
All **CLI** or **UI** related parts should be moved out of the main library.
Imagine roughly the following architecture:
``` mermaid
graph TD
%% Define styles
classDef frontend fill:#f9f,stroke:#333,stroke-width:2px;
classDef backend fill:#bbf,stroke:#333,stroke-width:2px;
classDef storage fill:#ff9,stroke:#333,stroke-width:2px;
classDef testing fill:#cfc,stroke:#333,stroke-width:2px;
%% Define nodes
user(["User"]) -->|Interacts with| Frontends
subgraph "Frontends"
CLI["CLI"]:::frontend
APP["Desktop App"]:::frontend
TUI["TUI"]:::frontend
REST["REST API"]:::frontend
end
subgraph "Python"
API["Library <br>for interacting with clan"]:::backend
BusinessLogic["Business Logic<br>Implements actions like 'machine create'"]:::backend
STORAGE[("Persistence")]:::storage
NIX["Nix Eval & Build"]:::backend
end
subgraph "CI/CD & Tests"
TEST["Feature Testing"]:::testing
end
%% Define connections
CLI --> API
APP --> API
TUI --> API
REST --> API
TEST --> API
API --> BusinessLogic
BusinessLogic --> STORAGE
BusinessLogic --> NIX
```
With this very simple design it is ensured that all the basic features remain stable across all frontends.
In the end it is straight forward to create python library function calls in a testing framework to ensure that kind of stability.
Integration tests and smaller unit-tests should both be utilized to ensure the stability of the library.
Note: Library function don't have to be json-serializable in general.
Persistence includes but is not limited to: creating git commits, writing to inventory.json, reading and writing vars, and interacting with persisted data in general.
## Benefits / Drawbacks
- (+) Less tight coupling of frontend- / backend-teams
- (+) Consistency and inherent behavior
- (+) Performance & Scalability
- (+) Different frontends for different user groups
- (+) Documentation per library function makes it convenient to interact with the clan resources.
- (+) Testing the library ensures stability of the underlyings for all layers above.
- (-) Complexity overhead
- (-) library needs to be designed / documented
- (+) library can be well documented since it is a finite set of functions.
- (-) Error handling might be harder.
- (+) Common error reporting
- (-) different frontends need different features. The library must include them all.
- (+) All those core features must be implemented anyways.
- (+) VPN Benchmarking uses the existing library's already and works relatively well.
## Implementation considerations
Not all required details that need to change over time are possible to be pointed out ahead of time.
The goal of this document is to create a common understanding for how we like our project to be structured.
Any future commits should contribute to this goal.
Some ideas what might be needed to change:
- Having separate locations or packages for the library and the CLI.
- Rename the `clan_cli` package to `clan` and move the `cli` frontend into a subfolder or a separate package.
- Python Argparse or other cli related code should not exist in the `clan` python library.
- `__init__.py` should be very minimal. Only init the business logic models and resources. Note that all `__init__.py` files all the way up in the module tree are always executed as part of the python module import logic and thus should be as small as possible.
i.e. `from clan_cli.vars.generators import ...` executes both `clan_cli/__init__.py` and `clan_cli/vars/__init__.py` if any of those exist.
- `api` folder doesn't make sense since the python library `clan` is the api.
- Logic needed for the webui that performs json serialization and deserialization will be some `json-adapter` folder or package.
- Code for serializing dataclasses and typed dictionaries is needed for the persistence layer. (i.e. for read-write of inventory.json)
- The inventory-json is a backend resource, that is internal. Its logic includes merging, unmerging and partial updates with considering nix values and their priorities. Nobody should try to read or write to it directly.
Instead there will be library methods i.e. to add a `service` or to update/read/delete some information from it.
- Library functions should be carefully designed with suitable conventions for writing good api's in mind. (i.e: https://swagger.io/resources/articles/best-practices-in-api-design/)

View File

@@ -0,0 +1,45 @@
## Status
Proposed after some conversation between @lassulus, @Mic92, & @lopter.
## Context
It can be useful to refer to ADRs by their numbers, rather than their full title. To that end, short and sequential numbers are useful.
The issue is that an ADR number is effectively assigned when the ADR is merged, before being merged its number is provisional. Because multiple ADRs can be written at the same time, you end-up with multiple provisional ADRs with the same number, for example this is the third ADR-3:
1. ADR-3-clan-compat: see [#3212];
2. ADR-3-fetching-nix-from-python: see [#3452];
3. ADR-3-numbering-process: this ADR.
This situation makes it impossible to refer to an ADR by its number, and why I (@lopter) went with the arbitrary number 7 in [#3196].
We could solve this problem by using the PR number as the ADR number (@lassulus). The issue is that PR numbers are getting big in clan-core which does not make them easy to remember, or use in conversation and code (@lopter).
Another approach would be to move the ADRs in a different repository, this would reset the counter back to 1, and make it straightforward to keep ADR and PR numbers in sync (@lopter). The issue then is that ADR are not in context with their changes which makes them more difficult to review (@Mic92).
## Decision
A third approach would be to:
1. Commit ADRs before they are approved, so that the next ADR number gets assigned;
1. Open a PR for the proposed ADR;
1. Update the ADR file committed in step 1, so that its markdown contents point to the PR that tracks it.
## Consequences
### ADR have unique and memorable numbers trough their entire life cycle
This makes it easier to refer to them in conversation or in code.
### You need to have commit access to get an ADR number assigned
This makes it more difficult for someone external to the project to contribute an ADR.
### Creating a new ADR requires multiple commits
Maybe a script or CI flow could help with that if it becomes painful.
[#3212]: https://git.clan.lol/clan/clan-core/pulls/3212/
[#3452]: https://git.clan.lol/clan/clan-core/pulls/3452/
[#3196]: https://git.clan.lol/clan/clan-core/pulls/3196/

View File

@@ -0,0 +1,97 @@
## Status
accepted
## Context
In our clan-cli we need to get a lot of values from nix into the python runtime. This is used to determine the hostname, the target ips address, scripts to generate vars, file locations and many more.
Currently we use two different accessing methods:
### Method 1: deployment.json
A json file that serializes some predefined values into a JSON file as build-time artifact.
Downsides:
* no access to flake level values
* all or nothing:
* values are either cached via deployment.json or not. So we can only put cheap values into there,
* in the past var generation script were added here, which added a huge build time overhead for every time we wanted to do any action
* duplicated nix code
* values need duplicated nix code, once to define them at the correct place in the module system (clan.core.vars.generators) and code to accumulate them again for the deployment.json (system.clan.deployment.data)
* This duality adds unnecessary dependencies to the nixos module system.
Benefits:
* Utilize `nix build` for caching the file.
* Caching mechanism is very simple.
### Method 2: Direct access
Directly calling the evaluator / build sandbox via `nix build` and `nix eval`within the Python code
Downsides:
* Access is not cached: Static overhead (see below: \~1.5s) is present every time, if we invoke `nix commands`
* The static overhead depends obviously which value we need to retrieve, since the `evalModules` overhead depends, whether we evaluate some attribute inside a machine or a flake attribute
* Accessing more and more attributes with this method increases the static overhead, which leads to a linear decrease in performance.
* Boilerplate for interacting with the CLI and Error handling code is repeated every time.
Benefits:
* Simple and native interaction with the `nix commands`is rather intuitive
* Custom error handling for each attribute is easy
This sytem could be enhanced with custom nix expressions, which could be used in places where we don't want to put values into deployment.json or want to fetch flake level values. This also has some downsides:
* technical debt
* we have to maintain custom nix expressions inside python code, embedding code is error prone and the language linters won't help you here, so errors are common and harder to debug.
* we need custom error reporting code in case something goes wrong, either the value doesn't exist or there is an reported build error
* no caching/custom caching logic
* currently there is no infrastructure to cache those extra values, so we would need to store them somewhere, we could either enhance one of the many classes we have or don't cache them at all
* even if we implement caching for extra nix expressions, there can be no sharing between extra nix expressions. for example we have 2 nix expressions, one fetches paths and values for all generators and the second one fetches only the values, we still need to execute both of them in both contexts although the second one could be skipped if the first one is already cached
### Method 3: nix select
Move all code that extracts nix values into a common class:
Downsides:
* added complexity for maintaining our own DSL
Benefits:
* we can implement an API (select DSL) to get those values from nix without writing complex nix expressions.
* we can implement caching of those values beyond the runtime of the CLI
* we can use precaching at different endpoints to eliminate most of multiple nix evaluations (except in cases where we have to break the cache or we don't know if we need the value in the value later and getting it is expensive).
## Decision
Use Method 3 (nix select) for extracting values out of nix.
This adds the Flake class in flake.py with a select method, which takes a selector string and returns a python dict.
Example:
```python
from clan_lib.flake import Flake
flake = Flake("github:lassulus/superconfig")
flake.select("nixosConfigurations.*.config.networking.hostName)
```
returns:
```
{
"ignavia": "ignavia",
"mors": "mors",
...
}
```
## Consequences
* Faster execution due to caching most things beyond a single execution, if no cache break happens execution is basically instant, because we don't need to run nix again.
* Better error reporting, since all nix values go through one chokepoint, we can parse error messages in that chokepoint and report them in a more user friendly way, for example if a value is missing at the expected location inside the module system.
* less embedded nix code inside python code
* more portable CLI, since we need to import less modules into the module system and most things can be extracted by the python code directly

View File

@@ -0,0 +1,34 @@
## Status
accepted
## Context
Currently different operations (install, update) have different modes. Install always evals locally and pushes the derivation to a remote system. update has a configurable buildHost and targetHost.
Confusingly install always evals locally and update always evals on the targetHost, so hosts have different semantics in different operations contexts.
## Decision
Add evalHost to make this clear and configurable for the user. This would leave us with:
- evalHost
- buildHost
- targetHost
for the update and install operation.
`evalHost` would be the machine that evaluates the nixos configuration. if evalHost is not localhost, we upload the non secret vars and the nix archived flake (this is usually the same operation) to the evalMachine.
`buildHost` would be what is used by the machine to build, it would correspond to `--build-host` on the nixos-rebuild command or `--builders` for nix build.
`targetHost` would be the machine where the closure gets copied to and activated (either through install or switch-to-configuration). It corresponds to `--targetHost` for nixos-rebuild or where we usually point `nixos-anywhere` to.
This hosts could be set either through CLI args (or forms for the GUI) or via the inventory. If both are given, the CLI args would take precedence.
## Consequences
We now support every deployment model of every tool out there with a bunch of simple flags. The semantics are more clear and we can write some nice documentation.
The install code has to be reworked, since nixos-anywhere has problems with evalHost and targetHost being the same machine, So we would need to kexec first and use the kexec image (or installer) as the evalHost afterwards.
In cases where the evalHost doesn't have access to the targetHost or buildHost, we need to setup temporary entries for the lifetime of the command.

View File

@@ -0,0 +1,14 @@
This section contains the architecture decisions that have been reviewed and generally agreed upon
## What is an ADR?
> An architecture decision record (ADR) is a document that captures an important architecture decision made along with its context and consequences.
!!! Note
For further reading about adr's we recommend [architecture-decision-record](https://github.com/joelparkerhenderson/architecture-decision-record)
## Crafting a new ADR
1. Use the [template](../decisions/template.md)
2. Create the Pull request and gather feedback
3. Retreive your adr-number (see: [numbering](../decisions/03-adr-numbering-process.md))

View File

@@ -0,0 +1,24 @@
## Decision record template by Michael Nygard
This is the template in [Documenting architecture decisions - Michael Nygard](https://thinkrelevance.com/blog/2011/11/15/documenting-architecture-decisions).
You can use [adr-tools](https://github.com/npryce/adr-tools) for managing the ADR files.
In each ADR file, write these sections:
# Title
## Status
What is the status, such as proposed, accepted, rejected, deprecated, superseded, etc.?
## Context
What is the issue that we're seeing that is motivating this decision or change?
## Decision
What is the change that we're proposing and/or doing?
## Consequences
What becomes easier or more difficult to do because of this change?

View File

@@ -0,0 +1,145 @@
# Add Machines
Machines can be added using the following methods
- Create a file `machines/{machine_name}/configuration.nix` (See: [File Autoincludes](../guides/inventory/autoincludes.md))
- Imperative via cli command: `clan machines create`
- Editing nix expressions in flake.nix See [`clan-core.lib.clan`](../reference/options/clan.md)
See the complete [list](../guides/inventory/autoincludes.md) of auto-loaded files.
## Create a machine
::::tabs
:::tab[clan.nix (declarative)]
```nix {3-4}
{
inventory.machines = {
# Define a machine
jon = { };
};
# Additional NixOS configuration can be added here.
machines = {
# jon = { config, ... }: {
# environment.systemPackages = [ pkgs.asciinema ];
# };
};
}
```
:::
:::tab[CLI (imperative)]
```sh
clan machines create jon
```
The imperative command might create a machine folder in `machines/jon`
And might persist information in `inventory.json`
:::
::::
::::tabs
:::tab[file name test]
```nix
{
inventory.machines = {
# Define a machine
jon = { };
};
}
```
::::
### Configuring a machine
:::note
The option: `inventory.machines.<name>` is used to define metadata about the machine
That includes for example `deploy.targethost` `machineClass` or `tags`
The option: `machines.<name>` is used to add extra _nixosConfiguration_ to a machine
:::
Add the following to your `clan.nix` file for each machine.
This example demonstrates what is needed based on a machine called `jon`:
```nix {3-6,15-19}
{
inventory.machines = {
jon = {
# Define tags here (optional)
tags = [ ]; # (1)
};
sara = {
deploy.targetHost = "root@sara";
tags = [ ];
};
};
# Define additional nixosConfiguration here
# Or in /machines/jon/configuration.nix (autoloaded)
machines = {
jon = { config, pkgs, ... }: {
users.users.root.openssh.authorizedKeys.keys = [
"ssh-ed25519 AAAAC3NzaC..." # elided (2)
];
};
};
}
```
1. Tags can be used to automatically add this machine to services later on. - You dont need to set this now.
2. Add your _ssh key_ here - That will ensure you can always login to your machine via _ssh_ in case something goes wrong.
### (Optional) Create a `configuration.nix`
```nix title="./machines/jon/configuration.nix"
{
imports = [
# enables GNOME desktop (optional)
../../modules/gnome.nix
];
# Set nixosOptions here
# Or import your own modules via 'imports'
# ...
}
```
### (Optional) Renaming a Machine
Older templates included static machine folders like `jon` and `sara`.
If your setup still uses such static machines, you can rename a machine folder to match your own machine name:
```bash
git mv ./machines/jon ./machines/<your-machine-name>
```
Since your Clan configuration lives inside a Git repository, remember:
- Only files tracked by Git (`git add`) are recognized.
- Whenever you add, rename, or remove files, run:
```bash
git add ./machines/<your-machine-name>
```
to stage the changes.
---
### (Optional) Removing a Machine
If you want to work with a single machine for now, you can remove other machine entries both from your `flake.nix` and from the `machines` directory. For example, to remove the machine `sara`:
```bash
git rm -rf ./machines/sara
```
Make sure to also remove or update any references to that machine in your `nix files` or `inventory.json` if you have any of that

View File

@@ -0,0 +1,75 @@
A service in clan is a self-contained, reusable unit of system configuration that provides a specific piece of functionality across one or more machines.
Think of it as a recipe for running a tool — like automatic backups, VPN networking, monitoring, etc.
In Clan Services are multi-Host & role-based:
- Roles map machines to logical service responsibilities, enabling structured, clean deployments.
- You can use tags instead of explicit machine names.
To learn more: [Guide about clanService](../guides/services/introduction-to-services.md)
!!! Important
It is recommended to add at least one networking service such as `zerotier` that allows to reach all your clan machines from your setup computer across the globe.
## Configure a Zerotier Network (recommended)
```{.nix title="clan.nix" hl_lines="8-16"}
{
inventory.machines = {
jon = { };
sara = { };
};
inventory.instances = {
zerotier = { # (1)
# Replace with the name (string) of your machine that you will use as zerotier-controller
# See: https://docs.zerotier.com/controller/
# Deploy this machine first to create the network secrets
roles.controller.machines."jon" = { }; # (2)
# Peers of the network
# this line means 'all' clan machines will be 'peers'
roles.peer.tags.all = { }; # (3)
};
};
# ...
# elided
}
```
1. See [services/official](../services/definition.md) for all available services and how to configure them.
Or read [guides/services](../guides/services/community.md) if you want to bring your own
2. Replace `__YOUR_CONTROLLER_` with the *name* of your machine.
3. This line will add all machines of your clan as `peer` to zerotier
## Adding more recommended defaults
Adding the following services is recommended for most users:
```{.nix title="clan.nix" hl_lines="7-14"}
{
inventory.machines = {
jon = { };
sara = { };
};
inventory.instances = {
admin = { # (1)
roles.default.tags.all = { };
roles.default.settings = {
allowedKeys = {
"my-user" = "ssh-ed25519 AAAAC3N..."; # (2)
};
};
};
# ...
# elided
};
}
```
1. The `admin` service will generate a **root-password** and **add your ssh-key** that allows for convienient administration.
2. Equivalent to directly setting `authorizedKeys` like in [configuring a machine](../getting-started/add-machines.md#configuring-a-machine)
3. Adds `user = jon` as a user on all machines. Will create a `home` directory, and prompt for a password before deployment.

View File

@@ -0,0 +1,125 @@
!!! Note "Under construction"
The users concept of clan is not done yet. This guide outlines some solutions from our community.
Defining users can be done in many different ways. We want to highlight two approaches:
- Using clan's [users](../services/official/users.md) service.
- Using a custom approach.
## Adding Users using the [users](../services/official/users.md) service
To add a first *user* this guide will be leveraging two things:
- [services](../services/definition.md): Allows to bind arbitrary logic to something we call an `ìnstance`.
- [services/users](../services/official/users.md): Implements logic for adding a single user perInstance.
The example shows how to add a user called `jon`:
```{.nix title="clan.nix" hl_lines="7-21"}
{
inventory.machines = {
jon = { };
sara = { };
};
inventory.instances = {
jon-user = { # (1)
module.name = "users";
roles.default.tags.all = { }; # (2)
roles.default.settings = {
user = "jon"; # (3)
groups = [
"wheel" # Allow using 'sudo'
"networkmanager" # Allows to manage network connections.
"video" # Allows to access video devices.
"input" # Allows to access input devices.
];
};
};
# ...
# elided
};
}
```
1. Add `user = jon` as a user on all machines. Will create a `home` directory, and prompt for a password before deployment.
2. Add this user to `all` machines
3. Define the `name` of the user to be `jon`
The `users` service creates a `/home/jon` directory, allows `jon` to sign in and will take care of the user's password.
For more information see [services/users](../services/official/users.md)
## Using a custom approach
Some people like to define a `users` folder in their repository root.
That allows to bind all user specific logic to a single place (`default.nix`)
Which can be imported into individual machines to make the user available on that machine.
```bash
.
├── machines
│   ├── jon
# ......
├── users
│   ├── jon
│ │ └── default.nix # <- a NixOS module; sets some options
# ... ... ...
```
## using [home-manager](https://github.com/nix-community/home-manager)
When using clan's `users` service it is possible to define extraModules.
In fact this is always possible when using clan's services.
We can use this property of clan services to bind a nixosModule to the user, which configures home-manager.
```{.nix title="clan.nix" hl_lines="22"}
{
inventory.machines = {
jon = { };
sara = { };
};
inventory.instances = {
jon-user = {
module.name = "users";
roles.default.tags.all = { };
roles.default.settings = {
user = "jon",
groups = [
"wheel"
"networkmanager"
"video"
"input"
];
};
roles.default.extraModules = [ ./users/jon/home.nix ]; # (1)
};
# ...
# elided
};
}
```
1. Type `path` or `string`: Must point to a separate file. Inlining a module is not possible
!!! Note "This is inspiration"
Our community might come up with better solutions soon.
We are seeking contributions to improve this pattern if you have a nicer solution in mind.
```nix title="users/jon/home.nix"
# NixOS module to import home-manager and the home-manager configuration of 'jon'
{ self, ...}:
{
imports = [ self.inputs.home-manager.nixosModules.default ];
home-manager.users.jon = {
imports = [
./home-configuration.nix
];
};
}
```

View File

@@ -0,0 +1,74 @@
By default clan uses [disko](https://github.com/nix-community/disko) which allows for declarative disk partitioning.
To see what disk templates are available run:
```{.shellSession hl_lines="10" .no-copy}
$ clan templates list
Available 'clan' template
├── <builtin>
│ ├── default: Initialize a new clan flake
│ ├── flake-parts: Flake-parts
│ └── minimal: for clans managed via (G)UI
Available 'disko' templates
├── <builtin>
│ └── single-disk: A simple ext4 disk with a single partition
Available 'machine' templates
├── <builtin>
│ ├── demo-template: Demo machine for the CLAN project
│ ├── flash-installer: Initialize a new flash-installer machine
│ ├── new-machine: Initialize a new machine
│ └── test-morph-template: Morph a machine
```
For this guide we will select the `single-disk` template, that uses `A simple ext4 disk with a single partition`.
!!! tip
For advanced partitioning, see [Disko templates](https://github.com/nix-community/disko-templates) or [Disko examples](https://github.com/nix-community/disko/tree/master/example).
You can also [contribute a disk template to clan core](https://docs.clan.lol/guides/disko-templates/community/)
To setup a disk schema for a machine run
```bash
clan templates apply disk single-disk jon --set mainDisk ""
```
Which should fail and give the valid options for the specific hardware:
```shellSession
Invalid value for placeholder mainDisk - Valid options:
/dev/disk/by-id/nvme-WD_PC_SN740_SDDQNQD-512G-1201_232557804368
```
Re-run the command with the correct disk:
```bash
clan templates apply disk single-disk jon --set mainDisk "/dev/disk/by-id/nvme-WD_PC_SN740_SDDQNQD-512G-1201_232557804368"
```
Should now be successful
```shellSession
Applied disk template 'single-disk' to machine 'jon'
```
A disko.nix file should be created in `machines/jon`
You can have a look and customize it if needed.
!!! Danger
Don't change the `disko.nix` after the machine is installed for the first time, unless you really know what you are doing.
Changing disko configuration requires wiping and reinstalling the machine.
## Deploy the machine
**Finally deployment time!**
This command is destructive and will format your disk and install NixOS on it! It is equivalent to appending `--phases kexec,disko,install,reboot`.
```bash
clan machines install [MACHINE] --target-host root@<IP>
```

View File

@@ -0,0 +1,28 @@
### Generate Facts and Vars
Typically, this step is handled automatically when a machine is deployed. However, to enable the use of `nix flake check` with your configuration, it must be completed manually beforehand.
Currently, generating all the necessary facts requires two separate commands. This is due to the coexistence of two parallel secret management solutions:
the newer, recommended version (`clan vars`) and the older version (`clan facts`) that we are slowly phasing out.
To generate both facts and vars, execute the following commands:
```sh
clan facts generate && clan vars generate
```
### Check Configuration
Validate your configuration by running:
```bash
nix flake check
```
This command helps ensure that your system configuration is correct and free from errors.
!!! Tip
You can integrate this step into your [Continuous Integration](https://en.wikipedia.org/wiki/Continuous_integration) workflow to ensure that only valid Nix configurations are merged into your codebase.

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