Compare commits

...

88 Commits

Author SHA1 Message Date
a-kenji
8a08c00f46 clan: add run_no_stdout function suppressing stdout
Add the `run_no_stdout` function suppressing stdout by default.
This keeps the noise down on most commands, while still staying
debuggable.
Stdout will be active when the `--debug` flag is passed to the cli.

Fixes #1443
2024-05-28 09:52:00 +02:00
clan-bot
dbaa26ccaa Merge pull request 'clan: machines --help add examples to help output' (#1468) from a-kenji-clan/cli/machines-examples into main 2024-05-27 19:04:30 +00:00
a-kenji
d1591d4485 clan: machines --help add examples to help output 2024-05-27 21:01:05 +02:00
clan-bot
c68a8306ba Merge pull request 'clan: duplicate description field for generation of the reference documentation' (#1467) from a-kenji-clan/cli/add-description into main 2024-05-27 18:32:54 +00:00
a-kenji
ec9f605004 clan: duplicate description field for generation of the reference documentation 2024-05-27 20:29:34 +02:00
clan-bot
e60efea1f7 Merge pull request 'clan: ssh --help add examples' (#1466) from a-kenji-clan/help/ssh into main 2024-05-27 18:18:08 +00:00
a-kenji
efacb7f184 clan: ssh --help add examples
Add examples to the output of `clan ssh --help`.
2024-05-27 20:14:37 +02:00
clan-bot
67275aac63 Merge pull request 'clan: rename cLan to clan' (#1465) from a-kenji-rename-clan into main 2024-05-27 17:56:32 +00:00
a-kenji
a704a05b15 clan: rename cLan to clan 2024-05-27 19:52:51 +02:00
Mic92
01aafc520d Merge pull request 'consistent rename cLAN -> Clan' (#1464) from rename into main
Reviewed-on: https://git.clan.lol/clan/clan-core/pulls/1464
2024-05-27 13:58:32 +00:00
Jörg Thalheim
c0a54f539a consistent rename cLAN -> Clan 2024-05-27 13:58:32 +00:00
clan-bot
1df4e361f7 Merge pull request 'clan: add examples and description to most help' (#1463) from a-kenji-clan/examples into main 2024-05-27 12:37:22 +00:00
a-kenji
e8bd5ad24b clan: add examples and description to most help
Add examples and description to most toplevel help outputs.
2024-05-27 14:33:58 +02:00
clan-bot
775f993ecc Merge pull request 'clan-cli: Fix nix develop not working in template because of symlink' (#1462) from Qubasa-main into main 2024-05-27 12:05:17 +00:00
Qubasa
0e1478edcd clan-cli: Fix nix develop not working in template because of symlink 2024-05-27 14:00:31 +02:00
clan-bot
bfc1203a8a Merge pull request 'init: vscode .#editor' (#1461) from Qubasa-init/editors into main 2024-05-27 12:00:26 +00:00
Qubasa
2bd8b144b9 init: vscode .#editor 2024-05-27 13:52:54 +02:00
clan-bot
b52b2221b0 Merge pull request 'drop offline in nix shell' (#1459) from fix-mass-rebuilds into main 2024-05-27 11:43:32 +00:00
Jörg Thalheim
64adf17368 drop offline in nix shell
Otherwise we become gentoo!
2024-05-27 13:40:11 +02:00
clan-bot
509d8c1dae Merge pull request 'make git-hooks opt-in' (#1453) from git-hooks into main 2024-05-27 10:34:07 +00:00
clan-bot
295de17640 Merge pull request 'docs: secrets: list the main steps of the guide' (#1456) from DavHau-dave into main 2024-05-27 10:05:55 +00:00
DavHau
b158c2706f docs: secrets: list the main steps of the guide
... so the user has a routh idea about the mein steps
2024-05-27 12:02:29 +02:00
clan-bot
750979c988 Merge pull request 'A Kenji Clan/Cli/Fix Naming' (#1455) from a-kenji-clan/cli/fix-naming into main 2024-05-27 09:54:36 +00:00
a-kenji
6d7849a03c clan: fix description of cli tool 2024-05-27 11:51:25 +02:00
Jörg Thalheim
f46fd3ace6 make git-hooks opt-in
pre-commit hook break git commits and are disruptive.
Therefore people that want to enable this feature, should enable it locally instead.
I.e. treefmt will also check untracked files that are not meant for the current commit.
2024-05-27 11:08:17 +02:00
clan-bot
6e9f1515d3 Merge pull request 'clan: facts generate allow regeneration of facts' (#1447) from a-kenji-clan/feat/facts-regenerate-1403 into main 2024-05-26 21:00:38 +00:00
a-kenji
81e0700826 clan: facts generate allow regeneration of facts
Add `--regenerate` flag to `clan facts generate` which allows forcing
the generation of facts, regardless of their current existence.

Examples:
```
clan facts generate [MACHINE] --regenerate
```
or
```
clan facts generate [MACHINE] --service [SERVICE] --regenerate
```
2024-05-26 22:55:48 +02:00
clan-bot
4daf036a3c Merge pull request 'clan: facts generate specific service' (#1446) from a-kenji-feat/clan/facts-generate-service-1395 into main 2024-05-26 19:57:17 +00:00
a-kenji
4faab0a20f clan: facts generate specific service
Add `--service` flag to the `clan` cli which allows specifying a certain
service to be generated.

Example:

```
clan facts generate [MACHINE] --service [SERVICE]
```

Fixes #1395
2024-05-26 21:52:56 +02:00
clan-bot
76622557c4 Merge pull request 'Webview: improve linting & typechecks' (#1445) from hsjobeki-main into main 2024-05-26 16:41:11 +00:00
Johannes Kirschbauer
4c4c94c508 Webview: improve linting & typechecks 2024-05-26 18:37:29 +02:00
clan-bot
b0d5ef01ca Merge pull request 'Api: init response envelop' (#1444) from hsjobeki-feat/api-improvements into main 2024-05-26 16:08:02 +00:00
Johannes Kirschbauer
ab656d5655 API: handle functions with multiple arguments 2024-05-26 18:04:49 +02:00
Johannes Kirschbauer
ed171f0264 Api: init response envelop 2024-05-26 15:57:10 +02:00
clan-bot
270f9d54cb Merge pull request 'clan: fix clan facts list [MACHINE]' (#1442) from a-kenji-fix/clan-facts-list into main 2024-05-26 12:29:37 +00:00
a-kenji
73f486fe13 clan: fix clan facts list [MACHINE]
Fix `clan facts list [MACHINE]`.
The get command returned a dictionary of bytestrings.
We now convert them to strings.
2024-05-26 14:26:08 +02:00
clan-bot
7da6826344 Merge pull request 'api: improve message serialisation' (#1440) from hsjobeki-feat/api-improvements into main 2024-05-26 12:20:11 +00:00
Johannes Kirschbauer
f54c518fd7 api: fix breaking tests 2024-05-26 14:17:17 +02:00
Johannes Kirschbauer
522fd1bcaa api: convert name casing 2024-05-26 13:54:21 +02:00
Johannes Kirschbauer
be5c3accfe webview: fix typo 2024-05-26 13:41:07 +02:00
Johannes Kirschbauer
3998efac78 fix: tests 2024-05-26 13:41:05 +02:00
Johannes Kirschbauer
691ae9fb15 api: improve message serialisation 2024-05-26 13:40:47 +02:00
clan-bot
fc8a64ef49 Merge pull request 'Add draft: Git Based Machine Deployment with Clan-Core' (#1439) from Qubasa-main into main 2024-05-25 16:37:39 +00:00
Qubasa
c0f3810e01 Add draft: Git Based Machine Deployment with Clan-Core 2024-05-25 18:34:19 +02:00
clan-bot
39d8cf91cf Merge pull request 'blog: fix typos' (#1436) from a-kenji-fix/typos-1 into main 2024-05-25 10:34:05 +00:00
a-kenji
233b973120 blog: fix typos 2024-05-25 11:57:28 +02:00
clan-bot
f3f2f6df69 Merge pull request 'blog: fix typo on jsonschema post' (#1435) from DavHau-dave into main 2024-05-25 08:41:03 +00:00
DavHau
ec824becc8 blog: fix typo on jsonschema post 2024-05-25 10:37:56 +02:00
clan-bot
7fbb50fcc8 Merge pull request 'blog: add link for discourse comments' (#1434) from DavHau-dave into main 2024-05-25 07:47:56 +00:00
DavHau
33695f7470 blog: add link for discourse comments 2024-05-25 09:44:49 +02:00
clan-bot
001ed3283a Merge pull request 'blog: custom slug for jsonschema converter post' (#1433) from DavHau-dave into main 2024-05-25 07:37:54 +00:00
DavHau
9ae7d4a24b blog: custom slug for jsonschema converter post 2024-05-25 09:34:50 +02:00
clan-bot
e39911d99c Merge pull request 'blog: improve description of jsonschema post' (#1432) from DavHau-dave into main 2024-05-25 07:12:35 +00:00
DavHau
9c3da78792 blog: improve description of jsonschema post 2024-05-25 09:09:20 +02:00
clan-bot
69ddda24c9 Merge pull request 'blog: add jsonschema blog post' (#1431) from DavHau-dave into main 2024-05-25 07:01:10 +00:00
DavHau
c6f078fafc blog: add jsonschema blog post 2024-05-25 08:57:57 +02:00
clan-bot
4e3ed011da Merge pull request 'blog: Remove asciinema player' (#1430) from Qubasa-main into main 2024-05-24 15:13:58 +00:00
Qubasa
a71191486c blog: Fix remote url 2024-05-24 17:10:47 +02:00
clan-bot
d3e6276d04 Merge pull request 'blog: Remove asciinema player' (#1429) from Qubasa-main into main 2024-05-24 14:58:52 +00:00
Qubasa
a1e2d1017e blog: Remove asciinema player 2024-05-24 16:57:26 +02:00
Qubasa
114b0b02d0 blog: Remove asciinema player 2024-05-24 16:54:51 +02:00
clan-bot
de3e133981 Merge pull request 'blog: Remove pictures.' (#1428) from Qubasa-main into main 2024-05-24 13:53:28 +00:00
Qubasa
083e30f468 blog: Remove pictures. 2024-05-24 15:50:14 +02:00
Luis Hebendanz
d7ef88c67c Merge pull request 'add-pre-commit' (#1413) from fricklerhandwerk/clan-core:add-pre-commit into main
Reviewed-on: https://git.clan.lol/clan/clan-core/pulls/1413
2024-05-24 12:39:56 +00:00
Valentin Gagarin
055b17c01b add pre-commit check
make sure things are sane before they hit CI, re-purposing the existing
treefmt configuration.

this adds a custom installer for pre-commit hooks, which is inspired by
pre-commit.nix[0], but is much more minimal than the underlying
pre-commit[1] and builds on a historic idea[2] from this repository.

[0]: https://github.com/cachix/git-hooks.nix
[1]: https://github.com/pre-commit/pre-commit
[2]: 930923512c
2024-05-24 12:39:56 +00:00
clan-bot
4b07bf363b Merge pull request 'documentation: add keys' (#1426) from a-kenji-add-keys into main 2024-05-24 11:49:09 +00:00
a-kenji
97928801f9 documentation: add keys 2024-05-24 13:46:05 +02:00
clan-bot
fdacfb8ecf Merge pull request 'documenation: use monospace font for code blocks' (#1425) from a-kenji-fix-monospace into main 2024-05-24 11:11:02 +00:00
a-kenji
508a26d68d documenation: use monospace font for code blocks
Use monospace font for code blocks.

Fixes: #1337
2024-05-24 13:08:00 +02:00
clan-bot
8055c21984 Merge pull request 'clan machines list: reduce noise' (#1418) from a-kenji-fix/output-noisy/1115 into main 2024-05-24 10:58:13 +00:00
a-kenji
9bb6ed313f clan machines list: reduce noise
Don't log nix error output by default on `clan machines list`.
Log it, if `--debug` is passed.

Fixes #1115
2024-05-24 12:55:14 +02:00
clan-bot
8c36df77cc Merge pull request 'documentation: improve legibility of command output' (#1423) from a-kenji-fix/lsblk into main 2024-05-24 10:40:34 +00:00
a-kenji
2284b060be documentation: improve legibility of command output
Improve legibility of command output.
2024-05-24 12:37:27 +02:00
clan-bot
491b5d28f2 Merge pull request 'documenation: fix flake-parts what's next section' (#1422) from a-kenji-fix/docs/flake-parts into main 2024-05-24 10:14:55 +00:00
a-kenji
bf212ce9c4 documenation: fix flake-parts what's next section 2024-05-24 12:11:42 +02:00
clan-bot
35be09feaa Merge pull request 'impure-checks: unset CLAN_DIR' (#1420) from a-kenji-fix/env into main 2024-05-24 10:06:10 +00:00
a-kenji
4ee90b4b9f impure-checks: unset CLAN_DIR
Unset `CLAN_DIR` in the impure checks, so that it won't reference the
users configuration.

Fixes #1419
2024-05-24 12:03:11 +02:00
clan-bot
cab69935ef Merge pull request 'lib.jsonschema: parse some more types' (#1417) from DavHau-dave into main 2024-05-23 14:17:56 +00:00
DavHau
54fcfda43e lib.jsonschema: parse some more types 2024-05-23 16:12:49 +02:00
clan-bot
d137342243 Merge pull request 'modules: init trusted-nix-cache module' (#1414) from a-kenji-add/cache-module into main 2024-05-23 07:32:36 +00:00
a-kenji
3eba6e85cc modules: init trusted-nix-cache module 2024-05-23 09:29:31 +02:00
clan-bot
d395e2abf3 Merge pull request 'flake.nix: change disko back to nix-community' (#1409) from change_disko_input into main 2024-05-22 19:17:48 +00:00
Qubasa
b971156df1 flake.nix: change disko back to nix-community 2024-05-22 21:14:30 +02:00
clan-bot
c885a3fec8 Merge pull request 'Revert "Merge pull request 'add pre-commit-check' (#1369) from fricklerhandwerk/clan-core:add-pre-commit into main"' (#1408) from Qubasa-revert_hooks into main 2024-05-22 12:36:23 +00:00
Qubasa
ae7794dddd Revert "Merge pull request 'add pre-commit-check' (#1369) from fricklerhandwerk/clan-core:add-pre-commit into main"
This reverts commit acaa69e2bf, reversing
changes made to a78f5b2bec.
2024-05-22 14:31:53 +02:00
Luis Hebendanz
acaa69e2bf Merge pull request 'add pre-commit-check' (#1369) from fricklerhandwerk/clan-core:add-pre-commit into main
Reviewed-on: https://git.clan.lol/clan/clan-core/pulls/1369
2024-05-22 11:49:57 +00:00
Valentin Gagarin
e37f7e2760 use a custom installer for pre-commit hooks 2024-05-22 11:49:57 +00:00
Valentin Gagarin
245b615209 add pre-commit check
make sure things are sane before they hit CI.
this re-purposes the existing treefmt configuration.
2024-05-22 11:49:57 +00:00
81 changed files with 2838 additions and 2405 deletions

View File

@@ -1,4 +1,4 @@
# Contributing to cLAN # Contributing to Clan
## Live-reloading documentation ## Live-reloading documentation

View File

@@ -1,6 +1,6 @@
# Clan Core Repository # Clan Core Repository
Welcome to the Clan Core Repository, the heart of the [clan.lol](https://clan.lol/) project! This monorepo is the foundation of Clan, a revolutionary open-source project aimed at restoring fun, freedom, and functionality to computing. Here, you'll find all the essential packages, NixOS modules, CLI tools, and tests needed to contribute to and work with the cLAN project. Clan leverages the Nix system to ensure reliability, security, and seamless management of digital environments, putting the power back into the hands of users. Welcome to the Clan Core Repository, the heart of the [clan.lol](https://clan.lol/) project! This monorepo is the foundation of Clan, a revolutionary open-source project aimed at restoring fun, freedom, and functionality to computing. Here, you'll find all the essential packages, NixOS modules, CLI tools, and tests needed to contribute to and work with the Clan project. Clan leverages the Nix system to ensure reliability, security, and seamless management of digital environments, putting the power back into the hands of users.
## Why Clan? ## Why Clan?

View File

@@ -7,6 +7,8 @@
#!${pkgs.bash}/bin/bash #!${pkgs.bash}/bin/bash
set -euo pipefail set -euo pipefail
unset CLAN_DIR
export PATH="${ export PATH="${
lib.makeBinPath [ lib.makeBinPath [
pkgs.gitMinimal pkgs.gitMinimal

View File

@@ -17,6 +17,7 @@
static-hosts = ./static-hosts; static-hosts = ./static-hosts;
syncthing = ./syncthing; syncthing = ./syncthing;
thelounge = ./thelounge; thelounge = ./thelounge;
trusted-nix-caches = ./trusted-nix-caches;
user-password = ./user-password; user-password = ./user-password;
xfce = ./xfce; xfce = ./xfce;
zerotier-static-peers = ./zerotier-static-peers; zerotier-static-peers = ./zerotier-static-peers;

View File

@@ -0,0 +1,2 @@
This module sets the `clan.lol` and `nix-community` cache up as a trusted cache.
----

View File

@@ -0,0 +1,10 @@
{
nix.settings.trusted-substituters = [
"https://cache.clan.lol"
"https://nix-community.cachix.org"
];
nix.settings.trusted-public-keys = [
"nix-community.cachix.org-1:mB9FSh9qf2dCimDSUo8Zy7bkq5CX+/rkCWyvRCYg3Fs="
"cache.clan.lol-1:3KztgSAB5R1M+Dz7vzkBGzXdodizbgLXGXKXlcQLA28="
];
}

View File

@@ -1,3 +1,4 @@
{ ... }:
{ {
perSystem = perSystem =
{ {
@@ -34,9 +35,6 @@
config.treefmt.build.wrapper config.treefmt.build.wrapper
]; ];
shellHook = '' shellHook = ''
# no longer used
rm -f "$(git rev-parse --show-toplevel)/.git/hooks/pre-commit"
echo -e "${ansiEscapes.green}switch to another dev-shell using: select-shell${ansiEscapes.reset}" echo -e "${ansiEscapes.green}switch to another dev-shell using: select-shell${ansiEscapes.reset}"
''; '';
}; };

View File

@@ -20,11 +20,11 @@ There are several reasons for choosing to self-host. These can include:
Alice wants to self-host a mumble server for her family. Alice wants to self-host a mumble server for her family.
- She visits to the cLAN website, and follows the instructions on how to install cLAN-OS on her server. - She visits to the Clan website, and follows the instructions on how to install Clan-OS on her server.
- Alice logs into a terminal on her server via SSH (alternatively uses cLAN GUI app) - Alice logs into a terminal on her server via SSH (alternatively uses Clan GUI app)
- Using the cLAN CLI or GUI tool, alice creates a new private network for her family (VPN) - Using the Clan CLI or GUI tool, alice creates a new private network for her family (VPN)
- Alice now browses a list of curated cLAN modules and finds a module for mumble. - Alice now browses a list of curated Clan modules and finds a module for mumble.
- She adds this module to her network using the cLAN tool. - She adds this module to her network using the Clan tool.
- After that, she uses the clan tool to invite her family members to her network - After that, she uses the clan tool to invite her family members to her network
- Other family members join the private network via the invitation. - Other family members join the private network via the invitation.
- By accepting the invitation, other members automatically install all required software to interact with the network on their machine. - By accepting the invitation, other members automatically install all required software to interact with the network on their machine.
@@ -33,7 +33,7 @@ Alice wants to self-host a mumble server for her family.
Alice wants to add a photos app to her private network Alice wants to add a photos app to her private network
- She uses the clan CLI or GUI tool to manage her existing private cLAN family network - She uses the clan CLI or GUI tool to manage her existing private Clan family network
- She discovers a module for photoprism, and adds it to her server using the tool - She discovers a module for photoprism, and adds it to her server using the tool
- Other members who are already part of her network, will receive a notification that an update is required to their environment - Other members who are already part of her network, will receive a notification that an update is required to their environment
- After accepting, all new software and services to interact with the new photoprism service will be installed automatically. - After accepting, all new software and services to interact with the new photoprism service will be installed automatically.

View File

@@ -1,4 +1,4 @@
# Joining a cLAN network # Joining a Clan network
## General Description ## General Description
@@ -8,13 +8,13 @@ Joining a self-hosted infrastructure involves connecting to a network, server, o
### Story 1: Joining a private network ### Story 1: Joining a private network
Alice' son Bob has never heard of cLAN, but receives an invitation URL from Alice who already set up private cLAN network for her family. Alice' son Bob has never heard of Clan, but receives an invitation URL from Alice who already set up private Clan network for her family.
Bob opens the invitation link and lands on the cLAN website. He quickly learns about what cLAN is and can see that the invitation is for a private network of his family that hosts a number of services, like a private voice chat and a photo sharing platform. Bob opens the invitation link and lands on the Clan website. He quickly learns about what Clan is and can see that the invitation is for a private network of his family that hosts a number of services, like a private voice chat and a photo sharing platform.
Bob decides to join the network and follows the instructions to install the cLAN tool on his computer. Bob decides to join the network and follows the instructions to install the Clan tool on his computer.
Feeding the invitation link to the cLAN tool, bob registers his machine with the network. Feeding the invitation link to the Clan tool, bob registers his machine with the network.
All programs required to interact with the network will be installed and configured automatically and securely. All programs required to interact with the network will be installed and configured automatically and securely.
@@ -22,7 +22,7 @@ Optionally, bob can customize the configuration of these programs through a simp
### Story 2: Receiving breaking changes ### Story 2: Receiving breaking changes
The cLAN family network which Bob is part of received an update. The Clan family network which Bob is part of received an update.
The existing photo sharing service has been removed and replaced with another alternative service. The new photo sharing service requires a different client app to view and upload photos. The existing photo sharing service has been removed and replaced with another alternative service. The new photo sharing service requires a different client app to view and upload photos.
@@ -30,7 +30,7 @@ Bob accepts the update. Now his environment will be updated. The old client soft
Because Bob has customized the previous photo viewing app, he is notified that this customization is no longer valid, as the software has been removed (deprecation message).l Because Bob has customized the previous photo viewing app, he is notified that this customization is no longer valid, as the software has been removed (deprecation message).l
Optionally, Bob can now customize the new photo viewing software through his cLAN configuration app or via a config file. Optionally, Bob can now customize the new photo viewing software through his Clan configuration app or via a config file.
## Challenges ## Challenges

View File

@@ -1,10 +1,10 @@
# cLAN module maintaining # Clan module maintaining
## General Description ## General Description
cLAN modules are pieces of software that can be used by admins to build a private or public infrastructure. Clan modules are pieces of software that can be used by admins to build a private or public infrastructure.
cLAN modules should have the following properties: Clan modules should have the following properties:
1. Documented: It should be clear what the module does and how to use it. 1. Documented: It should be clear what the module does and how to use it.
1. Self contained: A module should be usable as is. If it requires any other software or settings, those should be delivered with the module itself. 1. Self contained: A module should be usable as is. If it requires any other software or settings, those should be delivered with the module itself.

View File

@@ -28,6 +28,7 @@ markdown_extensions:
- pymdownx.highlight: - pymdownx.highlight:
use_pygments: true use_pygments: true
anchor_linenums: true anchor_linenums: true
- pymdownx.keys
- toc: - toc:
title: On this page title: On this page
@@ -64,6 +65,7 @@ nav:
- reference/clanModules/syncthing.md - reference/clanModules/syncthing.md
- reference/clanModules/static-hosts.md - reference/clanModules/static-hosts.md
- reference/clanModules/thelounge.md - reference/clanModules/thelounge.md
- reference/clanModules/trusted-nix-caches.md
- reference/clanModules/user-password.md - reference/clanModules/user-password.md
- reference/clanModules/xfce.md - reference/clanModules/xfce.md
- reference/clanModules/zerotier-static-peers.md - reference/clanModules/zerotier-static-peers.md
@@ -92,8 +94,8 @@ docs_dir: site
site_dir: out site_dir: out
theme: theme:
logo: static/clan-white.png logo: https://clan.lol/static/logo/clan-white.png
favicon: static/clan-dark.png favicon: https://clan.lol/static/logo/clan-dark.png
name: material name: material
features: features:
- navigation.instant - navigation.instant
@@ -103,6 +105,8 @@ theme:
- content.tabs.link - content.tabs.link
icon: icon:
repo: fontawesome/brands/git repo: fontawesome/brands/git
font:
code: Roboto Mono
palette: palette:
# Palette toggle for light mode # Palette toggle for light mode

View File

@@ -2,6 +2,8 @@
pkgs, pkgs,
module-docs, module-docs,
clan-cli-docs, clan-cli-docs,
asciinema-player-js,
asciinema-player-css,
... ...
}: }:
let let
@@ -27,6 +29,10 @@ pkgs.stdenv.mkDerivation {
mkdir -p ./site/reference/cli mkdir -p ./site/reference/cli
cp -af ${module-docs}/* ./site/reference/ cp -af ${module-docs}/* ./site/reference/
cp -af ${clan-cli-docs}/* ./site/reference/cli/ cp -af ${clan-cli-docs}/* ./site/reference/cli/
mkdir -p ./site/static/asciinema-player
ln -snf ${asciinema-player-js} ./site/static/asciinema-player/asciinema-player.min.js
ln -snf ${asciinema-player-css} ./site/static/asciinema-player/asciinema-player.css
''; '';
buildPhase = '' buildPhase = ''

View File

@@ -40,6 +40,15 @@
mypy --strict $out mypy --strict $out
''; '';
asciinema-player-js = pkgs.fetchurl {
url = "https://github.com/asciinema/asciinema-player/releases/download/v3.7.0/asciinema-player.min.js";
sha256 = "sha256-Ymco/+FinDr5YOrV72ehclpp4amrczjo5EU3jfr/zxs=";
};
asciinema-player-css = pkgs.fetchurl {
url = "https://github.com/asciinema/asciinema-player/releases/download/v3.7.0/asciinema-player.css";
sha256 = "sha256-GZMeZFFGvP5GMqqh516mjJKfQaiJ6bL38bSYOXkaohc=";
};
module-docs = pkgs.runCommand "rendered" { nativeBuildInputs = [ pkgs.python3 ]; } '' module-docs = pkgs.runCommand "rendered" { nativeBuildInputs = [ pkgs.python3 ]; } ''
export CLAN_CORE=${jsonDocs.clanCore}/share/doc/nixos/options.json export CLAN_CORE=${jsonDocs.clanCore}/share/doc/nixos/options.json
# A file that contains the links to all clanModule docs # A file that contains the links to all clanModule docs
@@ -56,12 +65,16 @@
devShells.docs = pkgs.callPackage ./shell.nix { devShells.docs = pkgs.callPackage ./shell.nix {
inherit (self'.packages) docs clan-cli-docs; inherit (self'.packages) docs clan-cli-docs;
inherit module-docs; inherit module-docs;
inherit asciinema-player-js;
inherit asciinema-player-css;
}; };
packages = { packages = {
docs = pkgs.python3.pkgs.callPackage ./default.nix { docs = pkgs.python3.pkgs.callPackage ./default.nix {
inherit (self'.packages) clan-cli-docs; inherit (self'.packages) clan-cli-docs;
inherit (inputs) nixpkgs; inherit (inputs) nixpkgs;
inherit module-docs; inherit module-docs;
inherit asciinema-player-js;
inherit asciinema-player-css;
}; };
deploy-docs = pkgs.callPackage ./deploy-docs.nix { inherit (config.packages) docs; }; deploy-docs = pkgs.callPackage ./deploy-docs.nix { inherit (config.packages) docs; };
inherit module-docs; inherit module-docs;

View File

@@ -3,6 +3,8 @@
pkgs, pkgs,
module-docs, module-docs,
clan-cli-docs, clan-cli-docs,
asciinema-player-js,
asciinema-player-css,
... ...
}: }:
pkgs.mkShell { pkgs.mkShell {
@@ -14,5 +16,9 @@ pkgs.mkShell {
chmod +w ./site/reference/* chmod +w ./site/reference/*
echo "Generated API documentation in './site/reference/' " echo "Generated API documentation in './site/reference/' "
mkdir -p ./site/static/asciinema-player
ln -snf ${asciinema-player-js} ./site/static/asciinema-player/asciinema-player.min.js
ln -snf ${asciinema-player-css} ./site/static/asciinema-player/asciinema-player.css
''; '';
} }

View File

@@ -1,21 +1,26 @@
authors: authors:
DavHau:
name: "DavHau"
description: "Core Developer"
avatar: "https://clan.lol/static/profiles/davhau.jpg"
url: "https://DavHau.com"
Lassulus: Lassulus:
name: "Lassulus" name: "Lassulus"
description: "Contributor to Clan" description: "Core Developer"
avatar: "https://avatars.githubusercontent.com/u/621759?v=4" avatar: "https://clan.lol/static/profiles/lassulus.jpg"
url: "https://http://lassul.us/" url: "https://http://lassul.us/"
Mic92: Mic92:
name: "Mic92" name: "Mic92"
description: "Contributor to Clan" description: "Core Developer"
avatar: "https://avatars.githubusercontent.com/u/96200?v=4" avatar: "https://clan.lol/static/profiles/mic92.jpg"
url: "https://thalheim.io" url: "https://thalheim.io"
W: W:
name: "W" name: "W"
description: "Founder of Clan" description: "Founder of Clan"
avatar: "/static/w_profile.webp" avatar: "https://clan.lol/static/profiles/w_profile.webp"
url: "" url: ""
Qubasa: Qubasa:
name: "Qubasa" name: "Qubasa"
description: "Contributor to Clan" description: "Core Developer"
avatar: "https://avatars.githubusercontent.com/u/22085373?v=4" avatar: "https://clan.lol/static/profiles/qubasa.png"
url: "https://github.com/Qubasa" url: "https://github.com/Qubasa"

View File

@@ -0,0 +1,194 @@
---
title: "Dev Report: Introducing the NixOS to JSON Schema Converter"
description: "Discover our new library designed to extract JSON schema interfaces from NixOS modules, streamlining frontend development"
authors:
- DavHau
date: 2024-05-25
slug: jsonschema-converter
---
## Overview
Weve developed a new library designed to extract interfaces from NixOS modules and convert them into JSON schemas, paving the way for effortless GUI generation. This blog post outlines the motivations behind this development, demonstrates the capabilities of the library, and guides you through leveraging it to create GUIs seamlessly.
## Motivation
In recent months, our team has been exploring various graphical user interfaces (GUIs) to streamline NixOS machine configuration. While our opinionated Clan modules simplify NixOS configurations, there's a need to configure these modules from diverse frontends, such as:
- Command-line interfaces (CLIs)
- Web-based UIs
- Desktop applications
- Mobile applications
- Large Language Models (LLMs)
Given this need, a universal format like JSON is a natural choice. It is already possible as of now, to import json based NixOS configurations, as illustrated below:
`configuration.json`:
```json
{ "networking": { "hostName": "my-machine" } }
```
This configuration can be then imported inside a classic NixOS config:
```nix
{config, lib, pkgs, ...}: {
imports = [
(lib.importJSON ./configuration.json)
];
}
```
This straightforward approach allows us to build a frontend that generates JSON, enabling the configuration of NixOS machines. But, two critical questions arise:
1. How does the frontend learn about existing configuration options?
2. How can it verify user input without running Nix?
Introducing [JSON schema](https://json-schema.org/), a widely supported standard that defines interfaces in JSON and validates input against them.
Example schema for `networking.hostName`:
```json
{
"type": "object",
"properties": {
"networking": {
"type": "object",
"properties": {
"hostName": {
"type": "string",
"pattern": "^$|^[a-z0-9]([a-z0-9_-]{0,61}[a-z0-9])?$"
}
}
}
}
}
```
## Client-Side Input Validation
Validating input against JSON schemas is both efficient and well-supported across numerous programming languages. Using JSON schema validators, you can accurately check configurations like our `configuration.json`.
Validation example:
```shell
$ nix-shell -p check-jsonschema
$ jsonschema -o pretty ./schema.json -i ./configuration.json
===[SUCCESS]===(./configuration.json)===
```
In case of invalid input, schema validators provide explicit error messages:
```shell
$ echo '{ "networking": { "hostName": "my/machine" } }' > configuration.json
$ jsonschema -o pretty ./schema.json -i ./configuration.json
===[ValidationError]===(./configuration.json)===
'my/machine' does not match '^$|^[a-z0-9]([a-z0-9_-]{0,61}[a-z0-9])?$'
Failed validating 'pattern' in schema['properties']['networking']['properties']['hostName']:
{'pattern': '^$|^[a-z0-9]([a-z0-9_-]{0,61}[a-z0-9])?$',
'type': 'string'}
On instance['networking']['hostName']:
'my/machine'
```
## Automatic GUI Generation
Certain libraries facilitate straightforward GUI generation from JSON schemas. For instance, the [react-jsonschema-form playground](https://rjsf-team.github.io/react-jsonschema-form/) auto-generates a form for any given schema.
## NixOS Module to JSON Schema Converter
To enable the development of responsive frontends, our library allows the extraction of interfaces from NixOS modules to JSON schemas. Open-sourced for community collaboration, this library supports building sophisticated user interfaces for NixOS.
Heres a preview of our library's functions exposed through the [clan-core](https://git.clan.lol/clan/clan-core) flake:
- `lib.jsonschema.parseModule` - Generates a schema for a NixOS module.
- `lib.jsonschema.parseOption` - Generates a schema for a single NixOS option.
- `lib.jsonschema.parseOptions` - Generates a schema from an attrset of NixOS options.
Example:
`module.nix`:
```nix
{lib, config, pkgs, ...}: {
# a simple service with two options
options.services.example-web-service = {
enable = lib.mkEnableOption "Example web service";
port = lib.mkOption {
type = lib.types.int;
description = "Port used to serve the content";
};
};
}
```
Converted, using the `parseModule` function:
```shell
$ cd clan-core
$ nix eval --json --impure --expr \
'(import ./lib/jsonschema {}).parseModule ./module.nix' | jq | head
{
"properties": {
"services": {
"properties": {
"example-web-service": {
"properties": {
"enable": {
"default": false,
"description": "Whether to enable Example web service.",
"examples": [
...
```
This utility can also generate interfaces for existing NixOS modules or options.
## GUI for NGINX in Under a Minute
Creating a prototype GUI for the NGINX module using our library and [react-jsonschema-form playground](https://rjsf-team.github.io/react-jsonschema-form/) can be done quickly:
1. Export all NGINX options into a JSON schema using a Nix expression:
```nix
# export.nix
let
pkgs = import <nixpkgs> {};
clan-core = builtins.getFlake "git+https://git.clan.lol/clan/clan-core";
options = (pkgs.nixos {}).options.services.nginx;
in
clan-core.lib.jsonschema.parseOption options
```
2. Write the schema into a file:
```shell
$ nix eval --json -f ./export.nix | jq > nginx.json
```
3. Open the [react-jsonschema-form playground](https://rjsf-team.github.io/react-jsonschema-form/), select `Blank` and paste the `nginx.json` contents.
This provides a quick look at a potential GUI (screenshot is cropped).
![Image title](https://clan.lol/static/blog-post-jsonschema/nginx-gui.jpg)
## Limitations
### Laziness
JSON schema mandates the declaration of all required fields upfront, which might be configured implicitly or remain unused. For instance, `services.nginx.virtualHosts.<name>.sslCertificate` must be specified even if SSL isnt enabled.
### Limited Types
Certain NixOS module types, like `types.functionTo` and `types.package`, do not map straightforwardly to JSON. For full compatibility, adjustments to NixOS modules might be necessary, such as substituting `listOf package` with `listOf str`.
### Parsing NixOS Modules
Currently, our converter relies on the `options` attribute of evaluated NixOS modules, extracting information from the `type.name` attribute, which is suboptimal. Enhanced introspection capabilities within the NixOS module system would be beneficial.
## Future Prospects
We hope these experiments inspire the community, encourage contributions and further development in this space. Share your ideas and contributions through our issue tracker or matrix channel!
## Links
- [Comments on NixOS Discourse](https://discourse.nixos.org/t/introducing-the-nixos-to-json-schema-converter/45948)
- [Source Code of the JSON Schema Library](https://git.clan.lol/clan/clan-core/src/branch/main/lib/jsonschema)
- [Our Issue Tracker](https://git.clan.lol/clan/clan-core/issues)
- [Our Matrix Channel](https://matrix.to/#/#clan:lassul.us)
- [react-jsonschema-form Playground](https://rjsf-team.github.io/react-jsonschema-form/)

View File

@@ -0,0 +1,63 @@
---
title: "Git Based Machine Deployment with Clan-Core"
description: ""
authors:
- Qubasa
date: 2024-05-25
---
## Revolutionizing Server Management
In the world of server management, countless tools claim to offer seamless deployment of multiple machines. Yet, many fall short, leaving server admins and self-hosting enthusiasts grappling with complexity. Enter the Clan-Core Framework—a groundbreaking all in one solution designed to transform decentralized self-hosting into an effortless and scalable endeavor.
### The Power of Clan-Core
Imagine having the power to manage your servers with unparalleled ease, scaling your IT infrastructure like never before. Clan-Core empowers you to do just that. At its core, Clan-Core leverages a single Git repository to define everything about your machines. This central repository utilizes Nix or JSON files to specify configurations, including disk formatting, ensuring a streamlined and unified approach.
### Simplified Deployment Process
With Clan-Core, the cumbersome task of bootstrapping a specific ISO is a thing of the past. All you need is SSH access to your Linux server. Clan-Core allows you to overwrite any existing Linux distribution live over SSH, eliminating time-consuming setup processes. This capability means you can deploy updates or new configurations swiftly and efficiently, maximizing uptime and minimizing hassle.
### Secure and Efficient Secret Management
Security is paramount in server management, and Clan-Core takes it seriously. Passwords and other sensitive information are encrypted within the Git repository, automatically decrypted during deployment. This not only ensures the safety of your secrets but also simplifies their management. Clan-Core supports sharing secrets with other admins, fostering collaboration and maintaining reproducibillity and security without sacrificing convenience.
### Services as Apps
Setting up a service can be quite difficult. Many server adjustments need to be made, from setting up a database to adjusting webserver configurations and generating the correct private keys. However, Clan-Core aims to make setting up a service as easy as installing an application. Through Clan-Core's Module system, everything down to secrets can be automatically set up. This transforms the often daunting task of service setup into a smooth, automated process, making it accessible to all.
### Decentralized Mesh VPN
Building on these features is a self-configuring decentralized mesh VPN that interconnects all your machines into a private darknet. This ensures that sensitive services, which might have too much attack surface to be hosted on the public internet, can still be made available privately without the need to worry about potential system compromise. By creating a secure, private network, Clan-Core offers an additional layer of protection for your most critical services.
### Decentralized Domain Name System
Current DNS implementations are distributed but not truly decentralized. For Clan-Core, we implemented our own truly decentralized DNS module. This module uses simple flooding and caching algorithms to discover available domains inside the darknet. This approach ensures that your internal domain name system is robust, reliable, and independent of external control, enhancing the resilience and security of your infrastructure.
### A New Era of Decentralized Self-Hosting
Clan-Core is more than just a tool; it's a paradigm shift in server management. By consolidating machine definitions, secrets and network configuration, into a single, secure repository, it transforms how you manage and scale your infrastructure. Whether you're a seasoned server admin or a self-hosting enthusiast, Clan-Core offers a powerful, user-friendly solution to take your capabilities to the next level.
### Key Features of Clan-Core:
- **Unified Git Repository**: All machine configurations and secrets stored in a single repository.
- **Live Overwrites**: Deploy configurations over existing Linux distributions via SSH.
- **Automated Service Setup**: Easily set up services with Clan-Core's Module system.
- **Decentralized Mesh VPN**: Securely interconnect all machines into a private darknet.
- **Decentralized DNS**: Robust, independent DNS using flooding and caching algorithms.
- **Automated Secret Management**: Encrypted secrets that are automatically decrypted during deployment.
- **Collaboration Support**: Share secrets securely with other admins.
## Clan-Cores Future
Our vision for Clan-Core extends far beyond being just another deployment tool. Clan-Core is a framework we've developed to achieve something much greater. We want to put the "personal" back into "personal computing." Our goal is for everyday users to fully customize their phones or laptops and create truly private spaces for friends and family.
Our first major step is to develop a Graphical User Interface (GUI) that makes configuring all this possible. Initial tests have shown that AI can be leveraged as an alternative to traditional GUIs. This paves the way for a future where people can simply talk to their computers, and they will configure themselves according to the users' wishes.
By adopting Clan, you're not just embracing a tool—you're joining a movement towards a more efficient, secure, and scalable approach to server management. Join us and revolutionize your IT infrastructure today.

View File

@@ -59,7 +59,7 @@ Adding or configuring a new machine requires two simple steps:
Which should show something like: Which should show something like:
```bash hl_lines="6" ```{.shellSession hl_lines="6" .no-copy}
NAME ID-LINK FSTYPE SIZE MOUNTPOINT NAME ID-LINK FSTYPE SIZE MOUNTPOINT
sda usb-ST_16GB_AA6271026J1000000509-0:0 14.9G sda usb-ST_16GB_AA6271026J1000000509-0:0 14.9G
├─sda1 usb-ST_16GB_AA6271026J1000000509-0:0-part1 1M ├─sda1 usb-ST_16GB_AA6271026J1000000509-0:0-part1 1M

View File

@@ -52,7 +52,7 @@ This process involves preparing a suitable hardware and disk partitioning config
This is an example of the booted installer. This is an example of the booted installer.
```{ .bash .annotate .no-copy } ```{ .bash .annotate .no-copy .nohighlight}
┌─────────────────────────────────────────────────────────────────────────────────────┐ ┌─────────────────────────────────────────────────────────────────────────────────────┐
│ ┌───────────────────────────┐ │ │ ┌───────────────────────────┐ │
│ │███████████████████████████│ # This is the QR Code (1) │ │ │███████████████████████████│ # This is the QR Code (1) │
@@ -151,7 +151,7 @@ Clan CLI enables you to remotely update your machines over SSH. This requires se
### Setting the Target Host ### Setting the Target Host
Replace `root@jon` with the actual hostname or IP address of your target machine: Replace `root@jon` with the actual hostname or IP address of your target machine:
```nix hl_lines="9" ```{.nix hl_lines="9" .no-copy}
buildClan { buildClan {
# ... # ...
machines = { machines = {
@@ -192,7 +192,7 @@ it is also possible to specify a build host instead.
During an update, the cli will ssh into the build host and run `nixos-rebuild` from there. During an update, the cli will ssh into the build host and run `nixos-rebuild` from there.
```nix hl_lines="5" ```{.nix hl_lines="5" .no-copy}
buildClan { buildClan {
# ... # ...
machines = { machines = {
@@ -208,7 +208,7 @@ buildClan {
To exclude machines from being updated when running `clan machines update` without any machines specified, To exclude machines from being updated when running `clan machines update` without any machines specified,
one can set the `clan.deployment.requireExplicitUpdate` option to true: one can set the `clan.deployment.requireExplicitUpdate` option to true:
```nix hl_lines="5" ```{.nix hl_lines="5" .no-copy}
buildClan { buildClan {
# ... # ...
machines = { machines = {

View File

@@ -94,9 +94,4 @@ Below is a guide on how to structure this in your flake.nix:
For detailed information about configuring `flake-parts` and the available options within Clan, For detailed information about configuring `flake-parts` and the available options within Clan,
refer to the Clan module documentation located [here](https://git.clan.lol/clan/clan-core/src/branch/main/flakeModules/clan.nix). refer to the Clan module documentation located [here](https://git.clan.lol/clan/clan-core/src/branch/main/flakeModules/clan.nix).
## Whats next?
- [Configure Machines](configure.md): Customize machine configuration
- [Deploying](deploy.md): Deploying a Machine configuration
--- ---

View File

@@ -22,7 +22,7 @@ Follow our step-by-step guide to create and transfer this image onto a bootable
lsblk lsblk
``` ```
```shellSession hl_lines="2" ```{.shellSession hl_lines="2" .no-copy}
NAME MAJ:MIN RM SIZE RO TYPE MOUNTPOINTS NAME MAJ:MIN RM SIZE RO TYPE MOUNTPOINTS
sdb 8:0 1 117,2G 0 disk sdb 8:0 1 117,2G 0 disk
└─sdb1 8:1 1 117,2G 0 part /run/media/qubasa/INTENSO └─sdb1 8:1 1 117,2G 0 part /run/media/qubasa/INTENSO
@@ -123,7 +123,7 @@ This will enter `iwd`
Now run the following command to connect to your Wifi: Now run the following command to connect to your Wifi:
```shellSession ```{.shellSession .no-copy}
# Identify your network device. # Identify your network device.
device list device list
@@ -148,10 +148,10 @@ Connected network FRITZ!Box (Your router device)
IPv4 address 192.168.188.50 (Your new local ip) IPv4 address 192.168.188.50 (Your new local ip)
``` ```
Press `ctrl-d` to exit `IWD`. Press ++ctrl+d++ to exit `IWD`.
!!! Important !!! Important
Press `ctrl-d` **again** to update the displayed QR code and connection information. Press ++ctrl+d++ **again** to update the displayed QR code and connection information.
You're all set up You're all set up

View File

@@ -9,7 +9,7 @@ include a new machine into the VPN.
By default all machines within one clan are connected via a chosen network technology. By default all machines within one clan are connected via a chosen network technology.
``` ```{.no-copy}
Clan Clan
Node A Node A
<-> (zerotier / mycelium / ...) <-> (zerotier / mycelium / ...)
@@ -36,7 +36,7 @@ peers. Once addresses are allocated, the controller's continuous operation is no
``` ```
3. **Update the Controller Machine**: Execute the following: 3. **Update the Controller Machine**: Execute the following:
```bash ```bash
$ clan machines update <CONTROLLER> clan machines update <CONTROLLER>
``` ```
Your machine is now operational as the VPN controller. Your machine is now operational as the VPN controller.

View File

@@ -4,10 +4,15 @@ Clan enables encryption of secrets (such as passwords & keys) ensuring security
Clan utilizes the [sops](https://github.com/getsops/sops) format and integrates with [sops-nix](https://github.com/Mic92/sops-nix) on NixOS machines. Clan utilizes the [sops](https://github.com/getsops/sops) format and integrates with [sops-nix](https://github.com/Mic92/sops-nix) on NixOS machines.
This guide will walk you through:
### Create Your Master Keypair - **Creating a Keypair for Your User**: Learn how to generate a keypair for $USER to securely control all secrets.
- **Creating Your First Secret**: Step-by-step instructions on creating your initial secret.
- **Assigning Machine Access to the Secret**: Understand how to grant a machine access to the newly created secret.
To get started, you'll need to create **Your master keypair**. ## Create Your Admin Keypair
To get started, you'll need to create **Your admin keypair**.
!!! info !!! info
Don't worry — if you've already made one before, this step won't change or overwrite it. Don't worry — if you've already made one before, this step won't change or overwrite it.
@@ -27,7 +32,7 @@ Also add your age public key to the repository with 'clan secrets users add YOUR
!!! warning !!! warning
Make sure to keep a safe backup of the private key you've just created. Make sure to keep a safe backup of the private key you've just created.
If it's lost, you won't be able to get to your secrets anymore because they all need the master key to be unlocked. If it's lost, you won't be able to get to your secrets anymore because they all need the admin key to be unlocked.
!!! note !!! note
It's safe to add any secrets created by the clan CLI and placed in your repository to version control systems like `git`. It's safe to add any secrets created by the clan CLI and placed in your repository to version control systems like `git`.
@@ -35,7 +40,7 @@ Also add your age public key to the repository with 'clan secrets users add YOUR
### Add Your Public Key ### Add Your Public Key
```bash ```bash
clan secrets users add <your_username> <your_public_key> clan secrets users add $USER <your_public_key>
``` ```
It's best to choose the same username as on your Setup/Admin Machine that you use to control the deployment with. It's best to choose the same username as on your Setup/Admin Machine that you use to control the deployment with.

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1 @@
/nix/store/8y5h98wk5p94mv1wyb2c4gkrr7bswd19-asciinema-player.css

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1 @@
/nix/store/w0i3f9qzn9n6jmfnfgiw5wnab2f9ssdw-asciinema-player.min.js

View File

@@ -1,45 +0,0 @@
.asciinema-player-theme-alabaster-auto {
--term-color-foreground: #000000; /* Black for foreground text */
--term-color-background: #f7f7f7; /* Very light gray for background */
--term-color-0: #000000; /* Black */
--term-color-1: #aa3731; /* Red */
--term-color-2: #448c37; /* Green */
--term-color-3: #cb9000; /* Yellow */
--term-color-4: #325cc0; /* Blue */
--term-color-5: #7a3e9d; /* Magenta */
--term-color-6: #0083b2; /* Cyan */
--term-color-7: #bbbbbb; /* White */
--term-color-8: #777777; /* Bright black (gray) */
--term-color-9: #f05050; /* Bright red */
--term-color-10: #60cb00; /* Bright green */
--term-color-11: #ffbc5d; /* Bright yellow */
--term-color-12: #007acc; /* Bright blue */
--term-color-13: #e64ce6; /* Bright magenta */
--term-color-14: #00aacb; /* Bright cyan */
--term-color-15: #ffffff; /* Bright white */
}
@media (prefers-color-scheme: dark) {
.asciinema-player-theme-solarized-auto {
--term-color-foreground: #839496;
--term-color-background: #002b36;
--term-color-0: #073642;
--term-color-1: #dc322f;
--term-color-2: #859900;
--term-color-3: #b58900;
--term-color-4: #268bd2;
--term-color-5: #d33682;
--term-color-6: #2aa198;
--term-color-7: #eee8d5;
--term-color-8: #002b36;
--term-color-9: #cb4b16;
--term-color-10: #586e75;
--term-color-11: #657b83;
--term-color-12: #839496;
--term-color-13: #6c71c4;
--term-color-14: #93a1a1;
--term-color-15: #fdf6e3;
}
}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 108 KiB

10
flake.lock generated
View File

@@ -7,15 +7,15 @@
] ]
}, },
"locked": { "locked": {
"lastModified": 1714400597, "lastModified": 1716394172,
"narHash": "sha256-AA1TCyEl4O6+6F5man/V5VH9Zl9HPBpK91tSkZ16i2E=", "narHash": "sha256-B+pNhV8GFeCj9/MoH+qtGqKbgv6fU4hGaw2+NoYYtB0=",
"owner": "Qubasa", "owner": "nix-community",
"repo": "disko", "repo": "disko",
"rev": "58785136b8c37aeb2f67081387b48f663b166331", "rev": "23c63fb09334c3e8958b57e2ddc3870b75b9111d",
"type": "github" "type": "github"
}, },
"original": { "original": {
"owner": "Qubasa", "owner": "nix-community",
"repo": "disko", "repo": "disko",
"type": "github" "type": "github"
} }

View File

@@ -8,7 +8,7 @@
inputs = { inputs = {
nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable-small"; nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable-small";
disko.url = "github:Qubasa/disko"; disko.url = "github:nix-community/disko";
disko.inputs.nixpkgs.follows = "nixpkgs"; disko.inputs.nixpkgs.follows = "nixpkgs";
sops-nix.url = "github:Mic92/sops-nix"; sops-nix.url = "github:Mic92/sops-nix";
sops-nix.inputs.nixpkgs.follows = "nixpkgs"; sops-nix.inputs.nixpkgs.follows = "nixpkgs";

View File

@@ -83,20 +83,20 @@ rec {
in in
# either type # either type
# TODO: if all nested optiosn are excluded, the parent sould be excluded too # TODO: if all nested options are excluded, the parent should be excluded too
if if
option.type.name or null == "either" option.type.name or null == "either" || option.type.name or null == "coercedTo"
# return jsonschema property definition for either # return jsonschema property definition for either
then then
let let
optionsList' = [ optionsList' = [
{ {
type = option.type.nestedTypes.left; type = option.type.nestedTypes.left or option.type.nestedTypes.coercedType;
_type = "option"; _type = "option";
loc = option.loc; loc = option.loc;
} }
{ {
type = option.type.nestedTypes.right; type = option.type.nestedTypes.right or option.type.nestedTypes.finalType;
_type = "option"; _type = "option";
loc = option.loc; loc = option.loc;
} }
@@ -157,12 +157,21 @@ rec {
# TODO: Add support for intMatching in jsonschema # TODO: Add support for intMatching in jsonschema
# parse port type aka. "unsignedInt16" # parse port type aka. "unsignedInt16"
else if option.type.name == "unsignedInt16" then else if
option.type.name == "unsignedInt16"
|| option.type.name == "unsignedInt"
|| option.type.name == "pkcs11"
|| option.type.name == "intBetween"
then
default // example // description // { type = "integer"; } default // example // description // { type = "integer"; }
# parse string # parse string
# TODO: parse more precise string types
else if else if
option.type.name == "str" option.type.name == "str"
|| option.type.name == "singleLineStr"
|| option.type.name == "passwdEntry str"
|| option.type.name == "passwdEntry path"
# return jsonschema property definition for string # return jsonschema property definition for string
then then
default // example // description // { type = "string"; } default // example // description // { type = "string"; }

View File

@@ -1,10 +1,12 @@
import json
from clan_cli.api import API from clan_cli.api import API
def main() -> None: def main() -> None:
schema = API.to_json_schema() schema = API.to_json_schema()
print( print(
f"""export const schema = {schema} as const; f"""export const schema = {json.dumps(schema, indent=2)} as const;
""" """
) )

View File

@@ -52,7 +52,17 @@ class AppendOptionAction(argparse.Action):
def create_parser(prog: str | None = None) -> argparse.ArgumentParser: def create_parser(prog: str | None = None) -> argparse.ArgumentParser:
parser = argparse.ArgumentParser(prog=prog, description="cLAN tool") parser = argparse.ArgumentParser(
prog=prog,
description="The clan cli tool.",
epilog=(
"""
Online reference for the clan cli tool: https://docs.clan.lol/reference/cli/
For more detailed information, visit: https://docs.clan.lol
"""
),
formatter_class=argparse.RawTextHelpFormatter,
)
parser.add_argument( parser.add_argument(
"--debug", "--debug",
@@ -90,28 +100,160 @@ def create_parser(prog: str | None = None) -> argparse.ArgumentParser:
"backups", "backups",
help="manage backups of clan machines", help="manage backups of clan machines",
description="manage backups of clan machines", description="manage backups of clan machines",
epilog=(
"""
This subcommand provides an interface to backups that clan machines expose.
Examples:
$ clan backups list [MACHINE]
List backups for the machine [MACHINE]
$ clan backups create [MACHINE]
Create a backup for the machine [MACHINE].
$ clan backups restore [MACHINE] [PROVIDER] [NAME]
The backup to restore for the machine [MACHINE] with the configured [PROVIDER]
with the name [NAME].
For more detailed information, visit: https://docs.clan.lol/getting-started/backups/
"""
),
formatter_class=argparse.RawTextHelpFormatter,
) )
backups.register_parser(parser_backups) backups.register_parser(parser_backups)
parser_flake = subparsers.add_parser( parser_flake = subparsers.add_parser(
"flakes", help="create a clan flake inside the current directory" "flakes",
help="create a clan flake inside the current directory",
epilog=(
"""
Examples:
$ clan flakes create [DIR]
Will create a new clan flake in the specified directory and create it if it
doesn't exist yet. The flake will be created from a default template.
For more detailed information, visit: https://docs.clan.lol/getting-started
"""
),
formatter_class=argparse.RawTextHelpFormatter,
) )
flakes.register_parser(parser_flake) flakes.register_parser(parser_flake)
parser_config = subparsers.add_parser("config", help="set nixos configuration") parser_config = subparsers.add_parser(
"config",
help="set nixos configuration",
epilog=(
"""
"""
),
formatter_class=argparse.RawTextHelpFormatter,
)
config.register_parser(parser_config) config.register_parser(parser_config)
parser_ssh = subparsers.add_parser("ssh", help="ssh to a remote machine") parser_ssh = subparsers.add_parser(
"ssh",
help="ssh to a remote machine",
epilog=(
"""
This subcommand allows seamless ssh access to the nixos-image builders.
Examples:
$ clan ssh [ssh_args ...] --json [JSON]
Will ssh in to the machine based on the deployment information contained in
the json string. [JSON] can either be a json formatted string itself, or point
towards a file containing the deployment information
For more detailed information, visit: https://docs.clan.lol/getting-started/deploy
"""
),
formatter_class=argparse.RawTextHelpFormatter,
)
ssh_cli.register_parser(parser_ssh) ssh_cli.register_parser(parser_ssh)
parser_secrets = subparsers.add_parser("secrets", help="manage secrets") parser_secrets = subparsers.add_parser(
"secrets",
help="manage secrets",
epilog=(
"""
This subcommand provides an interface to secret facts.
Examples:
$ clan secrets list [regex]
Will list secrets for all managed machines.
It accepts an optional regex, allowing easy filtering of returned secrets.
$ clan secrets get [SECRET]
Will display the content of the specified secret.
For more detailed information, visit: https://docs.clan.lol/getting-started/secrets/
"""
),
formatter_class=argparse.RawTextHelpFormatter,
)
secrets.register_parser(parser_secrets) secrets.register_parser(parser_secrets)
parser_facts = subparsers.add_parser("facts", help="manage facts") parser_facts = subparsers.add_parser(
"facts",
help="manage facts",
epilog=(
"""
This subcommand provides an interface to facts of clan machines.
Facts are artifacts that a service can generate.
There are public and secret facts.
Public facts can be referenced by other machines directly.
Public facts can include: ip addresses, public keys.
Secret facts can include: passwords, private keys.
A service is an included clan-module that implements facts generation functionality.
For example the zerotier module will generate private and public facts.
In this case the public fact will be the resulting zerotier-ip of the machine.
The secret fact will be the zerotier-identity-secret, which is used by zerotier
to prove the machine has control of the zerotier-ip.
Examples:
$ clan facts generate
Will generate facts for all machines.
$ clan facts generate --service [SERVICE] --regenerate
Will regenerate facts, if they are already generated for a specific service.
This is especially useful for resetting certain passwords while leaving the rest
of the facts for a machine in place.
For more detailed information, visit: https://docs.clan.lol/getting-started/secrets/
"""
),
formatter_class=argparse.RawTextHelpFormatter,
)
facts.register_parser(parser_facts) facts.register_parser(parser_facts)
parser_machine = subparsers.add_parser( parser_machine = subparsers.add_parser(
"machines", help="manage machines and their configuration" "machines",
help="manage machines and their configuration",
epilog=(
"""
This subcommand provides an interface to machines managed by clan.
Examples:
$ clan machines list
List all the machines managed by clan.
$ clan machines update [MACHINES]
Will update the specified machine [MACHINE], if [MACHINE] is omitted, the command
will attempt to update every configured machine.
$ clan machines install [MACHINES] [TARGET_HOST]
Will install the specified machine [MACHINE], to the specified [TARGET_HOST].
For more detailed information, visit: https://docs.clan.lol/getting-started/deploy
"""
),
formatter_class=argparse.RawTextHelpFormatter,
) )
machines.register_parser(parser_machine) machines.register_parser(parser_machine)

View File

@@ -1,18 +1,37 @@
from collections.abc import Callable from collections.abc import Callable
from typing import Any from dataclasses import dataclass
from typing import Any, Generic, Literal, TypeVar
T = TypeVar("T")
ResponseDataType = TypeVar("ResponseDataType")
@dataclass
class ApiError:
message: str
description: str | None
location: list[str] | None
@dataclass
class ApiResponse(Generic[ResponseDataType]):
status: Literal["success", "error"]
errors: list[ApiError] | None
data: ResponseDataType | None
class _MethodRegistry: class _MethodRegistry:
def __init__(self) -> None: def __init__(self) -> None:
self._registry: dict[str, Callable] = {} self._registry: dict[str, Callable[[Any], Any]] = {}
def register(self, fn: Callable) -> Callable: def register(self, fn: Callable[..., T]) -> Callable[..., T]:
self._registry[fn.__name__] = fn self._registry[fn.__name__] = fn
return fn return fn
def to_json_schema(self) -> str: def to_json_schema(self) -> dict[str, Any]:
# Import only when needed # Import only when needed
import json
from typing import get_type_hints from typing import get_type_hints
from clan_cli.api.util import type_to_dict from clan_cli.api.util import type_to_dict
@@ -21,25 +40,51 @@ class _MethodRegistry:
"$comment": "An object containing API methods. ", "$comment": "An object containing API methods. ",
"type": "object", "type": "object",
"additionalProperties": False, "additionalProperties": False,
"required": ["list_machines"], "required": [func_name for func_name in self._registry.keys()],
"properties": {}, "properties": {},
} }
for name, func in self._registry.items(): for name, func in self._registry.items():
hints = get_type_hints(func) hints = get_type_hints(func)
serialized_hints = { serialized_hints = {
"argument" if key != "return" else "return": type_to_dict( key: type_to_dict(
value, scope=name + " argument" if key != "return" else "return" value, scope=name + " argument" if key != "return" else "return"
) )
for key, value in hints.items() for key, value in hints.items()
} }
return_type = serialized_hints.pop("return")
api_schema["properties"][name] = { api_schema["properties"][name] = {
"type": "object",
"required": ["arguments", "return"],
"additionalProperties": False,
"properties": {
"return": return_type,
"arguments": {
"type": "object", "type": "object",
"required": [k for k in serialized_hints.keys()], "required": [k for k in serialized_hints.keys()],
"additionalProperties": False, "additionalProperties": False,
"properties": {**serialized_hints}, "properties": serialized_hints,
},
},
} }
return json.dumps(api_schema, indent=2) return api_schema
def get_method_argtype(self, method_name: str, arg_name: str) -> Any:
from inspect import signature
func = self._registry.get(method_name, None)
if func:
sig = signature(func)
param = sig.parameters.get(arg_name)
if param:
param_class = param.annotation
return param_class
return None
API = _MethodRegistry() API = _MethodRegistry()

View File

@@ -42,8 +42,13 @@ def type_to_dict(t: Any, scope: str = "") -> dict:
return {"type": "array", "items": type_to_dict(t.__args__[0], scope)} return {"type": "array", "items": type_to_dict(t.__args__[0], scope)}
elif issubclass(origin, dict): elif issubclass(origin, dict):
value_type = t.__args__[1]
if value_type is Any:
return {"type": "object", "additionalProperties": True}
else:
return { return {
"type": "object", "type": "object",
"additionalProperties": type_to_dict(value_type, scope),
} }
raise BaseException(f"Error api type not yet supported {t!s}") raise BaseException(f"Error api type not yet supported {t!s}")

View File

@@ -140,3 +140,23 @@ def run(
raise ClanCmdError(cmd_out) raise ClanCmdError(cmd_out)
return cmd_out return cmd_out
def run_no_stdout(
cmd: list[str],
*,
env: dict[str, str] | None = None,
cwd: Path = Path.cwd(),
log: Log = Log.STDERR,
check: bool = True,
error_msg: str | None = None,
) -> CmdOut:
"""
Like run, but automatically suppresses stdout, if not in DEBUG log level.
If in DEBUG log level the stdout of commands will be shown.
"""
if logging.getLogger(__name__.split(".")[0]).isEnabledFor(logging.DEBUG):
return run(cmd, env=env, log=log, check=check, error_msg=error_msg)
else:
log = Log.NONE
return run(cmd, env=env, log=log, check=check, error_msg=error_msg)

View File

@@ -8,7 +8,7 @@ import sys
from pathlib import Path from pathlib import Path
from typing import Any, get_origin from typing import Any, get_origin
from clan_cli.cmd import run from clan_cli.cmd import run_no_stdout
from clan_cli.dirs import machine_settings_file from clan_cli.dirs import machine_settings_file
from clan_cli.errors import ClanError from clan_cli.errors import ClanError
from clan_cli.git import commit_file from clan_cli.git import commit_file
@@ -116,7 +116,7 @@ def options_for_machine(
f"{clan_dir}#nixosConfigurations.{machine_name}.config.clanCore.optionsNix" f"{clan_dir}#nixosConfigurations.{machine_name}.config.clanCore.optionsNix"
) )
cmd = nix_eval(flags=flags) cmd = nix_eval(flags=flags)
proc = run( proc = run_no_stdout(
cmd, cmd,
error_msg=f"Failed to read options for machine {machine_name}", error_msg=f"Failed to read options for machine {machine_name}",
) )
@@ -136,7 +136,7 @@ def read_machine_option_value(
f"{clan_dir}#nixosConfigurations.{machine_name}.config.{option}", f"{clan_dir}#nixosConfigurations.{machine_name}.config.{option}",
], ],
) )
proc = run(cmd, error_msg=f"Failed to read option {option}") proc = run_no_stdout(cmd, error_msg=f"Failed to read option {option}")
value = json.loads(proc.stdout) value = json.loads(proc.stdout)
# print the value so that the output can be copied and fed as an input. # print the value so that the output can be copied and fed as an input.

View File

@@ -2,7 +2,7 @@ import json
from pathlib import Path from pathlib import Path
from typing import Any from typing import Any
from ..cmd import run from ..cmd import run_no_stdout
from ..errors import ClanError from ..errors import ClanError
from ..nix import nix_eval from ..nix import nix_eval
@@ -32,7 +32,7 @@ def schema_from_module_file(
""" """
# run the nix expression and parse the output as json # run the nix expression and parse the output as json
cmd = nix_eval(["--expr", nix_expr]) cmd = nix_eval(["--expr", nix_expr])
proc = run(cmd) proc = run_no_stdout(cmd)
return json.loads(proc.stdout) return json.loads(proc.stdout)

View File

@@ -23,7 +23,42 @@ def register_parser(parser: argparse.ArgumentParser) -> None:
register_list_parser(list_parser) register_list_parser(list_parser)
parser_generate = subparser.add_parser( parser_generate = subparser.add_parser(
"generate", help="generate secrets for machines if they don't exist yet" "generate",
help="generate public and secret facts for machines",
epilog=(
"""
This subcommand allows control of the generation of facts.
Often this function will be invoked automatically on deploying machines,
but there are situations the user may want to have more granular control,
especially for the regeneration of certain services.
A service is an included clan-module that implements facts generation functionality.
For example the zerotier module will generate private and public facts.
In this case the public fact will be the resulting zerotier-ip of the machine.
The secret fact will be the zerotier-identity-secret, which is used by zerotier
to prove the machine has control of the zerotier-ip.
Examples:
$ clan facts generate
Will generate facts for all machines.
$ clan facts generate [MACHINE]
Will generate facts for the specified machine.
$ clan facts generate [MACHINE] --service [SERVICE]
Will generate facts for the specified machine for the specified service.
$ clan facts generate --service [SERVICE] --regenerate
Will regenerate facts, if they are already generated for a specific service.
This is especially useful for resetting certain passwords while leaving the rest
of the facts for a machine in place.
For more detailed information, visit: https://docs.clan.lol/getting-started/secrets/
"""
),
formatter_class=argparse.RawTextHelpFormatter,
) )
register_generate_parser(parser_generate) register_generate_parser(parser_generate)

View File

@@ -33,6 +33,7 @@ def read_multiline_input(prompt: str = "Finish with Ctrl-D") -> str:
def generate_service_facts( def generate_service_facts(
machine: Machine, machine: Machine,
service: str, service: str,
regenerate: bool,
secret_facts_store: SecretStoreBase, secret_facts_store: SecretStoreBase,
public_facts_store: FactStoreBase, public_facts_store: FactStoreBase,
tmpdir: Path, tmpdir: Path,
@@ -42,7 +43,7 @@ def generate_service_facts(
# check if all secrets exist and generate them if at least one is missing # check if all secrets exist and generate them if at least one is missing
needs_regeneration = not check_secrets(machine, service=service) needs_regeneration = not check_secrets(machine, service=service)
log.debug(f"{service} needs_regeneration: {needs_regeneration}") log.debug(f"{service} needs_regeneration: {needs_regeneration}")
if not needs_regeneration: if not (needs_regeneration or regenerate):
return False return False
if not isinstance(machine.flake, Path): if not isinstance(machine.flake, Path):
msg = f"flake is not a Path: {machine.flake}" msg = f"flake is not a Path: {machine.flake}"
@@ -134,7 +135,11 @@ def prompt_func(text: str) -> str:
def _generate_facts_for_machine( def _generate_facts_for_machine(
machine: Machine, tmpdir: Path, prompt: Callable[[str], str] = prompt_func machine: Machine,
service: str | None,
regenerate: bool,
tmpdir: Path,
prompt: Callable[[str], str] = prompt_func,
) -> bool: ) -> bool:
local_temp = tmpdir / machine.name local_temp = tmpdir / machine.name
local_temp.mkdir() local_temp.mkdir()
@@ -145,10 +150,23 @@ def _generate_facts_for_machine(
public_facts_store = public_facts_module.FactStore(machine=machine) public_facts_store = public_facts_module.FactStore(machine=machine)
machine_updated = False machine_updated = False
for service in machine.facts_data:
if service and service not in machine.facts_data:
services = list(machine.facts_data.keys())
raise ClanError(
f"Could not find service with name: {service}. The following services are available: {services}"
)
if service:
machine_service_facts = {service: machine.facts_data[service]}
else:
machine_service_facts = machine.facts_data
for service in machine_service_facts:
machine_updated |= generate_service_facts( machine_updated |= generate_service_facts(
machine=machine, machine=machine,
service=service, service=service,
regenerate=regenerate,
secret_facts_store=secret_facts_store, secret_facts_store=secret_facts_store,
public_facts_store=public_facts_store, public_facts_store=public_facts_store,
tmpdir=local_temp, tmpdir=local_temp,
@@ -161,7 +179,10 @@ def _generate_facts_for_machine(
def generate_facts( def generate_facts(
machines: list[Machine], prompt: Callable[[str], str] = prompt_func machines: list[Machine],
service: str | None,
regenerate: bool,
prompt: Callable[[str], str] = prompt_func,
) -> bool: ) -> bool:
was_regenerated = False was_regenerated = False
with TemporaryDirectory() as tmp: with TemporaryDirectory() as tmp:
@@ -170,7 +191,9 @@ def generate_facts(
for machine in machines: for machine in machines:
errors = 0 errors = 0
try: try:
was_regenerated |= _generate_facts_for_machine(machine, tmpdir, prompt) was_regenerated |= _generate_facts_for_machine(
machine, service, regenerate, tmpdir, prompt
)
except Exception as exc: except Exception as exc:
log.error(f"Failed to generate facts for {machine.name}: {exc}") log.error(f"Failed to generate facts for {machine.name}: {exc}")
errors += 1 errors += 1
@@ -189,7 +212,7 @@ def generate_command(args: argparse.Namespace) -> None:
machines = get_all_machines(args.flake) machines = get_all_machines(args.flake)
else: else:
machines = get_selected_machines(args.flake, args.machines) machines = get_selected_machines(args.flake, args.machines)
generate_facts(machines) generate_facts(machines, args.service, args.regenerate)
def register_generate_parser(parser: argparse.ArgumentParser) -> None: def register_generate_parser(parser: argparse.ArgumentParser) -> None:
@@ -200,4 +223,17 @@ def register_generate_parser(parser: argparse.ArgumentParser) -> None:
nargs="*", nargs="*",
default=[], default=[],
) )
parser.add_argument(
"--service",
type=str,
help="service to generate facts for, if empty, generate facts for every service",
default=None,
)
parser.add_argument(
"--regenerate",
type=bool,
action=argparse.BooleanOptionalAction,
help="whether to regenerate facts for the specified machine",
default=None,
)
parser.set_defaults(func=generate_command) parser.set_defaults(func=generate_command)

View File

@@ -26,7 +26,14 @@ def get_all_facts(machine: Machine) -> dict:
def get_command(args: argparse.Namespace) -> None: def get_command(args: argparse.Namespace) -> None:
machine = Machine(name=args.machine, flake=args.flake) machine = Machine(name=args.machine, flake=args.flake)
print(json.dumps(get_all_facts(machine), indent=4))
# the raw_facts are bytestrings making them not json serializable
raw_facts = get_all_facts(machine)
facts = dict()
for key in raw_facts["TODO"]:
facts[key] = raw_facts["TODO"][key].decode("utf8")
print(json.dumps(facts, indent=4))
def register_list_parser(parser: argparse.ArgumentParser) -> None: def register_list_parser(parser: argparse.ArgumentParser) -> None:

View File

@@ -2,7 +2,7 @@
import argparse import argparse
from pathlib import Path from pathlib import Path
from ..cmd import CmdOut, run from ..cmd import CmdOut, run_no_stdout
from ..errors import ClanError from ..errors import ClanError
from ..nix import nix_command, nix_shell from ..nix import nix_command, nix_shell
@@ -23,28 +23,28 @@ def create_flake(directory: Path, url: str) -> dict[str, CmdOut]:
url, url,
] ]
) )
out = run(command, cwd=directory) out = run_no_stdout(command, cwd=directory)
command = nix_shell(["nixpkgs#git"], ["git", "init"]) command = nix_shell(["nixpkgs#git"], ["git", "init"])
out = run(command, cwd=directory) out = run_no_stdout(command, cwd=directory)
response["git init"] = out response["git init"] = out
command = nix_shell(["nixpkgs#git"], ["git", "add", "."]) command = nix_shell(["nixpkgs#git"], ["git", "add", "."])
out = run(command, cwd=directory) out = run_no_stdout(command, cwd=directory)
response["git add"] = out response["git add"] = out
command = nix_shell(["nixpkgs#git"], ["git", "config", "user.name", "clan-tool"]) command = nix_shell(["nixpkgs#git"], ["git", "config", "user.name", "clan-tool"])
out = run(command, cwd=directory) out = run_no_stdout(command, cwd=directory)
response["git config"] = out response["git config"] = out
command = nix_shell( command = nix_shell(
["nixpkgs#git"], ["git", "config", "user.email", "clan@example.com"] ["nixpkgs#git"], ["git", "config", "user.email", "clan@example.com"]
) )
out = run(command, cwd=directory) out = run_no_stdout(command, cwd=directory)
response["git config"] = out response["git config"] = out
command = ["nix", "flake", "update"] command = ["nix", "flake", "update"]
out = run(command, cwd=directory) out = run_no_stdout(command, cwd=directory)
response["flake update"] = out response["flake update"] = out
return response return response

View File

@@ -2,7 +2,7 @@ import argparse
from dataclasses import dataclass from dataclasses import dataclass
from pathlib import Path from pathlib import Path
from ..cmd import run from ..cmd import run_no_stdout
from ..dirs import machine_gcroot from ..dirs import machine_gcroot
from ..errors import ClanError from ..errors import ClanError
from ..machines.list import list_machines from ..machines.list import list_machines
@@ -30,7 +30,7 @@ class FlakeConfig:
def run_cmd(cmd: list[str]) -> str: def run_cmd(cmd: list[str]) -> str:
proc = run(cmd) proc = run_no_stdout(cmd)
return proc.stdout.strip() return proc.stdout.strip()
@@ -39,7 +39,7 @@ def inspect_flake(flake_url: str | Path, machine_name: str) -> FlakeConfig:
system = config["system"] system = config["system"]
# Check if the machine exists # Check if the machine exists
machines = list_machines(flake_url) machines = list_machines(flake_url, False)
if machine_name not in machines: if machine_name not in machines:
raise ClanError( raise ClanError(
f"Machine {machine_name} not found in {flake_url}. Available machines: {', '.join(machines)}" f"Machine {machine_name} not found in {flake_url}. Available machines: {', '.join(machines)}"
@@ -53,7 +53,7 @@ def inspect_flake(flake_url: str | Path, machine_name: str) -> FlakeConfig:
gcroot_icon: Path = machine_gcroot(flake_url=str(flake_url)) / vm.machine_name gcroot_icon: Path = machine_gcroot(flake_url=str(flake_url)) / vm.machine_name
nix_add_to_gcroots(vm.machine_icon, gcroot_icon) nix_add_to_gcroots(vm.machine_icon, gcroot_icon)
# Get the cLAN name # Get the Clan name
cmd = nix_eval( cmd = nix_eval(
[ [
f'{flake_url}#clanInternals.machines."{system}"."{machine_name}".config.clanCore.clanName' f'{flake_url}#clanInternals.machines."{system}"."{machine_name}".config.clanCore.clanName'
@@ -70,7 +70,7 @@ def inspect_flake(flake_url: str | Path, machine_name: str) -> FlakeConfig:
) )
res = run_cmd(cmd) res = run_cmd(cmd)
# If the icon is null, no icon is set for this cLAN # If the icon is null, no icon is set for this Clan
if res == "null": if res == "null":
icon_path = None icon_path = None
else: else:
@@ -113,7 +113,7 @@ def inspect_command(args: argparse.Namespace) -> None:
res = inspect_flake( res = inspect_flake(
flake_url=inspect_options.flake, machine_name=inspect_options.machine flake_url=inspect_options.flake, machine_name=inspect_options.machine
) )
print("cLAN name:", res.clan_name) print("Clan name:", res.clan_name)
print("Icon:", res.icon) print("Icon:", res.icon)
print("Description:", res.description) print("Description:", res.description)
print("Last updated:", res.last_updated) print("Last updated:", res.last_updated)

View File

@@ -12,7 +12,7 @@ from pathlib import Path
from tempfile import TemporaryDirectory from tempfile import TemporaryDirectory
from typing import Any from typing import Any
from .cmd import Log, run from .cmd import Log, run, run_no_stdout
from .errors import ClanError from .errors import ClanError
from .facts.secret_modules import SecretStoreBase from .facts.secret_modules import SecretStoreBase
from .machines.machines import Machine from .machines.machines import Machine
@@ -60,7 +60,7 @@ def get_keymap_and_locale() -> dict[str, str]:
keymap = "en" keymap = "en"
# Execute the `localectl status` command # Execute the `localectl status` command
result = run(["localectl", "status"]) result = run_no_stdout(["localectl", "status"])
if result.returncode == 0: if result.returncode == 0:
output = result.stdout output = result.stdout

View File

@@ -4,7 +4,7 @@ from pathlib import Path
from clan_cli.errors import ClanError from clan_cli.errors import ClanError
from clan_cli.nix import nix_shell from clan_cli.nix import nix_shell
from .cmd import Log, run from .cmd import Log, run, run_no_stdout
from .locked_open import locked_open from .locked_open import locked_open
@@ -78,7 +78,7 @@ def _commit_file_to_git(
["git", "-C", str(repo_dir), "diff", "--cached", "--exit-code"] ["git", "-C", str(repo_dir), "diff", "--cached", "--exit-code"]
+ [str(file_path) for file_path in file_paths], + [str(file_path) for file_path in file_paths],
) )
result = run(cmd, check=False, cwd=repo_dir) result = run_no_stdout(cmd, check=False, cwd=repo_dir)
# if there is no diff, return # if there is no diff, return
if result.returncode == 0: if result.returncode == 0:
return return
@@ -97,6 +97,6 @@ def _commit_file_to_git(
+ [str(file_path) for file_path in file_paths], + [str(file_path) for file_path in file_paths],
) )
run( run_no_stdout(
cmd, error_msg=f"Failed to commit {file_paths} to git repository {repo_dir}" cmd, error_msg=f"Failed to commit {file_paths} to git repository {repo_dir}"
) )

View File

@@ -17,7 +17,26 @@ def register_parser(parser: argparse.ArgumentParser) -> None:
required=True, required=True,
) )
update_parser = subparser.add_parser("update", help="Update a machine") update_parser = subparser.add_parser(
"update",
help="Update a machine",
epilog=(
"""
This subcommand provides an interface to update machines managed by clan.
Examples:
$ clan machines update [MACHINES]
Will update the specified machine [MACHINE], if [MACHINE] is omitted, the command
will attempt to update every configured machine.
To exclude machines being updated `clan.deployment.requireExplicitUpdate = true;`
can be set in the machine config.
For more detailed information, visit: https://docs.clan.lol/getting-started/deploy
"""
),
formatter_class=argparse.RawTextHelpFormatter,
)
register_update_parser(update_parser) register_update_parser(update_parser)
create_parser = subparser.add_parser("create", help="Create a machine") create_parser = subparser.add_parser("create", help="Create a machine")
@@ -26,7 +45,21 @@ def register_parser(parser: argparse.ArgumentParser) -> None:
delete_parser = subparser.add_parser("delete", help="Delete a machine") delete_parser = subparser.add_parser("delete", help="Delete a machine")
register_delete_parser(delete_parser) register_delete_parser(delete_parser)
list_parser = subparser.add_parser("list", help="List machines") list_parser = subparser.add_parser(
"list",
help="List machines",
epilog=(
"""
This subcommand lists all machines managed by this clan.
Examples:
$ clan machines list
Lists all the machines and their descriptions.
"""
),
formatter_class=argparse.RawTextHelpFormatter,
)
register_list_parser(list_parser) register_list_parser(list_parser)
install_parser = subparser.add_parser( install_parser = subparser.add_parser(
@@ -37,5 +70,23 @@ def register_parser(parser: argparse.ArgumentParser) -> None:
The target must be a Linux based system reachable via SSH. The target must be a Linux based system reachable via SSH.
Installing a machine means overwriting the target's disk. Installing a machine means overwriting the target's disk.
""", """,
epilog=(
"""
This subcommand provides an interface to install machines managed by clan.
Examples:
$ clan machines install [MACHINE] [TARGET_HOST]
Will install the specified machine [MACHINE], to the specified [TARGET_HOST].
$ clan machines install [MACHINE] --json [JSON]
Will install the specified machine [MACHINE] to the host exposed by
the deployment information of the [JSON] deployment string.
For information on how to set up the installer see: https://docs.clan.lol/getting-started/installer/
For more detailed information, visit: https://docs.clan.lol/getting-started/deploy
"""
),
formatter_class=argparse.RawTextHelpFormatter,
) )
register_install_parser(install_parser) register_install_parser(install_parser)

View File

@@ -1,13 +1,27 @@
import argparse import argparse
import logging import logging
from dataclasses import dataclass
from pathlib import Path
from clan_cli.api import API
from clan_cli.config.machine import set_config_for_machine from clan_cli.config.machine import set_config_for_machine
log = logging.getLogger(__name__) log = logging.getLogger(__name__)
@dataclass
class MachineCreateRequest:
name: str
config: dict[str, int]
@API.register
def create_machine(flake_dir: str | Path, machine: MachineCreateRequest) -> None:
set_config_for_machine(Path(flake_dir), machine.name, machine.config)
def create_command(args: argparse.Namespace) -> None: def create_command(args: argparse.Namespace) -> None:
set_config_for_machine(args.flake, args.machine, dict()) create_machine(args.flake, MachineCreateRequest(args.machine, dict()))
def register_create_parser(parser: argparse.ArgumentParser) -> None: def register_create_parser(parser: argparse.ArgumentParser) -> None:

View File

@@ -35,7 +35,7 @@ def install_nixos(
target_host = f"{h.user or 'root'}@{h.host}" target_host = f"{h.user or 'root'}@{h.host}"
log.info(f"target host: {target_host}") log.info(f"target host: {target_host}")
generate_facts([machine]) generate_facts([machine], None, False)
with TemporaryDirectory() as tmpdir_: with TemporaryDirectory() as tmpdir_:
tmpdir = Path(tmpdir_) tmpdir = Path(tmpdir_)

View File

@@ -1,7 +1,7 @@
import json import json
from pathlib import Path from pathlib import Path
from ..cmd import run from ..cmd import run_no_stdout
from ..nix import nix_build, nix_config from ..nix import nix_build, nix_config
from .machines import Machine from .machines import Machine
@@ -10,7 +10,7 @@ from .machines import Machine
def get_all_machines(flake_dir: Path) -> list[Machine]: def get_all_machines(flake_dir: Path) -> list[Machine]:
config = nix_config() config = nix_config()
system = config["system"] system = config["system"]
json_path = run( json_path = run_no_stdout(
nix_build([f'{flake_dir}#clanInternals.all-machines-json."{system}"']) nix_build([f'{flake_dir}#clanInternals.all-machines-json."{system}"'])
).stdout ).stdout

View File

@@ -1,37 +1,63 @@
import argparse import argparse
import dataclasses
import json import json
import logging import logging
from pathlib import Path from pathlib import Path
from clan_cli.api import API from clan_cli.api import API
from ..cmd import run from ..cmd import run_no_stdout
from ..nix import nix_config, nix_eval from ..nix import nix_config, nix_eval
log = logging.getLogger(__name__) log = logging.getLogger(__name__)
@dataclasses.dataclass
class MachineInfo:
machine_name: str
machine_description: str | None
machine_icon: str | None
@API.register @API.register
def list_machines(flake_url: Path | str) -> list[str]: def list_machines(flake_url: str | Path, debug: bool) -> dict[str, MachineInfo]:
config = nix_config() config = nix_config()
system = config["system"] system = config["system"]
cmd = nix_eval( cmd = nix_eval(
[ [
f"{flake_url}#clanInternals.machines.{system}", f"{flake_url}#clanInternals.machines.{system}",
"--apply", "--apply",
"builtins.attrNames", """builtins.mapAttrs (name: attrs: {
inherit (attrs.config.clanCore) machineDescription machineIcon machineName;
})""",
"--json", "--json",
] ]
) )
proc = run(cmd)
proc = run_no_stdout(cmd)
res = proc.stdout.strip() res = proc.stdout.strip()
return json.loads(res) machines_dict = json.loads(res)
return {
k: MachineInfo(
machine_name=v.get("machineName"),
machine_description=v.get("machineDescription", None),
machine_icon=v.get("machineIcon", None),
)
for k, v in machines_dict.items()
}
def list_command(args: argparse.Namespace) -> None: def list_command(args: argparse.Namespace) -> None:
for machine in list_machines(Path(args.flake)): flake_path = Path(args.flake).resolve()
print(machine) print("Listing all machines:\n")
print("Source: ", flake_path)
print("-" * 40)
for name, machine in list_machines(flake_path, args.debug).items():
description = machine.machine_description or "[no description]"
print(f"{name}\n: {description}\n")
print("-" * 40)
def register_list_parser(parser: argparse.ArgumentParser) -> None: def register_list_parser(parser: argparse.ArgumentParser) -> None:

View File

@@ -10,7 +10,7 @@ from clan_cli.clan_uri import ClanURI, MachineData
from clan_cli.dirs import vm_state_dir from clan_cli.dirs import vm_state_dir
from clan_cli.qemu.qmp import QEMUMonitorProtocol from clan_cli.qemu.qmp import QEMUMonitorProtocol
from ..cmd import run from ..cmd import run_no_stdout
from ..errors import ClanError from ..errors import ClanError
from ..nix import nix_build, nix_config, nix_eval, nix_metadata from ..nix import nix_build, nix_config, nix_eval, nix_metadata
from ..ssh import Host, parse_deployment_address from ..ssh import Host, parse_deployment_address
@@ -197,15 +197,15 @@ class Machine:
config_json.flush() config_json.flush()
file_info = json.loads( file_info = json.loads(
run( run_no_stdout(
nix_eval( nix_eval(
[ [
"--impure", "--impure",
"--expr", "--expr",
f'let x = (builtins.fetchTree {{ type = "file"; url = "file://{config_json.name}"; }}); in {{ narHash = x.narHash; path = x.outPath; }}', f'let x = (builtins.fetchTree {{ type = "file"; url = "file://{config_json.name}"; }}); in {{ narHash = x.narHash; path = x.outPath; }}',
] ]
) ),
).stdout.strip() ).stdout.strip(),
) )
args = [] args = []
@@ -247,10 +247,10 @@ class Machine:
] ]
if method == "eval": if method == "eval":
output = run(nix_eval(args)).stdout.strip() output = run_no_stdout(nix_eval(args)).stdout.strip()
return output return output
elif method == "build": elif method == "build":
outpath = run(nix_build(args)).stdout.strip() outpath = run_no_stdout(nix_build(args)).stdout.strip()
return Path(outpath) return Path(outpath)
else: else:
raise ValueError(f"Unknown method {method}") raise ValueError(f"Unknown method {method}")

View File

@@ -98,7 +98,7 @@ def deploy_nixos(machines: MachineGroup) -> None:
env = os.environ.copy() env = os.environ.copy()
env["NIX_SSHOPTS"] = ssh_arg env["NIX_SSHOPTS"] = ssh_arg
generate_facts([machine]) generate_facts([machine], None, False)
upload_secrets(machine) upload_secrets(machine)
path = upload_sources(".", target) path = upload_sources(".", target)

View File

@@ -4,7 +4,7 @@ import tempfile
from pathlib import Path from pathlib import Path
from typing import Any from typing import Any
from .cmd import run from .cmd import run_no_stdout
from .dirs import nixpkgs_flake, nixpkgs_source from .dirs import nixpkgs_flake, nixpkgs_source
@@ -55,12 +55,12 @@ def nix_build(flags: list[str], gcroot: Path | None = None) -> list[str]:
def nix_add_to_gcroots(nix_path: Path, dest: Path) -> None: def nix_add_to_gcroots(nix_path: Path, dest: Path) -> None:
cmd = ["nix-store", "--realise", f"{nix_path}", "--add-root", f"{dest}"] cmd = ["nix-store", "--realise", f"{nix_path}", "--add-root", f"{dest}"]
run(cmd) run_no_stdout(cmd)
def nix_config() -> dict[str, Any]: def nix_config() -> dict[str, Any]:
cmd = nix_command(["show-config", "--json"]) cmd = nix_command(["show-config", "--json"])
proc = run(cmd) proc = run_no_stdout(cmd)
data = json.loads(proc.stdout) data = json.loads(proc.stdout)
config = {} config = {}
for key, value in data.items(): for key, value in data.items():
@@ -95,7 +95,7 @@ def nix_eval(flags: list[str]) -> list[str]:
def nix_metadata(flake_url: str | Path) -> dict[str, Any]: def nix_metadata(flake_url: str | Path) -> dict[str, Any]:
cmd = nix_command(["flake", "metadata", "--json", f"{flake_url}"]) cmd = nix_command(["flake", "metadata", "--json", f"{flake_url}"])
proc = run(cmd) proc = run_no_stdout(cmd)
data = json.loads(proc.stdout) data = json.loads(proc.stdout)
return data return data
@@ -106,7 +106,7 @@ def nix_shell(packages: list[str], cmd: list[str]) -> list[str]:
if os.environ.get("IN_NIX_SANDBOX"): if os.environ.get("IN_NIX_SANDBOX"):
return cmd return cmd
return [ return [
*nix_command(["shell", "--offline", "--inputs-from", f"{nixpkgs_flake()!s}"]), *nix_command(["shell", "--inputs-from", f"{nixpkgs_flake()!s}"]),
*packages, *packages,
"-c", "-c",
*cmd, *cmd,

View File

@@ -69,7 +69,7 @@ def get_secrets(
secret_facts_module = importlib.import_module(machine.secret_facts_module) secret_facts_module = importlib.import_module(machine.secret_facts_module)
secret_facts_store = secret_facts_module.SecretStore(machine=machine) secret_facts_store = secret_facts_module.SecretStore(machine=machine)
generate_facts([machine]) generate_facts([machine], None, False)
secret_facts_store.upload(secrets_dir) secret_facts_store.upload(secrets_dir)
return secrets_dir return secrets_dir

View File

@@ -83,7 +83,7 @@ let
cp -r ${./.} $out cp -r ${./.} $out
chmod -R +w $out chmod -R +w $out
rm $out/clan_cli/config/jsonschema rm $out/clan_cli/config/jsonschema
ln -s ${nixpkgs'} $out/clan_cli/nixpkgs ln -sf ${nixpkgs'} $out/clan_cli/nixpkgs
cp -r ${../../lib/jsonschema} $out/clan_cli/config/jsonschema cp -r ${../../lib/jsonschema} $out/clan_cli/config/jsonschema
''; '';
@@ -101,7 +101,7 @@ let
outputs = _inputs: { }; outputs = _inputs: { };
} }
EOF EOF
ln -s ${nixpkgs} $out/path ln -sf ${nixpkgs} $out/path
nix flake update $out \ nix flake update $out \
--store ./. \ --store ./. \
--extra-experimental-features 'nix-command flakes' --extra-experimental-features 'nix-command flakes'

View File

@@ -26,6 +26,14 @@ def test_create_flake(
cli.run(["machines", "create", "machine1"]) cli.run(["machines", "create", "machine1"])
capsys.readouterr() # flush cache capsys.readouterr() # flush cache
# create a hardware-configuration.nix that doesn't throw an eval error
for patch_machine in ["jon", "sara"]:
with open(
flake_dir / "machines" / f"{patch_machine}/hardware-configuration.nix", "w"
) as hw_config_nix:
hw_config_nix.write("{}")
cli.run(["machines", "list"]) cli.run(["machines", "list"])
assert "machine1" in capsys.readouterr().out assert "machine1" in capsys.readouterr().out
flake_show = subprocess.run( flake_show = subprocess.run(

View File

@@ -16,7 +16,10 @@ def test_machine_subcommands(
cli.run(["--flake", str(test_flake_with_core.path), "machines", "list"]) cli.run(["--flake", str(test_flake_with_core.path), "machines", "list"])
out = capsys.readouterr() out = capsys.readouterr()
assert "machine1\nvm1\nvm2\n" == out.out
assert "machine1" in out.out
assert "vm1" in out.out
assert "vm2" in out.out
cli.run( cli.run(
["--flake", str(test_flake_with_core.path), "machines", "delete", "machine1"] ["--flake", str(test_flake_with_core.path), "machines", "delete", "machine1"]
@@ -25,4 +28,7 @@ def test_machine_subcommands(
capsys.readouterr() capsys.readouterr()
cli.run(["--flake", str(test_flake_with_core.path), "machines", "list"]) cli.run(["--flake", str(test_flake_with_core.path), "machines", "list"])
out = capsys.readouterr() out = capsys.readouterr()
assert "vm1\nvm2\n" == out.out
assert "machine1" not in out.out
assert "vm1" in out.out
assert "vm2" in out.out

View File

@@ -106,7 +106,7 @@ class MainApplication(Adw.Application):
def on_activate(self, source: "MainApplication") -> None: def on_activate(self, source: "MainApplication") -> None:
if not self.window: if not self.window:
self.init_style() self.init_style()
self.window = MainWindow(config=ClanConfig(initial_view="webview")) self.window = MainWindow(config=ClanConfig(initial_view="list"))
self.window.set_application(self) self.window.set_application(self)
self.window.show() self.window.show()

View File

@@ -1,3 +1,4 @@
import dataclasses
import json import json
import logging import logging
import sys import sys
@@ -8,6 +9,7 @@ from threading import Lock
from typing import Any from typing import Any
import gi import gi
from clan_cli.api import API
gi.require_version("WebKit", "6.0") gi.require_version("WebKit", "6.0")
@@ -22,6 +24,23 @@ site_index: Path = (
log = logging.getLogger(__name__) log = logging.getLogger(__name__)
def dataclass_to_dict(obj: Any) -> Any:
"""
Utility function to convert dataclasses to dictionaries
It converts all nested dataclasses, lists, tuples, and dictionaries to dictionaries
It does NOT convert member functions.
"""
if dataclasses.is_dataclass(obj):
return {k: dataclass_to_dict(v) for k, v in dataclasses.asdict(obj).items()}
elif isinstance(obj, list | tuple):
return [dataclass_to_dict(item) for item in obj]
elif isinstance(obj, dict):
return {k: dataclass_to_dict(v) for k, v in obj.items()}
else:
return obj
class WebView: class WebView:
def __init__(self, methods: dict[str, Callable]) -> None: def __init__(self, methods: dict[str, Callable]) -> None:
self.method_registry: dict[str, Callable] = methods self.method_registry: dict[str, Callable] = methods
@@ -77,12 +96,35 @@ class WebView:
self.queue_size += 1 self.queue_size += 1
def threaded_handler( def threaded_handler(
self, handler_fn: Callable[[Any], Any], data: Any, method_name: str self,
handler_fn: Callable[
...,
Any,
],
data: dict[str, Any] | None,
method_name: str,
) -> None: ) -> None:
with self.mutex_lock: with self.mutex_lock:
log.debug("Executing... ", method_name) log.debug("Executing... ", method_name)
result = handler_fn(data) log.debug(f"{data}")
serialized = json.dumps(result) if data is None:
result = handler_fn()
else:
reconciled_arguments = {}
for k, v in data.items():
# Some functions expect to be called with dataclass instances
# But the js api returns dictionaries.
# Introspect the function and create the expected dataclass from dict dynamically
# Depending on the introspected argument_type
arg_type = API.get_method_argtype(method_name, k)
if dataclasses.is_dataclass(arg_type):
reconciled_arguments[k] = arg_type(**v)
else:
reconciled_arguments[k] = v
result = handler_fn(**reconciled_arguments)
serialized = json.dumps(dataclass_to_dict(result))
# Use idle_add to queue the response call to js on the main GTK thread # Use idle_add to queue the response call to js on the main GTK thread
GLib.idle_add(self.return_data_to_js, method_name, serialized) GLib.idle_add(self.return_data_to_js, method_name, serialized)

View File

@@ -26,7 +26,7 @@ log = logging.getLogger(__name__)
class MainWindow(Adw.ApplicationWindow): class MainWindow(Adw.ApplicationWindow):
def __init__(self, config: ClanConfig) -> None: def __init__(self, config: ClanConfig) -> None:
super().__init__() super().__init__()
self.set_title("cLAN Manager") self.set_title("Clan Manager")
self.set_default_size(980, 850) self.set_default_size(980, 850)
overlay = ToastOverlay.use().overlay overlay = ToastOverlay.use().overlay
@@ -62,8 +62,7 @@ class MainWindow(Adw.ApplicationWindow):
stack_view.add_named(Logs(), "logs") stack_view.add_named(Logs(), "logs")
webview = WebView(methods=API._registry) webview = WebView(methods=API._registry)
stack_view.add_named(webview.get_webview(), "webview")
stack_view.add_named(webview.get_webview(), "list")
stack_view.set_visible_child_name(config.initial_view) stack_view.set_visible_child_name(config.initial_view)

View File

@@ -27,7 +27,7 @@ let
name = "org.clan.vm-manager"; name = "org.clan.vm-manager";
exec = "clan-vm-manager %u"; exec = "clan-vm-manager %u";
icon = ./clan_vm_manager/assets/clan_white.png; icon = ./clan_vm_manager/assets/clan_white.png;
desktopName = "cLAN Manager"; desktopName = "Clan Manager";
startupWMClass = "clan"; startupWMClass = "clan";
mimeTypes = [ "x-scheme-handler/clan" ]; mimeTypes = [ "x-scheme-handler/clan" ];
}; };

View File

@@ -0,0 +1,40 @@
{
lib,
coreutils,
nil,
nixd,
nixpkgs-fmt,
direnv,
vscode-extensions,
vscode-with-extensions,
vscodium,
writeShellApplication,
}:
let
codium = vscode-with-extensions.override {
vscode = vscodium;
vscodeExtensions = [
vscode-extensions.jnoortheen.nix-ide
vscode-extensions.mkhl.direnv
];
};
in
writeShellApplication {
name = "clan-edit-codium";
runtimeInputs = [
coreutils
nil
nixd
nixpkgs-fmt
direnv
];
text = ''
set -eux
DATA_DIR="''${XDG_CACHE_HOME:-$HOME/.cache}/clan-edit-codium"
SETTINGS="$DATA_DIR"/User/settings.json
${coreutils}/bin/mkdir -p "$DATA_DIR/User"
cat ${./settings.json} > "$SETTINGS"
exec ${lib.getExe codium} --user-data-dir "$DATA_DIR" "$@"
'';
}

4
pkgs/editor/default.nix Normal file
View File

@@ -0,0 +1,4 @@
{ pkgs }:
{
clan-edit-codium = pkgs.callPackage ./clan-edit-codium.nix;
}

21
pkgs/editor/settings.json Normal file
View File

@@ -0,0 +1,21 @@
{
"security.workspace.trust.enabled": false,
"nix.enableLanguageServer": true,
"nix.serverPath": "nixd",
"nix.formatterPath": "nixpkgs-fmt",
"nix.serverSettings": {
"nixd": {
"formatting": {
"command": "nixpkgs-fmt"
},
"options": {
"nixos": {
"expr": "(builtins.getFlake \"github:nixos/nixpkgs\").nixosConfigurations.<name>.options"
},
"home-manager": {
"expr": "(builtins.getFlake \"github:nix-community/home-manager\").homeConfigurations.<name>.options"
}
}
}
}
}

View File

@@ -25,6 +25,7 @@
moonlight-sunshine-accept = pkgs.callPackage ./moonlight-sunshine-accept { }; moonlight-sunshine-accept = pkgs.callPackage ./moonlight-sunshine-accept { };
merge-after-ci = pkgs.callPackage ./merge-after-ci { inherit (config.packages) tea-create-pr; }; merge-after-ci = pkgs.callPackage ./merge-after-ci { inherit (config.packages) tea-create-pr; };
pending-reviews = pkgs.callPackage ./pending-reviews { }; pending-reviews = pkgs.callPackage ./pending-reviews { };
editor = pkgs.callPackage ./editor/clan-edit-codium.nix { };
} }
// lib.optionalAttrs pkgs.stdenv.isLinux { // lib.optionalAttrs pkgs.stdenv.isLinux {
wayland-proxy-virtwl = pkgs.callPackage ./wayland-proxy-virtwl { }; wayland-proxy-virtwl = pkgs.callPackage ./wayland-proxy-virtwl { };

5
pkgs/webview-ui/.vscode/settings.json vendored Normal file
View File

@@ -0,0 +1,5 @@
{
"typescript.tsdk": "node_modules/typescript/lib",
"tailwindCSS.experimental.classRegex": [["cx\\(([^)]*)\\)", "[\"'`]([^\"'`]*)[\"'`]"]],
"editor.wordWrap": "on"
}

View File

@@ -0,0 +1,26 @@
import eslint from "@eslint/js";
import tseslint from "typescript-eslint";
import tailwind from "eslint-plugin-tailwindcss";
export default tseslint.config(
eslint.configs.recommended,
...tseslint.configs.strict,
...tseslint.configs.stylistic,
...tailwind.configs["flat/recommended"],
{
rules: {
"tailwindcss/no-contradicting-classname": [
"error",
{
callees: ["cx"],
},
],
"tailwindcss/no-custom-classname": [
"error",
{
callees: ["cx"],
},
],
},
}
);

File diff suppressed because it is too large Load Diff

View File

@@ -8,22 +8,29 @@
"build": "npm run check && vite build && npm run convert-html", "build": "npm run check && vite build && npm run convert-html",
"convert-html": "node gtk.webview.js", "convert-html": "node gtk.webview.js",
"serve": "vite preview", "serve": "vite preview",
"check": "tsc --noEmit --skipLibCheck" "check": "tsc --noEmit --skipLibCheck && eslint ./src"
}, },
"license": "MIT", "license": "MIT",
"devDependencies": { "devDependencies": {
"@eslint/js": "^9.3.0",
"@types/node": "^20.12.12",
"@typescript-eslint/parser": "^7.10.0",
"autoprefixer": "^10.4.19", "autoprefixer": "^10.4.19",
"classnames": "^2.5.1",
"daisyui": "^4.11.1", "daisyui": "^4.11.1",
"eslint": "^8.57.0",
"json-schema-to-ts": "^3.1.0",
"postcss": "^8.4.38", "postcss": "^8.4.38",
"prettier": "^3.2.5",
"solid-devtools": "^0.29.2", "solid-devtools": "^0.29.2",
"tailwindcss": "^3.4.3", "tailwindcss": "^3.4.3",
"typescript": "^5.3.3", "typescript": "^5.4.5",
"typescript-eslint": "^7.10.0",
"vite": "^5.0.11", "vite": "^5.0.11",
"vite-plugin-solid": "^2.8.2" "vite-plugin-solid": "^2.8.2",
"eslint-plugin-tailwindcss": "^3.17.0"
}, },
"dependencies": { "dependencies": {
"@types/node": "^20.12.12",
"json-schema-to-ts": "^3.1.0",
"solid-js": "^1.8.11" "solid-js": "^1.8.11"
} }
} }

View File

@@ -1,34 +1,60 @@
import { Match, Switch, createSignal, type Component } from "solid-js"; import { Match, Switch, createSignal, type Component } from "solid-js";
import { CountProvider } from "./Config"; import { CountProvider } from "./Config";
// import { Nested } from "./nested";
import { Layout } from "./layout/layout";
import cx from "classnames";
import { Nested } from "./nested"; import { Nested } from "./nested";
type Route = "home" | "graph"; type Route = "home" | "machines";
const App: Component = () => { const App: Component = () => {
const [route, setRoute] = createSignal<Route>("home"); const [route, setRoute] = createSignal<Route>("home");
return ( return (
<CountProvider> <CountProvider>
<div class="w-full flex items-center flex-col gap-2 my-2"> <Layout>
<div>Clan</div> <div class="col-span-1">
<p>Current route: {route()}</p> <div class={cx("text-zinc-500")}>Navigation</div>
<ul>
<div class="flex items-center"> <li>
<button <button
onClick={() => setRoute((o) => (o === "graph" ? "home" : "graph"))} onClick={() => setRoute("home")}
class="btn btn-link" classList={{ "bg-blue-500": route() === "home" }}
> >
Navigate to {route() === "home" ? "graph" : "home"} Home
</button> </button>
</li>
<li>
{" "}
<button
onClick={() => setRoute("machines")}
classList={{ "bg-blue-500": route() === "machines" }}
>
Machines
</button>
</li>
</ul>
</div> </div>
<div class="col-span-7">
<div>{route()}</div>
<Switch fallback={<p>{route()} not found</p>}> <Switch fallback={<p>{route()} not found</p>}>
<Match when={route() == "home"}> <Match when={route() == "home"}>
<Nested /> <Nested />
</Match> </Match>
<Match when={route() == "graph"}> <Match when={route() == "machines"}>
<p></p> <div class="grid grid-cols-3 gap-2">
<div class="h-10 w-20 bg-red-500">red</div>
<div class="h-10 w-20 bg-green-500">green</div>
<div class="h-10 w-20 bg-blue-500">blue</div>
<div class="h-10 w-20 bg-yellow-500">yellow</div>
<div class="h-10 w-20 bg-purple-500">purple</div>
<div class="h-10 w-20 bg-cyan-500">cyan</div>
<div class="h-10 w-20 bg-pink-500">pink</div>
</div>
</Match> </Match>
</Switch> </Switch>
</div> </div>
</Layout>
</CountProvider> </CountProvider>
); );
}; };

View File

@@ -1,8 +1,16 @@
import { createSignal, createContext, useContext, JSXElement } from "solid-js"; import {
import { pyApi } from "./message"; createSignal,
createContext,
useContext,
JSXElement,
createEffect,
} from "solid-js";
import { OperationResponse, pyApi } from "./message";
export const makeCountContext = () => { export const makeCountContext = () => {
const [machines, setMachines] = createSignal<string[]>([]); const [machines, setMachines] = createSignal<
OperationResponse<"list_machines">
>({});
const [loading, setLoading] = createSignal(false); const [loading, setLoading] = createSignal(false);
pyApi.list_machines.receive((machines) => { pyApi.list_machines.receive((machines) => {
@@ -10,13 +18,17 @@ export const makeCountContext = () => {
setMachines(machines); setMachines(machines);
}); });
createEffect(() => {
console.log("The count is now", machines());
});
return [ return [
{ loading, machines }, { loading, machines },
{ {
getMachines: () => { getMachines: () => {
// When the gtk function sends its data the loading state will be set to false // When the gtk function sends its data the loading state will be set to false
setLoading(true); setLoading(true);
pyApi.list_machines.dispatch("."); pyApi.list_machines.dispatch({ debug: true, flake_url: "." });
}, },
}, },
] as const; ] as const;
@@ -25,8 +37,14 @@ export const makeCountContext = () => {
type CountContextType = ReturnType<typeof makeCountContext>; type CountContextType = ReturnType<typeof makeCountContext>;
export const CountContext = createContext<CountContextType>([ export const CountContext = createContext<CountContextType>([
{ loading: () => false, machines: () => [] },
{ {
loading: () => false,
// eslint-disable-next-line
machines: () => ({}),
},
{
// eslint-disable-next-line
getMachines: () => {}, getMachines: () => {},
}, },
]); ]);

View File

@@ -6,7 +6,6 @@ import App from "./App";
const root = document.getElementById("app"); const root = document.getElementById("app");
// @ts-ignore: add the clan scope to the window object so we can register callbacks for gtk
window.clan = window.clan || {}; window.clan = window.clan || {};
if (import.meta.env.DEV && !(root instanceof HTMLElement)) { if (import.meta.env.DEV && !(root instanceof HTMLElement)) {
@@ -15,4 +14,5 @@ if (import.meta.env.DEV && !(root instanceof HTMLElement)) {
); );
} }
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
render(() => <App />, root!); render(() => <App />, root!);

View File

@@ -0,0 +1,9 @@
import { Component, JSXElement } from "solid-js";
interface LayoutProps {
children: JSXElement;
}
export const Layout: Component<LayoutProps> = (props) => {
return <div class="grid grid-cols-8">{props.children}</div>;
};

View File

@@ -1,11 +1,11 @@
import { FromSchema } from "json-schema-to-ts"; import { FromSchema } from "json-schema-to-ts";
import { schema } from "@/api"; import { schema } from "@/api";
type API = FromSchema<typeof schema>; export type API = FromSchema<typeof schema>;
type OperationNames = keyof API; export type OperationNames = keyof API;
type OperationArgs<T extends OperationNames> = API[T]["argument"]; export type OperationArgs<T extends OperationNames> = API[T]["arguments"];
type OperationResponse<T extends OperationNames> = API[T]["return"]; export type OperationResponse<T extends OperationNames> = API[T]["return"];
declare global { declare global {
interface Window { interface Window {
@@ -15,7 +15,10 @@ declare global {
webkit: { webkit: {
messageHandlers: { messageHandlers: {
gtk: { gtk: {
postMessage: (message: { method: OperationNames; data: any }) => void; postMessage: (message: {
method: OperationNames;
data: OperationArgs<OperationNames>;
}) => void;
}; };
}; };
}; };
@@ -31,7 +34,7 @@ function createFunctions<K extends OperationNames>(
return { return {
dispatch: (args: OperationArgs<K>) => { dispatch: (args: OperationArgs<K>) => {
console.log( console.log(
`Operation: ${operationName}, Arguments: ${JSON.stringify(args)}` `Operation: ${String(operationName)}, Arguments: ${JSON.stringify(args)}`
); );
// Send the data to the gtk app // Send the data to the gtk app
window.webkit.messageHandlers.gtk.postMessage({ window.webkit.messageHandlers.gtk.postMessage({
@@ -40,7 +43,7 @@ function createFunctions<K extends OperationNames>(
}); });
}, },
receive: (fn: (response: OperationResponse<K>) => void) => { receive: (fn: (response: OperationResponse<K>) => void) => {
window.clan.list_machines = deserialize(fn); window.clan[operationName] = deserialize(fn);
}, },
}; };
} }
@@ -59,6 +62,7 @@ const deserialize =
<T>(fn: (response: T) => void) => <T>(fn: (response: T) => void) =>
(str: string) => { (str: string) => {
try { try {
console.debug("Received data: ", str);
fn(JSON.parse(str) as T); fn(JSON.parse(str) as T);
} catch (e) { } catch (e) {
alert(`Error parsing JSON: ${e}`); alert(`Error parsing JSON: ${e}`);
@@ -68,7 +72,10 @@ const deserialize =
// Create the API object // Create the API object
const pyApi: PyApi = {} as PyApi; const pyApi: PyApi = {} as PyApi;
operationNames.forEach((name) => { operationNames.forEach((opName) => {
const name = opName as OperationNames;
// @ts-expect-error - TODO: Fix this. Typescript is not recognizing the receive function correctly
pyApi[name] = createFunctions(name); pyApi[name] = createFunctions(name);
}); });
export { pyApi }; export { pyApi };

View File

@@ -1,24 +1,34 @@
import { For, Match, Switch, type Component } from "solid-js"; import { For, Match, Switch, createEffect, type Component } from "solid-js";
import { useCountContext } from "./Config"; import { useCountContext } from "./Config";
export const Nested: Component = () => { export const Nested: Component = () => {
const [{ machines, loading }, { getMachines }] = useCountContext(); const [{ machines, loading }, { getMachines }] = useCountContext();
const list = () => Object.values(machines());
createEffect(() => {
console.log("1", list());
});
createEffect(() => {
console.log("2", machines());
});
return ( return (
<div> <div>
<button onClick={() => getMachines()} class="btn btn-primary"> <button onClick={() => getMachines()} class="btn btn-primary">
Get machines Get machines
</button> </button>
<hr /> <div></div>
<Switch> <Switch>
<Match when={loading()}>Loading...</Match> <Match when={loading()}>Loading...</Match>
<Match when={!loading() && machines().length === 0}> <Match when={!loading() && Object.entries(machines()).length === 0}>
No machines found No machines found
</Match> </Match>
<Match when={!loading() && machines().length}> <Match when={!loading()}>
<For each={machines()}> <For each={list()}>
{(machine, i) => ( {(entry, i) => (
<li> <li>
{i() + 1}: {machine} {i() + 1}: {entry.machine_name}{" "}
{entry.machine_description || "No description"}
</li> </li>
)} )}
</For> </For>

View File

@@ -1,32 +0,0 @@
{
dream2nix,
config,
src,
...
}:
{
imports = [ dream2nix.modules.dream2nix.WIP-nodejs-builder-v3 ];
mkDerivation = {
inherit src;
};
deps =
{ nixpkgs, ... }:
{
inherit (nixpkgs) stdenv;
};
WIP-nodejs-builder-v3 = {
packageLockFile = "${config.mkDerivation.src}/package-lock.json";
# config.groups.all.packages.${config.name}.${config.version}.
};
public.out = {
checkPhase = ''
echo "Running tests"
echo "Tests passed"
'';
};
name = "@clan/webview-ui";
version = "0.0.1";
}

View File

@@ -9,10 +9,9 @@
src = ./app; src = ./app;
# npmDepsHash = "sha256-bRD2vzijhdOOvcEi6XaG/neSqhkVQMqIX/8bxvRQkTc=";
npmDeps = pkgs.fetchNpmDeps { npmDeps = pkgs.fetchNpmDeps {
src = ./app; src = ./app;
hash = "sha256-bRD2vzijhdOOvcEi6XaG/neSqhkVQMqIX/8bxvRQkTc="; hash = "sha256-E0++hupVKnDqmLk7ljoMcqcI4w+DIMlfRYRPbKUsT2c=";
}; };
# The prepack script runs the build script, which we'd rather do in the build phase. # The prepack script runs the build script, which we'd rather do in the build phase.
npmPackFlags = [ "--ignore-scripts" ]; npmPackFlags = [ "--ignore-scripts" ];

28
scripts/pre-commit Executable file
View File

@@ -0,0 +1,28 @@
#!/usr/bin/env bash
# To install:
# ln -sf ../../scripts/pre-commit .git/hooks/pre-commit
set -o errexit
set -o nounset
set -o pipefail
readarray staged < <(git diff --name-only --cached)
[[ ${#staged[@]} = 0 ]] && exit
unstash() {
local ret=$?
set +e
git stash pop -q
exit "$ret"
}
git stash push --quiet --keep-index --message "pre-commit"
trap unstash EXIT
nix fmt
{
changed=$(git diff --name-only --exit-code);
status=$?;
} || true
if [[ $status -ne 0 ]]; then
exec 1>&2
echo Files changed by pre-commit hook:
echo "$changed"
exit $status
fi

View File

@@ -85,9 +85,9 @@
}; };
in in
{ {
# all machines managed by cLAN # all machines managed by Clan
inherit (clan) nixosConfigurations clanInternals; inherit (clan) nixosConfigurations clanInternals;
# add the cLAN cli tool to the dev shell # add the Clan cli tool to the dev shell
devShells.${system}.default = pkgs.mkShell { devShells.${system}.default = pkgs.mkShell {
packages = [ clan-core.packages.${system}.clan-cli ]; packages = [ clan-core.packages.${system}.clan-cli ];
}; };