Compare commits
61 Commits
feat/snaps
...
hgl-site
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
271b6fe7fc | ||
|
|
b899f95cf6 | ||
|
|
f9fe1b8913 | ||
|
|
fc8a65c388 | ||
|
|
75f722bc79 | ||
|
|
38f3ea6dad | ||
|
|
9c5b0ed077 | ||
|
|
0dad11ffcf | ||
|
|
9144f5a3cd | ||
|
|
f66b96c102 | ||
|
|
7d3972b993 | ||
|
|
d61a042b76 | ||
|
|
2f05eccace | ||
|
|
8779dc07f0 | ||
|
|
ae6eb1a822 | ||
|
|
57c91c3da3 | ||
|
|
c5a8765a65 | ||
|
|
5ec14e51d4 | ||
|
|
a4cc333533 | ||
|
|
5299fe7259 | ||
|
|
e6a9bcbb69 | ||
|
|
b46f841257 | ||
|
|
14847ba846 | ||
|
|
6eb4c4c1e9 | ||
|
|
520c926d6d | ||
|
|
1205f74f87 | ||
|
|
9b392b66ee | ||
|
|
4e37f53b7a | ||
|
|
8eec4c89c5 | ||
|
|
9812d4114f | ||
|
|
6d622f7f68 | ||
|
|
c62995f91f | ||
|
|
7f0e6d74e6 | ||
|
|
bf46ea1ebb | ||
|
|
4ba722dd36 | ||
|
|
61baf0f6c3 | ||
|
|
c252dd7b47 | ||
|
|
4aa01a63dc | ||
|
|
8030b64cdb | ||
|
|
cbe7e27f91 | ||
|
|
d1e59fedb1 | ||
|
|
b3dd1c4a46 | ||
|
|
6614138fb8 | ||
|
|
92f87e169c | ||
|
|
a451946ab4 | ||
|
|
c7a1d7ce29 | ||
|
|
0e06ce3cca | ||
|
|
1bb1b966d6 | ||
|
|
db98d106a1 | ||
|
|
a40c6884d9 | ||
|
|
5cac9e7704 | ||
|
|
808491c71c | ||
|
|
68afbb564e | ||
|
|
11d851e934 | ||
|
|
d825a6b8c0 | ||
|
|
3187ad3f5b | ||
|
|
84ab04fc06 | ||
|
|
7112f608a7 | ||
|
|
70523f75fa | ||
|
|
25db58ce11 | ||
|
|
d92623f07e |
@@ -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
|
||||
@@ -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
|
||||
)
|
||||
|
||||
@@ -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
18
devFlake/flake.lock
generated
@@ -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
2
docs/.gitignore
vendored
@@ -1,6 +1,6 @@
|
||||
/site/reference
|
||||
/site/services/official
|
||||
/site/static
|
||||
/site/option-search
|
||||
/site/options
|
||||
/site/openapi.json
|
||||
!/site/static/extra.css
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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/
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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;
|
||||
@@ -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
|
||||
@@ -1,9 +0,0 @@
|
||||
# clan-core release notes 25.11
|
||||
|
||||
<!-- This is not rendered yet -->
|
||||
|
||||
## New features
|
||||
|
||||
## Breaking Changes
|
||||
|
||||
## Misc
|
||||
@@ -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>
|
||||
|
||||
@@ -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 = { };
|
||||
|
||||
@@ -122,7 +122,7 @@ hide:
|
||||
|
||||
command line interface
|
||||
|
||||
- [Clan Options](./reference/options/clan.md)
|
||||
- [Clan Options](/options)
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -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
6
flake.lock
generated
@@ -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": {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 = [ ];
|
||||
};
|
||||
};
|
||||
}
|
||||
@@ -12,7 +12,6 @@ let
|
||||
in
|
||||
#######
|
||||
{
|
||||
autoloading = import ./dir_test.nix { inherit lib; };
|
||||
test_missing_self =
|
||||
let
|
||||
eval = clan {
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
'');
|
||||
}
|
||||
|
||||
@@ -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
|
||||
# '';
|
||||
# };
|
||||
# };
|
||||
})
|
||||
|
||||
16
pkgs/clan-app/ui/package-lock.json
generated
16
pkgs/clan-app/ui/package-lock.json
generated
@@ -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": {
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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 },
|
||||
);
|
||||
});
|
||||
}
|
||||
},
|
||||
|
||||
@@ -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],
|
||||
|
||||
@@ -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>
|
||||
),
|
||||
},
|
||||
};
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
@@ -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>
|
||||
</>
|
||||
),
|
||||
},
|
||||
};
|
||||
|
||||
@@ -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" />
|
||||
</>
|
||||
),
|
||||
},
|
||||
};
|
||||
|
||||
@@ -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>
|
||||
),
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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 {
|
||||
|
||||
15
pkgs/clan-app/ui/src/scene/cubes.stories.tsx
Normal file
15
pkgs/clan-app/ui/src/scene/cubes.stories.tsx
Normal 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: {},
|
||||
};
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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",
|
||||
}}
|
||||
/>
|
||||
),
|
||||
};
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
}
|
||||
@@ -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()
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
@@ -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"],
|
||||
|
||||
@@ -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 = []
|
||||
|
||||
@@ -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
8
pkgs/docs-site/.envrc
Normal 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
29
pkgs/docs-site/.gitignore
vendored
Normal 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
1
pkgs/docs-site/.npmrc
Normal file
@@ -0,0 +1 @@
|
||||
engine-strict=true
|
||||
6
pkgs/docs-site/.postcssrc.json
Normal file
6
pkgs/docs-site/.postcssrc.json
Normal file
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"plugins": {
|
||||
"postcss-preset-env": {},
|
||||
"cssnano": { "preset": "default" }
|
||||
}
|
||||
}
|
||||
6
pkgs/docs-site/.prettierignore
Normal file
6
pkgs/docs-site/.prettierignore
Normal file
@@ -0,0 +1,6 @@
|
||||
# Package Managers
|
||||
package-lock.json
|
||||
pnpm-lock.yaml
|
||||
yarn.lock
|
||||
bun.lock
|
||||
bun.lockb
|
||||
11
pkgs/docs-site/.prettierrc
Normal file
11
pkgs/docs-site/.prettierrc
Normal file
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"plugins": ["prettier-plugin-svelte"],
|
||||
"overrides": [
|
||||
{
|
||||
"files": "*.svelte",
|
||||
"options": {
|
||||
"parser": "svelte"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
38
pkgs/docs-site/README.md
Normal file
38
pkgs/docs-site/README.md
Normal 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.
|
||||
33
pkgs/docs-site/default.nix
Normal file
33
pkgs/docs-site/default.nix
Normal 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
|
||||
'';
|
||||
}
|
||||
12
pkgs/docs-site/flake-module.nix
Normal file
12
pkgs/docs-site/flake-module.nix
Normal 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
10128
pkgs/docs-site/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
56
pkgs/docs-site/package.json
Normal file
56
pkgs/docs-site/package.json
Normal 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
13
pkgs/docs-site/src/app.d.ts
vendored
Normal 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 {};
|
||||
14
pkgs/docs-site/src/app.html
Normal file
14
pkgs/docs-site/src/app.html
Normal 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>
|
||||
27
pkgs/docs-site/src/config.ts
Normal file
27
pkgs/docs-site/src/config.ts
Normal 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[],
|
||||
};
|
||||
1
pkgs/docs-site/src/lib/assets/favicon.svg
Normal file
1
pkgs/docs-site/src/lib/assets/favicon.svg
Normal 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 |
296
pkgs/docs-site/src/lib/docs.ts
Normal file
296
pkgs/docs-site/src/lib/docs.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
1
pkgs/docs-site/src/lib/index.ts
Normal file
1
pkgs/docs-site/src/lib/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export * from "./docs";
|
||||
81
pkgs/docs-site/src/lib/markdown/admonition.css
Normal file
81
pkgs/docs-site/src/lib/markdown/admonition.css
Normal 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;
|
||||
}
|
||||
}
|
||||
15
pkgs/docs-site/src/lib/markdown/index.ts
Normal file
15
pkgs/docs-site/src/lib/markdown/index.ts
Normal 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[];
|
||||
};
|
||||
14
pkgs/docs-site/src/lib/markdown/main.css
Normal file
14
pkgs/docs-site/src/lib/markdown/main.css
Normal 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;
|
||||
}
|
||||
81
pkgs/docs-site/src/lib/markdown/shiki.css
Normal file
81
pkgs/docs-site/src/lib/markdown/shiki.css
Normal 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;
|
||||
}
|
||||
}
|
||||
50
pkgs/docs-site/src/lib/markdown/tabs.css
Normal file
50
pkgs/docs-site/src/lib/markdown/tabs.css
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
87
pkgs/docs-site/src/lib/markdown/vite/index.ts
Normal file
87
pkgs/docs-site/src/lib/markdown/vite/index.ts
Normal 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)};`;
|
||||
},
|
||||
};
|
||||
}
|
||||
26
pkgs/docs-site/src/lib/markdown/vite/link-migration.ts
Normal file
26
pkgs/docs-site/src/lib/markdown/vite/link-migration.ts
Normal 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}`;
|
||||
});
|
||||
};
|
||||
}
|
||||
74
pkgs/docs-site/src/lib/markdown/vite/rehype-toc-slug.ts
Normal file
74
pkgs/docs-site/src/lib/markdown/vite/rehype-toc-slug.ts
Normal 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;
|
||||
};
|
||||
}
|
||||
21
pkgs/docs-site/src/lib/markdown/vite/rehype-wrap-headings.ts
Normal file
21
pkgs/docs-site/src/lib/markdown/vite/rehype-wrap-headings.ts
Normal 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,
|
||||
},
|
||||
];
|
||||
});
|
||||
};
|
||||
}
|
||||
53
pkgs/docs-site/src/lib/markdown/vite/remark-admonition.ts
Normal file
53
pkgs/docs-site/src/lib/markdown/vite/remark-admonition.ts
Normal 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,
|
||||
];
|
||||
});
|
||||
};
|
||||
}
|
||||
24
pkgs/docs-site/src/lib/markdown/vite/remark-parse.ts
Normal file
24
pkgs/docs-site/src/lib/markdown/vite/remark-parse.ts
Normal 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,
|
||||
});
|
||||
};
|
||||
}
|
||||
93
pkgs/docs-site/src/lib/markdown/vite/remark-tabs.ts
Normal file
93
pkgs/docs-site/src/lib/markdown/vite/remark-tabs.ts
Normal 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,
|
||||
];
|
||||
});
|
||||
};
|
||||
}
|
||||
@@ -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";
|
||||
},
|
||||
};
|
||||
}
|
||||
168
pkgs/docs-site/src/routes/+layout.svelte
Normal file
168
pkgs/docs-site/src/routes/+layout.svelte
Normal 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>
|
||||
9
pkgs/docs-site/src/routes/+layout.ts
Normal file
9
pkgs/docs-site/src/routes/+layout.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
import { Docs } from "$lib";
|
||||
|
||||
export const prerender = true;
|
||||
|
||||
export async function load() {
|
||||
return {
|
||||
docs: await new Docs().init(),
|
||||
};
|
||||
}
|
||||
1
pkgs/docs-site/src/routes/+page.svelte
Normal file
1
pkgs/docs-site/src/routes/+page.svelte
Normal file
@@ -0,0 +1 @@
|
||||
<h1>Welcome to Clan</h1>
|
||||
338
pkgs/docs-site/src/routes/[...path]/+page.svelte
Normal file
338
pkgs/docs-site/src/routes/[...path]/+page.svelte
Normal 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"><</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">></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>
|
||||
10
pkgs/docs-site/src/routes/[...path]/+page.ts
Normal file
10
pkgs/docs-site/src/routes/[...path]/+page.ts
Normal 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;
|
||||
}
|
||||
548
pkgs/docs-site/src/routes/docs/decisions/01-Clan-Modules.md
Normal file
548
pkgs/docs-site/src/routes/docs/decisions/01-Clan-Modules.md
Normal 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
|
||||
112
pkgs/docs-site/src/routes/docs/decisions/02-clan-as-library.md
Normal file
112
pkgs/docs-site/src/routes/docs/decisions/02-clan-as-library.md
Normal 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/)
|
||||
|
||||
@@ -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/
|
||||
@@ -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
|
||||
@@ -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.
|
||||
@@ -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))
|
||||
24
pkgs/docs-site/src/routes/docs/decisions/template.md
Normal file
24
pkgs/docs-site/src/routes/docs/decisions/template.md
Normal 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?
|
||||
145
pkgs/docs-site/src/routes/docs/getting-started/add-machines.md
Normal file
145
pkgs/docs-site/src/routes/docs/getting-started/add-machines.md
Normal 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
|
||||
@@ -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.
|
||||
125
pkgs/docs-site/src/routes/docs/getting-started/add-users.md
Normal file
125
pkgs/docs-site/src/routes/docs/getting-started/add-users.md
Normal 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
|
||||
];
|
||||
};
|
||||
}
|
||||
```
|
||||
@@ -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>
|
||||
```
|
||||
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user