Compare commits

..

1 Commits

Author SHA1 Message Date
pinpox
3ac9167f18 expose metrics as json 2025-08-20 11:04:01 +02:00
458 changed files with 5941 additions and 11144 deletions

View File

@@ -10,7 +10,7 @@ jobs:
if: github.repository_owner == 'clan-lol'
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v5
- uses: actions/checkout@v4
with:
persist-credentials: false
- uses: actions/create-github-app-token@v2

View File

@@ -8,7 +8,7 @@ Our mission is simple: to democratize computing by providing tools that empower
## Features of Clan
- **Full-Stack System Deployment:** Utilize Clan's toolkit alongside Nix's reliability to build and manage systems effortlessly.
- **Full-Stack System Deployment:** Utilize Clans toolkit alongside Nix's reliability to build and manage systems effortlessly.
- **Overlay Networks:** Secure, private communication channels between devices.
- **Virtual Machine Integration:** Seamless operation of VM applications within the main operating system.
- **Robust Backup Management:** Long-term, self-hosted data preservation.

View File

@@ -55,8 +55,7 @@
closureInfo = pkgs.closureInfo { rootPaths = dependencies; };
in
{
# Skip flash test on aarch64-linux for now as it's too slow
checks = lib.optionalAttrs (pkgs.stdenv.isLinux && pkgs.hostPlatform.system != "aarch64-linux") {
checks = pkgs.lib.mkIf pkgs.stdenv.isLinux {
nixos-test-flash = self.clanLib.test.baseTest {
name = "flash";
nodes.target = {

View File

@@ -232,7 +232,6 @@
"-i", ssh_conn.ssh_key,
"--option", "store", os.environ['CLAN_TEST_STORE'],
"--update-hardware-config", "nixos-facter",
"--no-persist-state",
]
subprocess.run(clan_cmd, check=True)
@@ -276,7 +275,7 @@
"${self.checks.x86_64-linux.clan-core-for-checks}",
"${closureInfo}"
)
# Set up SSH connection
ssh_conn = setup_ssh_connection(
target,
@@ -302,8 +301,7 @@
"test-install-machine-without-system",
"-i", ssh_conn.ssh_key,
"--option", "store", os.environ['CLAN_TEST_STORE'],
"--target-host", f"nonrootuser@localhost:{ssh_conn.host_port}",
"--yes"
f"nonrootuser@localhost:{ssh_conn.host_port}"
]
result = subprocess.run(clan_cmd, capture_output=True, cwd=flake_dir)
@@ -327,9 +325,7 @@
"test-install-machine-without-system",
"-i", ssh_conn.ssh_key,
"--option", "store", os.environ['CLAN_TEST_STORE'],
"--target-host",
f"nonrootuser@localhost:{ssh_conn.host_port}",
"--yes"
f"nonrootuser@localhost:{ssh_conn.host_port}"
]
result = subprocess.run(clan_cmd, capture_output=True, cwd=flake_dir)

View File

@@ -1,68 +0,0 @@
This module enables hosting clan-internal services easily, which can be resolved
inside your VPN. This allows defining a custom top-level domain (e.g. `.clan`)
and exposing endpoints from a machine to others, which will be
accessible under `http://<service>.clan` in your browser.
The service consists of two roles:
- A `server` role: This is the DNS-server that will be queried when trying to
resolve clan-internal services. It defines the top-level domain.
- A `default` role: This does two things. First, it sets up the nameservers so
thatclan-internal queries are resolved via the `server` machine, while
external queries are resolved as normal via DHCP. Second, it allows exposing
services (see example below).
## Example Usage
Here the machine `dnsserver` is designated as internal DNS-server for the TLD
`.foo`. `server01` will host an application that shall be reachable at
`http://one.foo` and `server02` is going to be reachable at `http://two.foo`.
`client` is any other machine that is part of the clan but does not host any
services.
When `client` tries to resolve `http://one.foo`, the DNS query will be
routed to `dnsserver`, which will answer with `192.168.1.3`. If it tries to
resolve some external domain (e.g. `https://clan.lol`), the query will not be
routed to `dnsserver` but resolved as before, via the nameservers advertised by
DHCP.
```nix
inventory = {
machines = {
dnsserver = { }; # 192.168.1.2
server01 = { }; # 192.168.1.3
server02 = { }; # 192.168.1.4
client = { }; # 192.168.1.5
};
instances = {
coredns = {
module.name = "@clan/coredns";
module.input = "self";
# Add the default role to all machines, including `client`
roles.default.tags.all = { };
# DNS server
roles.server.machines."dnsserver".settings = {
ip = "192.168.1.2";
tld = "foo";
};
# First service
roles.default.machines."server01".settings = {
ip = "192.168.1.3";
services = [ "one" ];
};
# Second service
roles.default.machines."server02".settings = {
ip = "192.168.1.4";
services = [ "two" ];
};
};
};
};
```

View File

@@ -1,157 +0,0 @@
{ ... }:
{
_class = "clan.service";
manifest.name = "coredns";
manifest.description = "Clan-internal DNS and service exposure";
manifest.categories = [ "Network" ];
manifest.readme = builtins.readFile ./README.md;
roles.server = {
interface =
{ lib, ... }:
{
options.tld = lib.mkOption {
type = lib.types.str;
default = "clan";
description = ''
Top-level domain for this instance. All services below this will be
resolved internally.
'';
};
options.ip = lib.mkOption {
type = lib.types.str;
# TODO: Set a default
description = "IP for the DNS to listen on";
};
};
perInstance =
{
roles,
settings,
...
}:
{
nixosModule =
{
lib,
pkgs,
...
}:
{
networking.firewall.allowedTCPPorts = [ 53 ];
networking.firewall.allowedUDPPorts = [ 53 ];
services.coredns =
let
# Get all service entries for one host
hostServiceEntries =
host:
lib.strings.concatStringsSep "\n" (
map (
service: "${service} IN A ${roles.default.machines.${host}.settings.ip} ; ${host}"
) roles.default.machines.${host}.settings.services
);
zonefile = pkgs.writeTextFile {
name = "db.${settings.tld}";
text = ''
$TTL 3600
@ IN SOA ns.${settings.tld}. admin.${settings.tld}. 1 7200 3600 1209600 3600
IN NS ns.${settings.tld}.
ns IN A ${settings.ip} ; DNS server
''
+ (lib.strings.concatStringsSep "\n" (
map (host: hostServiceEntries host) (lib.attrNames roles.default.machines)
));
};
in
{
enable = true;
config = ''
. {
forward . 1.1.1.1
cache 30
}
${settings.tld} {
file ${zonefile}
}
'';
};
};
};
};
roles.default = {
interface =
{ lib, ... }:
{
options.services = lib.mkOption {
type = lib.types.listOf lib.types.str;
default = [ ];
description = ''
Service endpoints this host exposes (without TLD). Each entry will
be resolved to <entry>.<tld> using the configured top-level domain.
'';
};
options.ip = lib.mkOption {
type = lib.types.str;
# TODO: Set a default
description = "IP on which the services will listen";
};
};
perInstance =
{ roles, ... }:
{
nixosModule =
{ lib, ... }:
{
networking.nameservers = map (m: "127.0.0.1:5353#${roles.server.machines.${m}.settings.tld}") (
lib.attrNames roles.server.machines
);
services.resolved.domains = map (m: "~${roles.server.machines.${m}.settings.tld}") (
lib.attrNames roles.server.machines
);
services.unbound = {
enable = true;
settings = {
server = {
port = 5353;
verbosity = 2;
interface = [ "127.0.0.1" ];
access-control = [ "127.0.0.0/8 allow" ];
do-not-query-localhost = "no";
domain-insecure = map (m: "${roles.server.machines.${m}.settings.tld}.") (
lib.attrNames roles.server.machines
);
};
# Default: forward everything else to DHCP-provided resolvers
forward-zone = [
{
name = ".";
forward-addr = "127.0.0.53@53"; # Forward to systemd-resolved
}
];
stub-zone = map (m: {
name = "${roles.server.machines.${m}.settings.tld}.";
stub-addr = "${roles.server.machines.${m}.settings.ip}";
}) (lib.attrNames roles.server.machines);
};
};
};
};
};
}

View File

@@ -1,113 +0,0 @@
{
...
}:
{
name = "coredns";
clan = {
directory = ./.;
test.useContainers = true;
inventory = {
machines = {
dns = { }; # 192.168.1.2
server01 = { }; # 192.168.1.3
server02 = { }; # 192.168.1.4
client = { }; # 192.168.1.1
};
instances = {
coredns = {
module.name = "@clan/coredns";
module.input = "self";
roles.default.tags.all = { };
# First service
roles.default.machines."server01".settings = {
ip = "192.168.1.3";
services = [ "one" ];
};
# Second service
roles.default.machines."server02".settings = {
ip = "192.168.1.4";
services = [ "two" ];
};
# DNS server
roles.server.machines."dns".settings = {
ip = "192.168.1.2";
tld = "foo";
};
};
};
};
};
nodes = {
dns =
{ pkgs, ... }:
{
environment.systemPackages = [ pkgs.net-tools ];
};
client =
{ pkgs, ... }:
{
environment.systemPackages = [ pkgs.net-tools ];
};
server01 = {
services.nginx = {
enable = true;
virtualHosts."one.foo" = {
locations."/" = {
return = "200 'test server response one'";
extraConfig = "add_header Content-Type text/plain;";
};
};
};
};
server02 = {
services.nginx = {
enable = true;
virtualHosts."two.foo" = {
locations."/" = {
return = "200 'test server response two'";
extraConfig = "add_header Content-Type text/plain;";
};
};
};
};
};
testScript = ''
import json
start_all()
machines = [server01, server02, dns, client]
for m in machines:
m.systemctl("start network-online.target")
for m in machines:
m.wait_for_unit("network-online.target")
# import time
# time.sleep(2333333)
# This should work, but is borken in tests i think? Instead we dig directly
# client.succeed("curl -k -v http://one.foo")
# client.succeed("curl -k -v http://two.foo")
answer = client.succeed("dig @192.168.1.2 one.foo")
assert "192.168.1.3" in answer, "IP not found"
answer = client.succeed("dig @192.168.1.2 two.foo")
assert "192.168.1.4" in answer, "IP not found"
'';
}

View File

@@ -11,8 +11,7 @@
...
}:
let
jsonpath = "/tmp/telegraf.json";
auth_user = "prometheus";
jsonpath = /tmp/telegraf.json;
in
{
@@ -20,24 +19,18 @@
builtins.listToAttrs (
map (name: {
inherit name;
value.allowedTCPPorts = [
9273
9990
];
value.allowedTCPPorts = [ 9273 9990 ];
}) settings.interfaces
)
);
networking.firewall.allowedTCPPorts = lib.mkIf (settings.allowAllInterfaces == true) [
9273
9990
];
systemd.services.telegsaf-json.script = "${pkgs.miniserve}/bin/miniserve -p 9990 ${jsonpath}";
clan.core.vars.generators."telegraf" = {
networking.firewall.allowedTCPPorts = lib.mkIf (settings.allowAllInterfaces == true) [ 9273 ];
files.password.restartUnits = [ "telegraf.service" ];
files.password-env.restartUnits = [ "telegraf.service" ];
files.miniserve-auth.restartUnits = [ "telegraf.service" ];
clan.core.vars.generators."telegraf-password" = {
files.telegraf-password.neededFor = "users";
files.telegraf-password.restartUnits = [ "telegraf.service" ];
runtimeInputs = [
pkgs.coreutils
@@ -47,22 +40,16 @@
script = ''
PASSWORD=$(xkcdpass --numwords 4 --delimiter - --count 1 | tr -d "\n")
echo "BASIC_AUTH_PWD=$PASSWORD" > "$out"/password-env
echo "${auth_user}:$PASSWORD" > "$out"/miniserve-auth
echo "$PASSWORD" | tr -d "\n" > "$out"/password
echo "BASIC_AUTH_PWD=$PASSWORD" > "$out"/telegraf-password
'';
};
systemd.services.telegraf-json = {
enable = true;
wantedBy = [ "multi-user.target" ];
script = "${pkgs.miniserve}/bin/miniserve -p 9990 ${jsonpath} --auth-file ${config.clan.core.vars.generators.telegraf.files.miniserve-auth.path}";
};
services.telegraf = {
enable = true;
environmentFiles = [
(builtins.toString config.clan.core.vars.generators.telegraf.files.password-env.path)
(builtins.toString
config.clan.core.vars.generators."telegraf-password".files.telegraf-password.path
)
];
extraConfig = {
agent.interval = "60s";
@@ -77,35 +64,32 @@
exec =
let
nixosSystems = pkgs.writeShellScript "current-system" ''
printf "nixos_systems,current_system=%s,booted_system=%s,current_kernel=%s,booted_kernel=%s present=0\n" \
"$(readlink /run/current-system)" "$(readlink /run/booted-system)" \
"$(basename $(echo /run/current-system/kernel-modules/lib/modules/*))" \
"$(basename $(echo /run/booted-system/kernel-modules/lib/modules/*))"
currentSystemScript = pkgs.writeShellScript "current-system" ''
printf "current_system,path=%s present=0\n" $(readlink /run/current-system)
'';
in
[
{
# Expose the path to current-system as metric. We use
# this to check if the machine is up-to-date.
commands = [ nixosSystems ];
commands = [ currentSystemScript ];
data_format = "influx";
}
];
};
# sadly there doesn'T seem to exist a telegraf http_client output plugin
outputs.prometheus_client = {
listen = ":9273";
metric_version = 2;
basic_username = "${auth_user}";
basic_password = "$${BASIC_AUTH_PWD}";
};
outputs.file = {
files = [ jsonpath ];
data_format = "json";
json_timestamp_units = "1s";
};
outputs.prometheus_client = {
listen = ":9273";
metric_version = 2;
basic_username = "prometheus";
basic_password = "$${BASIC_AUTH_PWD}";
};
};
};
};

View File

@@ -0,0 +1,37 @@
This service generates the `system.stateVersion` of the nixos installation
automatically.
Possible values:
[system.stateVersion](https://search.nixos.org/options?channel=unstable&show=system.stateVersion&from=0&size=50&sort=relevance&type=packages&query=stateVersion)
## Usage
The following configuration will set `stateVersion` for all machines:
```
inventory.instances = {
state-version = {
module = {
name = "state-version";
input = "clan";
};
roles.default.tags.all = { };
};
```
## Migration
If you are already setting `system.stateVersion`, either let the automatic
generation happen, or trigger the generation manually for the machine. The
service will take the specified version, if one is already supplied through the
config.
To manually generate the version for a specified machine run:
```
clan vars generate [MACHINE]
```
If the setting was already set, you can then remove `system.stateVersion` from
your machine configuration. For new machines, just import the service as shown
above.

View File

@@ -0,0 +1,50 @@
{ ... }:
{
_class = "clan.service";
manifest.name = "clan-core/state-version";
manifest.description = "Automatically generate the state version of the nixos installation.";
manifest.categories = [ "System" ];
manifest.readme = builtins.readFile ./README.md;
roles.default = {
perInstance =
{ ... }:
{
nixosModule =
{
config,
lib,
...
}:
let
var = config.clan.core.vars.generators.state-version.files.version or { };
in
{
warnings = [
''
The clan.state-version service is deprecated and will be
removed on 2025-07-15 in favor of a nix option.
Please migrate your configuration to use `clan.core.settings.state-version.enable = true` instead.
''
];
system.stateVersion = lib.mkDefault (lib.removeSuffix "\n" var.value);
clan.core.vars.generators.state-version = {
files.version = {
secret = false;
value = lib.mkDefault config.system.nixos.release;
};
runtimeInputs = [ ];
script = ''
echo -n ${config.system.stateVersion} > "$out"/version
'';
};
};
};
};
}

View File

@@ -3,16 +3,14 @@ let
module = lib.modules.importApply ./default.nix { };
in
{
clan.modules = {
coredns = module;
};
clan.modules.state-version = module;
perSystem =
{ ... }:
{
clan.nixosTests.coredns = {
clan.nixosTests.state-version = {
imports = [ ./tests/vm/default.nix ];
clan.modules."@clan/coredns" = module;
clan.modules."@clan/state-version" = module;
};
};
}

View File

@@ -0,0 +1,22 @@
{ lib, ... }:
{
name = "service-state-version";
clan = {
directory = ./.;
inventory = {
machines.server = { };
instances.default = {
module.name = "@clan/state-version";
module.input = "self";
roles.default.machines."server" = { };
};
};
};
nodes.server = { };
testScript = lib.mkDefault ''
start_all()
'';
}

View File

@@ -17,20 +17,6 @@
};
};
# Deploy user Carol on all machines. Prompt only once and use the
# same password on all machines. (`share = true`)
user-carol = {
module = {
name = "users";
input = "clan";
};
roles.default.tags.all = { };
roles.default.settings = {
user = "carol";
share = true;
};
};
# Deploy user bob only on his laptop. Prompt for a password.
user-bob = {
module = {
@@ -43,44 +29,3 @@
};
}
```
## Migration from `root-password` module
The deprecated `clan.root-password` module has been replaced by the `users` module. Here's how to migrate:
### 1. Update your flake configuration
Replace the `root-password` module import with a `users` service instance:
```nix
# OLD - Remove this from your nixosModules:
imports = [
self.inputs.clan-core.clanModules.root-password
];
# NEW - Add to inventory.instances or machines/flake-module.nix:
instances = {
users-root = {
module.name = "users";
module.input = "clan-core";
roles.default.tags.nixos = { };
roles.default.settings = {
user = "root";
prompt = false; # Set to true if you want to be prompted
groups = [ ];
};
};
};
```
### 2. Migrate vars
The vars structure has changed from `root-password` to `user-password-root`:
```bash
# For each machine, rename the vars directories:
cd vars/per-machine/<machine-name>/
mv root-password user-password-root
mv user-password-root/password-hash user-password-root/user-password-hash
mv user-password-root/password user-password-root/user-password
```

View File

@@ -59,17 +59,6 @@
- "input" - Allows the user to access input devices.
'';
};
share = lib.mkOption {
type = lib.types.bool;
default = false;
example = true;
description = ''
Weather the user should have the same password on all machines.
By default, you will be prompted for a new password for every host.
Unless `generate` is set to `true`.
'';
};
};
};
@@ -93,6 +82,7 @@
};
clan.core.vars.generators."user-password-${settings.user}" = {
files.user-password-hash.neededFor = "users";
files.user-password-hash.restartUnits = lib.optional (config.services.userborn.enable) "userborn.service";
files.user-password.deploy = false;
@@ -117,8 +107,6 @@
pkgs.mkpasswd
];
share = settings.share;
script =
(
if settings.prompt then

View File

@@ -1,5 +1,6 @@
#!/usr/bin/env python3
"""IPv6 address allocator for WireGuard networks.
"""
IPv6 address allocator for WireGuard networks.
Network layout:
- Base network: /40 ULA prefix (fd00::/8 + 32 bits from hash)
@@ -12,11 +13,6 @@ import ipaddress
import sys
from pathlib import Path
# Constants for argument count validation
MIN_ARGS_BASE = 4
MIN_ARGS_CONTROLLER = 5
MIN_ARGS_PEER = 5
def hash_string(s: str) -> str:
"""Generate SHA256 hash of string."""
@@ -24,7 +20,8 @@ def hash_string(s: str) -> str:
def generate_ula_prefix(instance_name: str) -> ipaddress.IPv6Network:
"""Generate a /40 ULA prefix from instance name.
"""
Generate a /40 ULA prefix from instance name.
Format: fd{32-bit hash}/40
This gives us fd00:0000:0000::/40 through fdff:ffff:ff00::/40
@@ -44,14 +41,15 @@ def generate_ula_prefix(instance_name: str) -> ipaddress.IPv6Network:
prefix = f"fd{prefix_bits:08x}"
prefix_formatted = f"{prefix[:4]}:{prefix[4:8]}::/40"
return ipaddress.IPv6Network(prefix_formatted)
network = ipaddress.IPv6Network(prefix_formatted)
return network
def generate_controller_subnet(
base_network: ipaddress.IPv6Network,
controller_name: str,
base_network: ipaddress.IPv6Network, controller_name: str
) -> ipaddress.IPv6Network:
"""Generate a /56 subnet for a controller from the base /40 network.
"""
Generate a /56 subnet for a controller from the base /40 network.
We have 16 bits (40 to 56) to allocate controller subnets.
This allows for 65,536 possible controller subnets.
@@ -64,11 +62,14 @@ def generate_controller_subnet(
# The controller subnet is at base_prefix:controller_id::/56
base_int = int(base_network.network_address)
controller_subnet_int = base_int | (controller_id << (128 - 56))
return ipaddress.IPv6Network((controller_subnet_int, 56))
controller_subnet = ipaddress.IPv6Network((controller_subnet_int, 56))
return controller_subnet
def generate_peer_suffix(peer_name: str) -> str:
"""Generate a unique 64-bit host suffix for a peer.
"""
Generate a unique 64-bit host suffix for a peer.
This suffix will be used in all controller subnets to create unique addresses.
Format: :xxxx:xxxx:xxxx:xxxx (64 bits)
@@ -78,13 +79,14 @@ def generate_peer_suffix(peer_name: str) -> str:
suffix_bits = h[:16]
# Format as IPv6 suffix without leading colon
return f"{suffix_bits[0:4]}:{suffix_bits[4:8]}:{suffix_bits[8:12]}:{suffix_bits[12:16]}"
suffix = f"{suffix_bits[0:4]}:{suffix_bits[4:8]}:{suffix_bits[8:12]}:{suffix_bits[12:16]}"
return suffix
def main() -> None:
if len(sys.argv) < MIN_ARGS_BASE:
if len(sys.argv) < 4:
print(
"Usage: ipv6_allocator.py <output_dir> <instance_name> <controller|peer> <machine_name>",
"Usage: ipv6_allocator.py <output_dir> <instance_name> <controller|peer> <machine_name>"
)
sys.exit(1)
@@ -96,7 +98,7 @@ def main() -> None:
base_network = generate_ula_prefix(instance_name)
if node_type == "controller":
if len(sys.argv) < MIN_ARGS_CONTROLLER:
if len(sys.argv) < 5:
print("Controller name required")
sys.exit(1)
@@ -112,7 +114,7 @@ def main() -> None:
(output_dir / "prefix").write_text(prefix_str)
elif node_type == "peer":
if len(sys.argv) < MIN_ARGS_PEER:
if len(sys.argv) < 5:
print("Peer name required")
sys.exit(1)

18
devFlake/flake.lock generated
View File

@@ -3,10 +3,10 @@
"clan-core-for-checks": {
"flake": false,
"locked": {
"lastModified": 1756166884,
"narHash": "sha256-skg4rwpbCjhpLlrv/Pndd43FoEgrJz98WARtGLhCSzo=",
"lastModified": 1755649112,
"narHash": "sha256-Tk/Sjlb7W+5usS4upH0la4aGYs2velkHADnhhn2xO88=",
"ref": "main",
"rev": "f7414d7e6e58709af27b6fe16eb530278e81eaaf",
"rev": "bdab3e23af4a9715dfa6346f344f1bd428508672",
"shallow": true,
"type": "git",
"url": "https://git.clan.lol/clan/clan-core"
@@ -84,11 +84,11 @@
},
"nixpkgs-dev": {
"locked": {
"lastModified": 1756578978,
"narHash": "sha256-dLgwMLIMyHlSeIDsoT2OcZBkuruIbjhIAv1sGANwtes=",
"lastModified": 1755628699,
"narHash": "sha256-IAM29K+Cz9pu90lDDkJpmOpWzQ9Ed0FNkRJcereY+rM=",
"owner": "NixOS",
"repo": "nixpkgs",
"rev": "a85a50bef870537a9705f64ed75e54d1f4bf9c23",
"rev": "01b1b3809cfa3b0fbca8d75c9cf4b2efdb8182cf",
"type": "github"
},
"original": {
@@ -165,11 +165,11 @@
"nixpkgs": []
},
"locked": {
"lastModified": 1755934250,
"narHash": "sha256-CsDojnMgYsfshQw3t4zjRUkmMmUdZGthl16bXVWgRYU=",
"lastModified": 1754847726,
"narHash": "sha256-2vX8QjO5lRsDbNYvN9hVHXLU6oMl+V/PsmIiJREG4rE=",
"owner": "numtide",
"repo": "treefmt-nix",
"rev": "74e1a52d5bd9430312f8d1b8b0354c92c17453e5",
"rev": "7d81f6fb2e19bf84f1c65135d1060d829fae2408",
"type": "github"
},
"original": {

View File

@@ -33,6 +33,7 @@
self'.packages.tea-create-pr
self'.packages.merge-after-ci
self'.packages.pending-reviews
self'.packages.agit
# treefmt with config defined in ./flake-parts/formatting.nix
config.treefmt.build.wrapper
];
@@ -45,7 +46,7 @@
ln -sfT ${inputs.nix-select} "$PRJ_ROOT/pkgs/clan-cli/clan_lib/select"
# Generate classes.py from schemas
${self'.packages.classgen}/bin/classgen ${self'.legacyPackages.schemas.clanSchemaJson}/schema.json $PRJ_ROOT/pkgs/clan-cli/clan_lib/nix_models/clan.py
${self'.packages.classgen}/bin/classgen ${self'.legacyPackages.schemas.clan-schema-abstract}/schema.json $PRJ_ROOT/pkgs/clan-cli/clan_lib/nix_models/clan.py
'';
};
};

2
docs/.gitignore vendored
View File

@@ -1,5 +1,5 @@
/site/reference
/site/static
/site/options
/site/options-page
/site/openapi.json
!/site/static/extra.css

View File

@@ -1,11 +1,13 @@
{
lib,
config,
...
}:
let
suffix = config.clan.core.vars.generators.disk-id.files.diskId.value;
mirrorBoot = idx: {
# suffix is to prevent disk name collisions
name = idx;
name = idx + suffix;
type = "disk";
device = "/dev/disk/by-id/${idx}";
content = {

View File

@@ -1,11 +1,13 @@
{
lib,
config,
...
}:
let
suffix = config.clan.core.vars.generators.disk-id.files.diskId.value;
mirrorBoot = idx: {
# suffix is to prevent disk name collisions
name = idx;
name = idx + suffix;
type = "disk";
device = "/dev/disk/by-id/${idx}";
content = {

View File

@@ -2,11 +2,11 @@ site_name: Clan Documentation
site_url: https://docs.clan.lol
repo_url: https://git.clan.lol/clan/clan-core/
repo_name: "_>"
edit_uri: _edit/main/docs/site/
edit_uri: _edit/main/docs/docs/
validation:
omitted_files: warn
absolute_links: ignore
absolute_links: warn
unrecognized_links: warn
markdown_extensions:
@@ -59,15 +59,14 @@ nav:
- Configure Disk Config: guides/getting-started/choose-disk.md
- Update Machine: guides/getting-started/update.md
- Continuous Integration: guides/getting-started/flake-check.md
- Convert Existing NixOS Config: guides/getting-started/convert-flake.md
- ClanServices: guides/clanServices.md
- Using Services: guides/clanServices.md
- Backup & Restore: guides/backups.md
- Disk Encryption: guides/disk-encryption.md
- Age Plugins: guides/age-plugins.md
- Secrets management: guides/secrets.md
- Networking: guides/networking.md
- Target Host: guides/target-host.md
- Zerotier VPN: guides/mesh-vpn.md
- How to disable Secure Boot: guides/secure-boot.md
- Secure Boot: guides/secure-boot.md
- Flake-parts: guides/flake-parts.md
- macOS: guides/macos.md
- Contributing:
@@ -78,7 +77,8 @@ nav:
- Writing a Service Module: guides/services/community.md
- Writing a Disko Template: guides/disko-templates/community.md
- Migrations:
- Migrate from clan modules to services: guides/migrations/migrate-inventory-services.md
- Migrate existing Flakes: guides/migrations/migration-guide.md
- Migrate inventory Services: guides/migrations/migrate-inventory-services.md
- Facts Vars Migration: guides/migrations/migration-facts-vars.md
- Disk id: guides/migrations/disk-id.md
- Concepts:
@@ -88,13 +88,12 @@ nav:
- Templates: concepts/templates.md
- Reference:
- Overview: reference/index.md
- Browse Options: "/options"
- Clan Options: options.md
- Services:
- Overview:
- reference/clanServices/index.md
- reference/clanServices/admin.md
- reference/clanServices/borgbackup.md
- reference/clanServices/coredns.md
- reference/clanServices/data-mesher.md
- reference/clanServices/dyndns.md
- reference/clanServices/emergency-access.md
@@ -107,6 +106,7 @@ nav:
- reference/clanServices/monitoring.md
- reference/clanServices/packages.md
- reference/clanServices/sshd.md
- reference/clanServices/state-version.md
- reference/clanServices/syncthing.md
- reference/clanServices/trusted-nix-caches.md
- reference/clanServices/users.md
@@ -155,7 +155,6 @@ nav:
- 05-deployment-parameters: decisions/05-deployment-parameters.md
- Template: decisions/_template.md
- Glossary: reference/glossary.md
- Browse Options: "/options"
docs_dir: site
site_dir: out
@@ -173,7 +172,6 @@ theme:
- content.code.annotate
- content.code.copy
- content.tabs.link
- content.action.edit
icon:
repo: fontawesome/brands/git
custom_dir: overrides

View File

@@ -54,9 +54,9 @@ pkgs.stdenv.mkDerivation {
chmod -R +w ./site/reference
echo "Generated API documentation in './site/reference/' "
rm -rf ./site/options
cp -r ${docs-options} ./site/options
chmod -R +w ./site/options
rm -r ./site/options-page || true
cp -r ${docs-options} ./site/options-page
chmod -R +w ./site/options-page
mkdir -p ./site/static/asciinema-player
ln -snf ${asciinema-player-js} ./site/static/asciinema-player/asciinema-player.min.js

View File

@@ -25,7 +25,7 @@
serviceModules = self.clan.modules;
baseHref = "/options/";
baseHref = "/options-page/";
getRoles =
module:
@@ -126,7 +126,7 @@
nestedSettingsOption = mkOption {
type = types.raw;
description = ''
See [instances.${name}.roles.${roleName}.settings](${baseHref}?option_scope=0&option=inventory.instances.${name}.roles.${roleName}.settings)
See [instances.${name}.roles.${roleName}.settings](${baseHref}?option_scope=0&option=instances.${name}.roles.${roleName}.settings)
'';
};
settingsOption = mkOption {
@@ -161,42 +161,6 @@
}
];
baseModule =
# Module
{ config, ... }:
{
imports = (import (pkgs.path + "/nixos/modules/module-list.nix"));
nixpkgs.pkgs = pkgs;
clan.core.name = "dummy";
system.stateVersion = config.system.nixos.release;
# Set this to work around a bug where `clan.core.settings.machine.name`
# is forced due to `networking.interfaces` being forced
# somewhere in the nixpkgs options
facter.detected.dhcp.enable = lib.mkForce false;
};
evalClanModules =
let
evaled = lib.evalModules {
class = "nixos";
modules = [
baseModule
{
clan.core.settings.directory = self;
}
self.nixosModules.clanCore
];
};
in
evaled;
coreOptions =
(pkgs.nixosOptionsDoc {
options = (evalClanModules.options).clan.core or { };
warningsAreErrors = true;
transformOptions = self.clanLib.docs.stripStorePathsFromDeclarations;
}).optionsJSON;
in
{
# Uncomment for debugging
@@ -211,17 +175,10 @@
# scopes = mapAttrsToList mkScope serviceModules;
scopes = [
{
inherit baseHref;
name = "Flake Options (clan.nix file)";
name = "Clan";
modules = docModules;
urlPrefix = "https://git.clan.lol/clan/clan-core/src/branch/main/";
}
{
name = "Machine Options (clan.core NixOS options)";
optionsJSON = "${coreOptions}/share/doc/nixos/options.json";
urlPrefix = "https://git.clan.lol/clan/clan-core/src/branch/main/";
}
];
};
};

View File

@@ -1,5 +1,3 @@
"""Module for rendering NixOS options documentation from JSON format."""
# Options are available in the following format:
# https://github.com/nixos/nixpkgs/blob/master/nixos/lib/make-options-doc/default.nix
#
@@ -34,7 +32,7 @@ from typing import Any
from clan_lib.errors import ClanError
from clan_lib.services.modules import (
CategoryInfo,
ModuleManifest,
Frontmatter,
)
# Get environment variables
@@ -48,7 +46,7 @@ CLAN_SERVICE_INTERFACE = os.environ.get("CLAN_SERVICE_INTERFACE")
CLAN_MODULES_VIA_SERVICE = os.environ.get("CLAN_MODULES_VIA_SERVICE")
OUT = os.environ.get("out") # noqa: SIM112
OUT = os.environ.get("out")
def sanitize(text: str) -> str:
@@ -68,7 +66,8 @@ def render_option_header(name: str) -> str:
def join_lines_with_indentation(lines: list[str], indent: int = 4) -> str:
"""Joins multiple lines with a specified number of whitespace characters as indentation.
"""
Joins multiple lines with a specified number of whitespace characters as indentation.
Args:
lines (list of str): The lines of text to join.
@@ -76,7 +75,6 @@ def join_lines_with_indentation(lines: list[str], indent: int = 4) -> str:
Returns:
str: The indented and concatenated string.
"""
# Create the indentation string (e.g., four spaces)
indent_str = " " * indent
@@ -163,10 +161,7 @@ def render_option(
def print_options(
options_file: str,
head: str,
no_options: str,
replace_prefix: str | None = None,
options_file: str, head: str, no_options: str, replace_prefix: str | None = None
) -> str:
res = ""
with (Path(options_file) / "share/doc/nixos/options.json").open() as f:
@@ -175,16 +170,15 @@ def print_options(
res += head if len(options.items()) else no_options
for option_name, info in options.items():
if replace_prefix:
display_name = option_name.replace(replace_prefix + ".", "")
else:
display_name = option_name
option_name = option_name.replace(replace_prefix + ".", "")
res += render_option(display_name, info, 4)
res += render_option(option_name, info, 4)
return res
def module_header(module_name: str) -> str:
return f"# {module_name}\n\n"
def module_header(module_name: str, has_inventory_feature: bool = False) -> str:
indicator = " 🔹" if has_inventory_feature else ""
return f"# {module_name}{indicator}\n\n"
clan_core_descr = """
@@ -242,7 +236,7 @@ def produce_clan_core_docs() -> None:
for submodule_name, split_options in split.items():
outfile = f"{module_name}/{submodule_name}.md"
print(
f"[clan_core.{submodule_name}] Rendering option of: {submodule_name}... {outfile}",
f"[clan_core.{submodule_name}] Rendering option of: {submodule_name}... {outfile}"
)
init_level = 1
root = options_to_tree(split_options, debug=True)
@@ -277,9 +271,56 @@ def produce_clan_core_docs() -> None:
of.write(output)
def render_roles(roles: list[str] | None, module_name: str) -> str:
if roles:
roles_list = "\n".join([f"- `{r}`" for r in roles])
return (
f"""
### Roles
This module can be used via predefined roles
{roles_list}
"""
"""
Every role has its own configuration options, which are each listed below.
For more information, see the [inventory guide](../../concepts/inventory.md).
??? Example
For example the `admin` module adds the following options globally to all machines where it is used.
`clan.admin.allowedkeys`
```nix
clan-core.lib.clan {
inventory.services = {
admin.me = {
roles.default.machines = [ "jon" ];
config.allowedkeys = [ "ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABgQD..." ];
};
};
};
```
"""
)
return ""
clan_modules_descr = """
Clan modules are [NixOS modules](https://wiki.nixos.org/wiki/NixOS_modules)
which have been enhanced with additional features provided by Clan, with
certain option types restricted to enable configuration through a graphical
interface.
!!! note "🔹"
Modules with this indicator support the [inventory](../../concepts/inventory.md) feature.
"""
def render_categories(
categories: list[str],
categories_info: dict[str, CategoryInfo],
categories: list[str], categories_info: dict[str, CategoryInfo]
) -> str:
res = """<div style="display: grid; grid-template-columns: repeat(3, 1fr); gap: 10px;">"""
for cat in categories:
@@ -344,10 +385,10 @@ Learn how to use `clanServices` in practice in the [Using clanServices guide](..
# output += f"`clan.modules.{module_name}`\n"
output += f"*{module_info['manifest']['description']}*\n"
fm = Frontmatter("")
# output += "## Categories\n\n"
output += render_categories(
module_info["manifest"]["categories"],
ModuleManifest.categories_info(),
module_info["manifest"]["categories"], fm.categories_info
)
output += f"{module_info['manifest']['readme']}\n"
@@ -356,7 +397,7 @@ Learn how to use `clanServices` in practice in the [Using clanServices guide](..
output += f"The {module_name} module has the following roles:\n\n"
for role_name in module_info["roles"]:
for role_name, _ in module_info["roles"].items():
output += f"- {role_name}\n"
for role_name, role_filename in module_info["roles"].items():
@@ -376,8 +417,35 @@ Learn how to use `clanServices` in practice in the [Using clanServices guide](..
of.write(output)
def build_option_card(module_name: str, frontmatter: Frontmatter) -> str:
"""
Build the overview index card for each reference target option.
"""
def indent_all(text: str, indent_size: int = 4) -> str:
"""
Indent all lines in a string.
"""
indent = " " * indent_size
lines = text.split("\n")
indented_text = indent + ("\n" + indent).join(lines)
return indented_text
def to_md_li(module_name: str, frontmatter: Frontmatter) -> str:
md_li = (
f"""- **[{module_name}](./{"-".join(module_name.split(" "))}.md)**\n\n"""
)
md_li += f"""{indent_all("---", 4)}\n\n"""
fmd = f"\n{frontmatter.description.strip()}" if frontmatter.description else ""
md_li += f"""{indent_all(fmd, 4)}"""
return md_li
return f"{to_md_li(module_name, frontmatter)}\n\n"
def split_options_by_root(options: dict[str, Any]) -> dict[str, dict[str, Any]]:
"""Split the flat dictionary of options into a dict of which each entry will construct complete option trees.
"""
Split the flat dictionary of options into a dict of which each entry will construct complete option trees.
{
"a": { Data }
"a.b": { Data }
@@ -461,7 +529,9 @@ def option_short_name(option_name: str) -> str:
def options_to_tree(options: dict[str, Any], debug: bool = False) -> Option:
"""Convert the options dictionary to a tree structure."""
"""
Convert the options dictionary to a tree structure.
"""
# Helper function to create nested structure
def add_to_tree(path_parts: list[str], info: Any, current_node: Option) -> None:
@@ -513,24 +583,22 @@ def options_to_tree(options: dict[str, Any], debug: bool = False) -> Option:
def options_docs_from_tree(
root: Option,
init_level: int = 1,
prefix: list[str] | None = None,
root: Option, init_level: int = 1, prefix: list[str] | None = None
) -> str:
"""Eender the options from the tree structure.
"""
eender the options from the tree structure.
Args:
root (Option): The root option node.
init_level (int): The initial level of indentation.
prefix (list str): Will be printed as common prefix of all attribute names.
"""
def render_tree(option: Option, level: int = init_level) -> str:
output = ""
should_render = not option.name.startswith("<") and not option.name.startswith(
"_",
"_"
)
if should_render:
# short_name = option_short_name(option.name)
@@ -551,10 +619,11 @@ def options_docs_from_tree(
return output
return render_tree(root)
md = render_tree(root)
return md
if __name__ == "__main__":
if __name__ == "__main__": #
produce_clan_core_docs()
produce_clan_service_author_docs()

View File

@@ -1,33 +1,15 @@
# Auto-included Files
Clan automatically imports specific files from each machine directory and registers them, reducing the need for manual configuration.
Clan automatically imports the following files from a directory and registers them.
## Machine Registration
## Machine registration
Every folder under `machines/{machineName}` is automatically registered as a Clan machine.
Every folder `machines/{machineName}` will be registered automatically as a Clan machine.
!!! info "Files loaded automatically for each machine"
!!! info "Automatically loaded files"
The following files are detected and imported for every Clan machine:
The following files are loaded automatically for each Clan machine:
- [x] `machines/{machineName}/configuration.nix`
Main configuration file for the machine.
- [x] `machines/{machineName}/hardware-configuration.nix`
Hardware-specific configuration generated by NixOS.
- [x] `machines/{machineName}/facter.json`
Contains system facts. Automatically generated — see [nixos-facter](https://clan.lol/blog/nixos-facter/) for details.
- [x] `machines/{machineName}/disko.nix`
Disk layout configuration. See the [disko quickstart](https://github.com/nix-community/disko/blob/master/docs/quickstart.md) for more info.
## Other Auto-included Files
* **`inventory.json`**
Managed by Clan's API.
Merges with `clan.inventory` to extend the inventory.
* **`.clan-flake`**
Sentinel file to be used to locate the root of a Clan repository.
Falls back to `.git`, `.hg`, `.svn`, or `flake.nix` if not found.
- [x] `machines/{machineName}/configuration.nix`
- [x] `machines/{machineName}/hardware-configuration.nix`
- [x] `machines/{machineName}/facter.json` Automatically configured, for further information see [nixos-facter](https://clan.lol/blog/nixos-facter/)
- [x] `machines/{machineName}/disko.nix` Automatically loaded, for further information see the [disko docs](https://github.com/nix-community/disko/blob/master/docs/quickstart.md).

View File

@@ -1,22 +1,16 @@
# Using the Inventory
# Using `clanServices`
Clan's inventory system is a composable way to define and deploy services across
machines.
Clans `clanServices` system is a composable way to define and deploy services across machines.
This guide shows how to **instantiate** a `clanService`, explains how service
definitions are structured in your inventory, and how to pick or create services
from modules exposed by flakes.
This guide shows how to **instantiate** a `clanService`, explains how service definitions are structured in your inventory, and how to pick or create services from modules exposed by flakes.
The term **Multi-host-modules** was introduced previously in the [nixus
repository](https://github.com/infinisil/nixus) and represents a similar
concept.
The term **Multi-host-modules** was introduced previously in the [nixus repository](https://github.com/infinisil/nixus) and represents a similar concept.
______________________________________________________________________
---
## Overview
Services are used in `inventory.instances`, and assigned to *roles* and
*machines* -- meaning you decide which machines run which part of the service.
Services are used in `inventory.instances`, and then they attach to *roles* and *machines* — meaning you decide which machines run which part of the service.
For example:
@@ -24,138 +18,119 @@ For example:
inventory.instances = {
borgbackup = {
roles.client.machines."laptop" = {};
roles.client.machines."workstation" = {};
roles.client.machines."server1" = {};
roles.server.machines."backup-box" = {};
};
}
```
This says: "Run borgbackup as a *client* on my *laptop* and *workstation*, and
as a *server* on *backup-box*". `client` and `server` are roles defined by the
`borgbackup` service.
This says: Run borgbackup as a *client* on my *laptop* and *server1*, and as a *server* on *backup-box*.”
## Module source specification
Each instance includes a reference to a **module specification** -- this is how
Clan knows which service module to use and where it came from.
Each instance includes a reference to a **module specification** this is how Clan knows which service module to use and where it came from.
Usually one would just use `imports` but we needd to make the `module source` configurable via Python API.
By default it is not required to specify the `module`, in which case it defaults to the preprovided services of clan-core.
It is not required to specify the `module.input` parameter, in which case it
defaults to the pre-provided services of clan-core. In a similar fashion, the
`module.name` parameter can also be omitted, it will default to the name of the
instance.
---
## Override Example
Example of instantiating a `borgbackup` service using `clan-core`:
```nix
inventory.instances = {
borgbackup = { # <- Instance name
# This can be partially/fully specified,
# - If the instance name is not the name of the module
# - If the input is not clan-core
# module = {
# name = "borgbackup"; # Name of the module (optional)
# input = "clan-core"; # The flake input where the service is defined (optional)
# };
# Instance Name: Different name for this 'borgbackup' instance
borgbackup = {
# Since this is instances."borgbackup" the whole `module = { ... }` below is equivalent and optional.
module = {
name = "borgbackup"; # <-- Name of the module (optional)
input = "clan-core"; # <-- The flake input where the service is defined (optional)
};
# Participation of the machines is defined via roles
# Right side needs to be an attribute set. Its purpose will become clear later
roles.client.machines."machine-a" = {};
roles.server.machines."backup-host" = {};
};
}
```
## Module Settings
If you used `clan-core` as an input attribute for your flake:
Each role might expose configurable options. See clan's [clanServices
reference](../reference/clanServices/index.md) for all available options.
```nix
# ↓ module.input = "clan-core"
inputs.clan-core.url = "https://git.clan.lol/clan/clan-core/archive/main.tar.gz";
```
Settings can be set in per-machine or per-role. The latter is applied to all
machines that are assigned to that role.
## Simplified Example
If only one instance is needed for a service and the service is a clan core service, the `module` definition can be omitted.
```nix
# Simplified way of specifying a single instance
inventory.instances = {
# instance name is `borgbackup` -> clan core module `borgbackup` will be loaded.
borgbackup = {
# Participation of the machines is defined via roles
# Right side needs to be an attribute set. Its purpose will become clear later
roles.client.machines."machine-a" = {};
roles.server.machines."backup-host" = {};
};
}
```
## Configuration Example
Each role might expose configurable options
See clan's [clanServices reference](../reference/clanServices/index.md) for available options
```nix
inventory.instances = {
borgbackup = {
# Settings for 'machine-a'
borgbackup-example = {
module = {
name = "borgbackup";
input = "clan-core";
};
roles.client.machines."machine-a" = {
# 'client' -Settings of 'machine-a'
settings = {
backupFolders = [
/home
/var
];
};
# ---------------------------
};
# Settings for all machines of the role "server"
roles.server.settings = {
directory = "/var/lib/borgbackup";
};
roles.server.machines."backup-host" = {};
};
}
```
## Tags
Tags can be used to assign multiple machines to a role at once. It can be thought of as a grouping mechanism.
For example using the `all` tag for services that you want to be configured on all
your machines is a common pattern.
The following example could be used to backup all your machines to a common
backup server
Multiple members can be defined using tags as follows
```nix
inventory.instances = {
borgbackup = {
# "All" machines are assigned to the borgbackup 'client' role
roles.client.tags = [ "all" ];
# But only one specific machine (backup-host) is assigned to the 'server' role
roles.server.machines."backup-host" = {};
};
}
```
## Sharing additional Nix configuration
Sometimes you need to add custom NixOS configuration alongside your clan
services. The `extraModules` option allows you to include additional NixOS
configuration that is applied for every machine assigned to that role.
There are multiple valid syntaxes for specifying modules:
```nix
inventory.instances = {
borgbackup = {
roles.client = {
# Direct module reference
extraModules = [ ../nixosModules/borgbackup.nix ];
# Or using self (needs to be json serializable)
# See next example, for a workaround.
extraModules = [ self.nixosModules.borgbackup ];
# Or inline module definition, (needs to be json compatible)
extraModules = [
{
# Your module configuration here
# ...
#
# If the module needs to contain non-serializable expressions:
imports = [ ./path/to/non-serializable.nix ];
}
];
borgbackup-example = {
module = {
name = "borgbackup";
input = "clan-core";
};
#
# The 'all' -tag targets all machines
roles.client.tags."all" = {};
# ---------------------------
roles.server.machines."backup-host" = {};
};
}
```
## Picking a clanService
You can use services exposed by Clan's core module library, `clan-core`.
You can use services exposed by Clans core module library, `clan-core`.
🔗 See: [List of Available Services in clan-core](../reference/clanServices/index.md)
@@ -167,19 +142,18 @@ You can also author your own `clanService` modules.
You might expose your service module from your flake — this makes it easy for other people to also use your module in their clan.
______________________________________________________________________
---
## 💡 Tips for Working with clanServices
- You can add multiple inputs to your flake (`clan-core`, `your-org-modules`, etc.) to mix and match services.
- Each service instance is isolated by its key in `inventory.instances`, allowing to deploy multiple versions or roles of the same service type.
- Roles can target different machines or be scoped dynamically.
* You can add multiple inputs to your flake (`clan-core`, `your-org-modules`, etc.) to mix and match services.
* Each service instance is isolated by its key in `inventory.instances`, allowing you to deploy multiple versions or roles of the same service type.
* Roles can target different machines or be scoped dynamically.
______________________________________________________________________
---
## What's Next?
- [Author your own clanService →](../guides/services/community.md)
- [Migrate from clanModules →](../guides/migrations/migrate-inventory-services.md)
## Whats Next?
* [Author your own clanService →](../guides/services/community.md)
* [Migrate from clanModules →](../guides/migrations/migrate-inventory-services.md)
<!-- TODO: * [Understand the architecture →](../explanation/clan-architecture.md) -->

View File

@@ -27,7 +27,7 @@ inputs = {
## Import the Clan flake-parts Module
After updating your flake inputs, the next step is to import the Clan flake-parts module. This will make the [Clan options](/options) available within `mkFlake`.
After updating your flake inputs, the next step is to import the Clan flake-parts module. This will make the [Clan options](../options.md) available within `mkFlake`.
```nix
{

View File

@@ -2,9 +2,9 @@
Machines can be added using the following methods
- Create a file `machines/{machine_name}/configuration.nix` (See: [File Autoincludes](../../concepts/autoincludes.md))
- Imperative via cli command: `clan machines create`
- Editing nix expressions in flake.nix See [`clan-core.lib.clan`](/options/?scope=Flake Options (clan.nix file))
- Editing nix expressions in flake.nix (i.e. via `clan-core.lib.clan`)
- Editing machines/`machine_name`/configuration.nix (automatically included if it exists)
- `clan machines create` (imperative)
See the complete [list](../../concepts/autoincludes.md) of auto-loaded files.
@@ -39,6 +39,7 @@ See the complete [list](../../concepts/autoincludes.md) of auto-loaded files.
The imperative command might create a machine folder in `machines/jon`
And might persist information in `inventory.json`
### Configuring a machine
!!! Note

View File

@@ -1,15 +1,12 @@
# Update Machines
# Update Your Machines
The Clan command line interface enables you to update machines remotely over SSH.
In this guide we will teach you how to set a `targetHost` in Nix,
and how to define a remote builder for your machine closures.
Clan CLI enables you to remotely update your machines over SSH. This requires setting up a target address for each target machine.
### Setting `targetHost`
## Setting `targetHost`
In your Nix files, set the `targetHost` to the reachable IP address of your new machine. This eliminates the need to specify `--target-host` with every command.
Set the machines `targetHost` to the reachable IP address of the new machine.
This eliminates the need to specify `--target-host` in CLI commands.
```{.nix title="clan.nix" hl_lines="9"}
{
@@ -26,42 +23,15 @@ inventory.machines = {
# [...]
}
```
The use of `root@` in the target address implies SSH access as the `root` user.
Ensure that the root login is secured and only used when necessary.
## Multiple Target Hosts
You can now experiment with a new interface that allows you to define multiple `targetHost` addresses for different VPNs. Learn more and try it out in our [networking guide](../networking.md).
### Setting a Build Host
## Updating Machine Configurations
Execute the following command to update the specified machine:
```bash
clan machines update jon
```
All machines can be updated simultaneously by omitting the machine name:
```bash
clan machines update
```
---
## Advanced Usage
The following options are only needed for special cases, such as limited resources, mixed environments, or private flakes.
### Setting `buildHost`
If the machine does not have enough resources to run the NixOS **evaluation** or **build** itself,
it is also possible to specify a `buildHost` instead.
During an update, clan will ssh into the `buildHost` and run `nixos-rebuild` from there.
!!! Note
The `buildHost` option should be set directly within your machines Nix configuration, **not** under `inventory.machines`.
If the machine does not have enough resources to run the NixOS evaluation or build itself,
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.
```{.nix hl_lines="5" .no-copy}
@@ -75,11 +45,7 @@ buildClan {
};
```
### Overriding configuration with CLI flags
`buildHost` / `targetHost`, and other network settings can be temporarily overridden for a single command:
For the full list of flags refer to the [Clan CLI](../../reference/cli/index.md)
You can also override the build host via the command line:
```bash
# Build on a remote host
@@ -90,9 +56,23 @@ clan machines update jon --build-host local
```
!!! Note
Make sure the CPU architecture of the `buildHost` matches that of the `targetHost`
Make sure that the CPU architecture is the same for the buildHost as for the targetHost.
Example:
If you want to deploy to a macOS machine, your architecture is an ARM64-Darwin, that means you need a second macOS machine to build it.
For example, if deploying to a macOS machine with an ARM64-Darwin architecture, you need a second macOS machine with the same architecture to build it.
### Updating Machine Configurations
Execute the following command to update the specified machine:
```bash
clan machines update jon
```
You can also update all configured machines simultaneously by omitting the machine name:
```bash
clan machines update
```
### Excluding a machine from `clan machine update`
@@ -116,15 +96,14 @@ This is useful for machines that are not always online or are not part of the re
### Uploading Flake Inputs
When updating remote machines, flake inputs are usually fetched by the build host.
However, if flake inputs require authentication (e.g., private repositories),
Use the `--upload-inputs` flag to upload all inputs from your local machine:
However, if your flake inputs require authentication (e.g., private repositories),
you can use the `--upload-inputs` flag to upload all inputs from your local machine:
```bash
clan machines update jon --upload-inputs
```
This is particularly useful when:
- The flake references private Git repositories
- Authentication credentials are only available on local machine
- Your flake references private Git repositories
- Authentication credentials are only available on your local machine
- The build host doesn't have access to certain network resources

View File

@@ -254,7 +254,7 @@ The following table shows the migration status of each deprecated clanModule:
| `data-mesher` | ✅ [Migrated](../../reference/clanServices/data-mesher.md) | |
| `deltachat` | ❌ Removed | |
| `disk-id` | ❌ Removed | |
| `dyndns` | [Migrated](../../reference/clanServices/dyndns.md) | |
| `dyndns` | [Being Migrated](https://git.clan.lol/clan/clan-core/pulls/4390) | |
| `ergochat` | ❌ Removed | |
| `garage` | ✅ [Migrated](../../reference/clanServices/garage.md) | |
| `golem-provider` | ❌ Removed | |
@@ -263,18 +263,18 @@ The following table shows the migration status of each deprecated clanModule:
| `iwd` | ❌ Removed | Use [wifi service](../../reference/clanServices/wifi.md) instead |
| `localbackup` | ✅ [Migrated](../../reference/clanServices/localbackup.md) | |
| `localsend` | ❌ Removed | |
| `machine-id` | ✅ [Migrated](../../reference/clan.core/settings.md) | Now an [option](../../reference/clan.core/settings.md) |
| `machine-id` | ❌ Removed | Now an [option](../../reference/clan.core/settings.md) |
| `matrix-synapse` | ✅ [Migrated](../../reference/clanServices/matrix-synapse.md) | |
| `moonlight` | ❌ Removed | |
| `mumble` | ❌ Removed | |
| `mycelium` | ✅ [Migrated](../../reference/clanServices/mycelium.md) | |
| `nginx` | ❌ Removed | |
| `packages` | ✅ [Migrated](../../reference/clanServices/packages.md) | |
| `postgresql` | ✅ [Migrated](../../reference/clan.core/settings.md) | Now an [option](../../reference/clan.core/settings.md) |
| `root-password` | ✅ [Migrated](../../reference/clanServices/users.md) | See [migration guide](../../reference/clanServices/users.md#migration-from-root-password-module) |
| `postgresql` | ❌ Removed | Now an [option](../../reference/clan.core/settings.md) |
| `root-password` | ✅ [Migrated](../../reference/clanServices/users.md) | |
| `single-disk` | ❌ Removed | |
| `sshd` | ✅ [Migrated](../../reference/clanServices/sshd.md) | |
| `state-version` | ✅ [Migrated](../../reference/clan.core/settings.md) | Now an [option](../../reference/clan.core/settings.md) |
| `state-version` | ✅ [Migrated](../../reference/clanServices/state-version.md) | |
| `static-hosts` | ❌ Removed | |
| `sunshine` | ❌ Removed | |
| `syncthing-static-peers` | ❌ Removed | |

View File

@@ -1,20 +1,18 @@
# Convert existing NixOS configurations
# Migrate existing NixOS configurations
This guide will help you convert your existing NixOS configurations into a Clan.
This guide will help you migrate your existing NixOS configurations into Clan.
!!! Warning
Migrating instead of starting new can be trickier and might lead to bugs or
unexpected issues. We recommend reading the [Getting Started](./index.md) guide first.
Once you have a working setup and understand the concepts transfering your NixOS configurations over is easy.
## Back up your existing configuration
unexpected issues. We recommend following the [Getting Started](../getting-started/index.md) guide first. Once you have a working setup, you can easily transfer your NixOS configurations over.
## Back up your existing configuration!
Before you start, it is strongly recommended to back up your existing
configuration in any form you see fit. If you use version control to manage
your configuration changes, it is also a good idea to follow the migration
guide in a separte branch until everything works as expected.
## Starting Point
We assume you are already using NixOS flakes to manage your configuration. If
@@ -45,9 +43,10 @@ have have two hosts: **berlin** and **cologne**.
}
```
## 1. Add `clan-core` to `inputs`
## Add clan-core Input
Add `clan-core` to your flake as input.
Add `clan-core` to your flake as input. It will provide everything we need to
manage your configurations with clan.
```nix
inputs.clan-core = {
@@ -57,7 +56,7 @@ inputs.clan-core = {
}
```
## 2. Update Outputs
## Update Outputs
To be able to access our newly added dependency, it has to be added to the
output parameters.
@@ -104,23 +103,26 @@ For the provide flake example, your flake should now look like this:
};
in
{
inherit (clan.config) nixosConfigurations nixosModules clanInternals;
clan = clan.config;
nixosConfigurations = clan.nixosConfigurations;
inherit (clan) clanInternals;
clan = {
inherit (clan) templates;
};
};
}
```
Et voilà! Your existing hosts are now part of a clan.
Existing Nix tooling
Et voilà! Your existing hosts are now part of a clan. Existing Nix tooling
should still work as normal. To check that you didn't make any errors, run `nix
flake show` and verify both hosts are still recognized as if nothing had
changed. You should also see the new `clan` output.
changed. You should also see the new `clanInternals` output.
```
nix flake show
git+file:///my-nixos-config
├───clan: unknown
├───clanInternals: unknown
└───nixosConfigurations
├───berlin: NixOS configuration
└───cologne: NixOS configuration
@@ -129,7 +131,7 @@ git+file:///my-nixos-config
Of course you can also rebuild your configuration using `nixos-rebuild` and
veryify everything still works.
## 3. Add `clan-cli` to your `devShells`
## Add Clan CLI devShell
At this point Clan is set up, but you can't use the CLI yet. To do so, it is
recommended to expose it via a `devShell` in your flake. It is also possible to
@@ -161,8 +163,8 @@ cologne
## Specify Targets
Clan needs to know where it can reach your hosts. For testing purpose set
`clan.core.networking.targetHost` to the machines adress or hostname.
Clan needs to know where it can reach your hosts. For each of your hosts, set
`clan.core.networking.targetHost` to its adress or hostname.
```nix
# machines/berlin/configuration.nix
@@ -171,8 +173,6 @@ Clan needs to know where it can reach your hosts. For testing purpose set
}
```
See our guide on for properly [configuring machines networking](../networking.md)
## Next Steps
You are now fully set up. Use the CLI to manage your hosts or proceed to

View File

@@ -1,184 +0,0 @@
# Connecting to Your Machines
Clan provides automatic networking with fallback mechanisms to reliably connect to your machines.
## Option 1: Automatic Networking with Fallback (Recommended)
Clan's networking module automatically manages connections through various network technologies with intelligent fallback. When you run `clan ssh` or `clan machines update`, Clan tries each configured network by priority until one succeeds.
### Basic Setup with Internet Service
For machines with public IPs or DNS names, use the `internet` service to configure direct SSH while keeping fallback options:
```{.nix title="flake.nix" hl_lines="7-10 14-16"}
{
outputs = { self, clan-core, ... }:
let
clan = clan-core.lib.clan {
inventory.instances = {
# Direct SSH with fallback support
internet = {
roles.default.machines.server1 = {
settings.address = "server1.example.com";
};
roles.default.machines.server2 = {
settings.address = "192.168.1.100";
};
};
# Fallback: Secure connections via Tor
tor = {
roles.server.tags.nixos = { };
};
};
};
in
{
inherit (clan.config) nixosConfigurations;
};
}
```
### Advanced Setup with Multiple Networks
```{.nix title="flake.nix" hl_lines="7-10 13-16 19-21"}
{
outputs = { self, clan-core, ... }:
let
clan = clan-core.lib.clan {
inventory.instances = {
# Priority 1: Try direct connection first
internet = {
roles.default.machines.publicserver = {
settings.address = "public.example.com";
};
};
# Priority 2: VPN for internal machines
zerotier = {
roles.controller.machines."controller" = { };
roles.peer.tags.nixos = { };
};
# Priority 3: Tor as universal fallback
tor = {
roles.server.tags.nixos = { };
};
};
};
in
{
inherit (clan.config) nixosConfigurations;
};
}
```
### How It Works
Clan automatically tries networks in order of priority:
1. Direct internet connections (if configured)
2. VPN networks (ZeroTier, Tailscale, etc.)
3. Tor hidden services
4. Any other configured networks
If one network fails, Clan automatically tries the next.
### Useful Commands
```bash
# View all configured networks and their status
clan network list
# Test connectivity through all networks
clan network ping machine1
# Show complete network topology
clan network overview
```
## Option 2: Manual targetHost (Bypasses Fallback!)
!!! warning
Setting `targetHost` directly **disables all automatic networking and fallback**. Only use this if you need complete control and don't want Clan's intelligent connection management.
### Using Inventory (For Static Addresses)
Use inventory-level `targetHost` when the address is **static** and doesn't depend on NixOS configuration:
```{.nix title="flake.nix" hl_lines="8"}
{
outputs = { self, clan-core, ... }:
let
clan = clan-core.lib.clan {
inventory.machines.server = {
# WARNING: This bypasses all networking modules!
# Use for: Static IPs, DNS names, known hostnames
deploy.targetHost = "root@192.168.1.100";
};
};
in
{
inherit (clan.config) nixosConfigurations;
};
}
```
**When to use inventory-level:**
- Static IP addresses: `"root@192.168.1.100"`
- DNS names: `"user@server.example.com"`
- Any address that doesn't change based on machine configuration
### Using NixOS Configuration (For Dynamic Addresses)
Use machine-level `targetHost` when you need to **interpolate values from the NixOS configuration**:
```{.nix title="flake.nix" hl_lines="7"}
{
outputs = { self, clan-core, ... }:
let
clan = clan-core.lib.clan {
machines.server = { config, ... }: {
# WARNING: This also bypasses all networking modules!
# REQUIRED for: Addresses that depend on NixOS config
clan.core.networking.targetHost = "root@${config.networking.hostName}.local";
};
};
in
{
inherit (clan.config) nixosConfigurations;
};
}
```
**When to use machine-level (NixOS config):**
- Using hostName from config: `"root@${config.networking.hostName}.local"`
- Building from multiple config values: `"${config.users.users.deploy.name}@${config.networking.hostName}"`
- Any address that depends on evaluated NixOS configuration
!!! info "Key Difference"
**Inventory-level** (`deploy.targetHost`) is evaluated immediately and works with static strings.
**Machine-level** (`clan.core.networking.targetHost`) is evaluated after NixOS configuration and can access `config.*` values.
## Quick Decision Guide
| Scenario | Recommended Approach | Why |
|----------|---------------------|-----|
| Public servers | `internet` service | Keeps fallback options |
| Mixed infrastructure | Multiple networks | Automatic failover |
| Machines behind NAT | ZeroTier/Tor | NAT traversal with fallback |
| Testing/debugging | Manual targetHost | Full control, no magic |
| Single static machine | Manual targetHost | Simple, no overhead |
## Command-Line Override
The `--target-host` flag bypasses ALL networking configuration:
```bash
# Emergency access - ignores all networking config
clan machines update server --target-host root@backup-ip.com
# Direct SSH - no fallback attempted
clan ssh laptop --target-host user@10.0.0.5
```
Use this for debugging or emergency access when automatic networking isn't working.

View File

@@ -255,50 +255,11 @@ outputs = inputs: flake-parts.lib.mkFlake { inherit inputs; } ({self, lib, ...}:
})
```
The benefit of this approach is that downstream users can override the value of
`myClan` by using `mkForce` or other priority modifiers.
## Example: A machine-type service
Users often have different types of machines. These could be any classification
you like, for example "servers" and "desktops". Having such distictions, allows
reusing parts of your configuration that should be appplied to a class of
machines. Since this is such a common pattern, here is how to write such a
service.
For this example the we have to roles: `server` and `desktop`. Additionally, we
can use the `perMachine` section to add configuration to all machines regardless
of their type.
```nix title="machine-type.nix"
{
_class = "clan.service";
manifest.name = "machine-type";
roles.server.perInstance.nixosModule = ./server.nix;
roles.desktop.perInstance.nixosModule = ./desktop.nix;
perMachine.nixosModule = {
# Configuration for all machines (any type)
};
}
```
In the inventory we the assign machines to a type, e.g. by using tags
```nix title="flake.nix"
instnaces.machine-type = {
module.input = "self";
module.name = "@pinpox/machine-type";
roles.desktop.tags.desktop = { };
roles.server.tags.server = { };
};
```
The benefit of this approach is that downstream users can override the value of `myClan` by using `mkForce` or other priority modifiers.
---
## Further Reading
## Further
- [Reference Documentation for Service Authors](../../reference/clanServices/clan-service-author-interface.md)
- [Migration Guide from ClanModules to ClanServices](../../guides/migrations/migrate-inventory-services.md)

View File

@@ -0,0 +1,84 @@
# How to Set `targetHost` for a Machine
The `targetHost` defines where the machine can be reached for operations like SSH or deployment. You can set it in two ways, depending on your use case.
---
## ✅ Option 1: Use the Inventory (Recommended for Static Hosts)
If the hostname is **static**, like `server.example.com`, set it in the **inventory**:
```{.nix title="flake.nix" hl_lines="8"}
{
# edlided
outputs =
{ self, clan-core, ... }:
let
# Sometimes this attribute set is defined in clan.nix
clan = clan-core.lib.clan {
inventory.machines.jon = {
deploy.targetHost = "root@server.example.com";
};
};
in
{
inherit (clan.config) nixosConfigurations nixosModules clanInternals;
# elided
};
}
```
This is fast, simple and explicit, and doesnt require evaluating the NixOS config. We can also displayed it in the clan-cli or clan-app.
---
## ✅ Option 2: Use NixOS (Only for Dynamic Hosts)
If your target host depends on a **dynamic expression** (like using the machines evaluated FQDN), set it inside the NixOS module:
```{.nix title="flake.nix" hl_lines="8"}
{
# edlided
outputs =
{ self, clan-core, ... }:
let
# Sometimes this attribute set is defined in clan.nix
clan = clan-core.lib.clan {
machines.jon = {config, ...}: {
clan.core.networking.targetHost = "jon@${config.networking.fqdn}";
};
};
in
{
inherit (clan.config) nixosConfigurations nixosModules clanInternals;
# elided
};
}
```
Use this **only if the value cannot be made static**, because its slower and won't be displayed in the clan-cli or clan-app yet.
---
## 📝 TL;DR
| Use Case | Use Inventory? | Example |
| ------------------------- | -------------- | -------------------------------- |
| Static hostname | ✅ Yes | `root@server.example.com` |
| Dynamic config expression | ❌ No | `jon@${config.networking.fqdn}` |
---
## 🚀 Coming Soon: Unified Networking Module
Were working on a new networking module that will automatically do all of this for you.
- Easier to use
- Sane defaults: Youll always be able to reach the machine — no need to worry about hostnames.
- ✨ Migration from **either method** will be supported and simple.
## Summary
- Ask: *Does this hostname dynamically change based on NixOS config?*
- If **no**, use the inventory.
- If **yes**, then use NixOS config.

6
docs/site/options.md Normal file
View File

@@ -0,0 +1,6 @@
---
template: options.html
---
<iframe src="/options-page/" height="1000" width="100%"></iframe>

View File

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

40
flake.lock generated
View File

@@ -13,11 +13,11 @@
]
},
"locked": {
"lastModified": 1756091210,
"narHash": "sha256-oEUEAZnLbNHi8ti4jY8x10yWcIkYoFc5XD+2hjmOS04=",
"rev": "eb831bca21476fa8f6df26cb39e076842634700d",
"lastModified": 1753067306,
"narHash": "sha256-jyoEbaXa8/MwVQ+PajUdT63y3gYhgD9o7snO/SLaikw=",
"rev": "18dfd42bdb2cfff510b8c74206005f733e38d8b9",
"type": "tarball",
"url": "https://git.clan.lol/api/v1/repos/clan/data-mesher/archive/eb831bca21476fa8f6df26cb39e076842634700d.tar.gz"
"url": "https://git.clan.lol/api/v1/repos/clan/data-mesher/archive/18dfd42bdb2cfff510b8c74206005f733e38d8b9.tar.gz"
},
"original": {
"type": "tarball",
@@ -31,11 +31,11 @@
]
},
"locked": {
"lastModified": 1756115622,
"narHash": "sha256-iv8xVtmLMNLWFcDM/HcAPLRGONyTRpzL9NS09RnryRM=",
"lastModified": 1755519972,
"narHash": "sha256-bU4nqi3IpsUZJeyS8Jk85ytlX61i4b0KCxXX9YcOgVc=",
"owner": "nix-community",
"repo": "disko",
"rev": "bafad29f89e83b2d861b493aa23034ea16595560",
"rev": "4073ff2f481f9ef3501678ff479ed81402caae6d",
"type": "github"
},
"original": {
@@ -71,11 +71,11 @@
]
},
"locked": {
"lastModified": 1755825449,
"narHash": "sha256-XkiN4NM9Xdy59h69Pc+Vg4PxkSm9EWl6u7k6D5FZ5cM=",
"lastModified": 1755275010,
"narHash": "sha256-lEApCoWUEWh0Ifc3k1JdVjpMtFFXeL2gG1qvBnoRc2I=",
"owner": "nix-darwin",
"repo": "nix-darwin",
"rev": "8df64f819698c1fee0c2969696f54a843b2231e8",
"rev": "7220b01d679e93ede8d7b25d6f392855b81dd475",
"type": "github"
},
"original": {
@@ -86,11 +86,11 @@
},
"nix-select": {
"locked": {
"lastModified": 1755887746,
"narHash": "sha256-lzWbpHKX0WAn/jJDoCijIDss3rqYIPawe46GDaE6U3g=",
"rev": "92c2574c5e113281591be01e89bb9ddb31d19156",
"lastModified": 1745005516,
"narHash": "sha256-IVaoOGDIvAa/8I0sdiiZuKptDldrkDWUNf/+ezIRhyc=",
"rev": "69d8bf596194c5c35a4e90dd02c52aa530caddf8",
"type": "tarball",
"url": "https://git.clan.lol/api/v1/repos/clan/nix-select/archive/92c2574c5e113281591be01e89bb9ddb31d19156.tar.gz"
"url": "https://git.clan.lol/api/v1/repos/clan/nix-select/archive/69d8bf596194c5c35a4e90dd02c52aa530caddf8.tar.gz"
},
"original": {
"type": "tarball",
@@ -99,11 +99,11 @@
},
"nixos-facter-modules": {
"locked": {
"lastModified": 1756291602,
"narHash": "sha256-FYhiArSzcx60OwoH3JBp5Ho1D5HEwmZx6WoquauDv3g=",
"lastModified": 1755504238,
"narHash": "sha256-mw7q5DPdmz/1au8mY0u1DztRgVyJToGJfJszxjKSNes=",
"owner": "nix-community",
"repo": "nixos-facter-modules",
"rev": "5c37cee817c94f50710ab11c25de572bc3604bd5",
"rev": "354ed498c9628f32383c3bf5b6668a17cdd72a28",
"type": "github"
},
"original": {
@@ -181,11 +181,11 @@
]
},
"locked": {
"lastModified": 1755934250,
"narHash": "sha256-CsDojnMgYsfshQw3t4zjRUkmMmUdZGthl16bXVWgRYU=",
"lastModified": 1754847726,
"narHash": "sha256-2vX8QjO5lRsDbNYvN9hVHXLU6oMl+V/PsmIiJREG4rE=",
"owner": "numtide",
"repo": "treefmt-nix",
"rev": "74e1a52d5bd9430312f8d1b8b0354c92c17453e5",
"rev": "7d81f6fb2e19bf84f1c65135d1060d829fae2408",
"type": "github"
},
"original": {

View File

@@ -328,7 +328,7 @@ rec {
# To get the type of a Deferred modules we need to know the interface of the place where it is evaluated.
# i.e. in case of a clan.service this is the interface of the service which dynamically changes depending on the service
# We assign "type" = []
# This means any value is valid — or like TypeScript's unknown.
# This means any value is valid — or like TypeScripts unknown.
# We can assign the type later, when we know the exact interface.
# tsType = "unknown" is a type that we preload for json2ts, such that it gets the correct type in typescript
(option.type.name == "deferredModule")

View File

@@ -245,8 +245,6 @@ in
in
{ config, ... }:
{
staticModules = clan-core.clan.modules;
distributedServices = clanLib.inventory.mapInstances {
inherit (clanConfig) inventory exportsModule;
inherit flakeInputs directory;

View File

@@ -639,7 +639,7 @@ in
Exports are used to share and expose information between instances.
Define exports in the [`perInstance`](#roles.perInstance) or [`perMachine`](#perMachine) scope.
Define exports in the [`perInstance`](#perInstance) or [`perMachine`](#perMachine) scope.
Accessing the exports:

View File

@@ -21,14 +21,14 @@ let
"secrets"
"templates"
];
clanSchemaNix = jsonLib.parseOptions (lib.filterAttrs (n: _v: lib.elem n include) clanOpts) { };
clanSchema = jsonLib.parseOptions (lib.filterAttrs (n: _v: lib.elem n include) clanOpts) { };
clanSchemaJson = pkgs.stdenv.mkDerivation {
clan-schema-abstract = pkgs.stdenv.mkDerivation {
name = "clan-schema-files";
buildInputs = [ pkgs.cue ];
src = ./.;
buildPhase = ''
export SCHEMA=${builtins.toFile "clan-schema.json" (builtins.toJSON clanSchemaNix)}
export SCHEMA=${builtins.toFile "clan-schema.json" (builtins.toJSON clanSchema)}
cp $SCHEMA schema.json
# Also generate a CUE schema version that is derived from the JSON schema
cue import -f -p compose -l '#Root:' schema.json
@@ -41,7 +41,7 @@ in
{
inherit
flakeOptions
clanSchemaNix
clanSchemaJson
clanSchema
clan-schema-abstract
;
}

View File

@@ -27,9 +27,7 @@ in
default = { };
};
tags = lib.mkOption {
type = types.coercedTo (types.listOf types.str) (t: lib.genAttrs t (_: { })) (
types.attrsOf (types.submodule { })
);
type = types.attrsOf (types.submodule { });
default = { };
};
settings =

View File

@@ -23,12 +23,6 @@ let
};
in
{
options.staticModules = lib.mkOption {
readOnly = true;
type = lib.types.raw;
apply = moduleSet: lib.mapAttrs (inspectModule "<clan-core>") moduleSet;
};
options.modulesPerSource = lib.mkOption {
# { sourceName :: { moduleName :: {} }}
readOnly = true;

View File

@@ -1,5 +1,3 @@
"""Test driver for container-based NixOS testing."""
import argparse
import ctypes
import os
@@ -13,7 +11,7 @@ import uuid
from collections.abc import Callable
from contextlib import _GeneratorContextManager
from dataclasses import dataclass
from functools import cache, cached_property
from functools import cached_property
from pathlib import Path
from tempfile import NamedTemporaryFile, TemporaryDirectory
from typing import Any
@@ -22,21 +20,23 @@ from colorama import Fore, Style
from .logger import AbstractLogger, CompositeLogger, TerminalLogger
# Global flag to track if test environment has been initialized
_test_env_initialized = False
@cache
def init_test_environment() -> None:
"""Set up the test environment (network bridge, /etc/passwd) once."""
global _test_env_initialized
if _test_env_initialized:
return
# Set up network bridge
subprocess.run(
["ip", "link", "add", "br0", "type", "bridge"],
check=True,
text=True,
["ip", "link", "add", "br0", "type", "bridge"], check=True, text=True
)
subprocess.run(["ip", "link", "set", "br0", "up"], check=True, text=True)
subprocess.run(
["ip", "addr", "add", "192.168.1.254/24", "dev", "br0"],
check=True,
text=True,
["ip", "addr", "add", "192.168.1.254/24", "dev", "br0"], check=True, text=True
)
# Set up minimal passwd file for unprivileged operations
@@ -44,7 +44,7 @@ def init_test_environment() -> None:
passwd_content = """root:x:0:0:Root:/root:/bin/sh
nixbld:x:1000:100:Nix build user:/tmp:/bin/sh
nobody:x:65534:65534:Nobody:/:/bin/sh
""" # noqa: S105 - This is not a password, it's a Unix passwd file format for testing
"""
with NamedTemporaryFile(mode="w", delete=False, prefix="test-passwd-") as f:
f.write(passwd_content)
@@ -84,6 +84,8 @@ nogroup:x:65534:
errno = ctypes.get_errno()
raise OSError(errno, os.strerror(errno), "Failed to mount group")
_test_env_initialized = True
# Load the C library
libc = ctypes.CDLL("libc.so.6", use_errno=True)
@@ -109,7 +111,8 @@ def mount(
mountflags: int = 0,
data: str | None = None,
) -> None:
"""A Python wrapper for the mount system call.
"""
A Python wrapper for the mount system call.
:param source: The source of the file system (e.g., device name, remote filesystem).
:param target: The mount point (an existing directory).
@@ -126,11 +129,7 @@ def mount(
# Call the mount system call
result = libc.mount(
source_c,
target_c,
fstype_c,
ctypes.c_ulong(mountflags),
data_c,
source_c, target_c, fstype_c, ctypes.c_ulong(mountflags), data_c
)
if result != 0:
@@ -142,11 +141,11 @@ class Error(Exception):
pass
def prepare_machine_root(root: Path) -> None:
def prepare_machine_root(machinename: str, root: Path) -> None:
root.mkdir(parents=True, exist_ok=True)
root.joinpath("etc").mkdir(parents=True, exist_ok=True)
root.joinpath(".env").write_text(
"\n".join(f"{k}={v}" for k, v in os.environ.items()),
"\n".join(f"{k}={v}" for k, v in os.environ.items())
)
@@ -158,6 +157,7 @@ def retry(fn: Callable, timeout: int = 900) -> None:
"""Call the given function repeatedly, with 1 second intervals,
until it returns True or a timeout is reached.
"""
for _ in range(timeout):
if fn(False):
return
@@ -189,7 +189,7 @@ class Machine:
return self.get_systemd_process()
def start(self) -> None:
prepare_machine_root(self.rootdir)
prepare_machine_root(self.name, self.rootdir)
init_test_environment()
cmd = [
"systemd-nspawn",
@@ -212,12 +212,8 @@ class Machine:
self.process = subprocess.Popen(cmd, stdout=subprocess.PIPE, text=True, env=env)
def get_systemd_process(self) -> int:
if self.process is None:
msg = "Machine not started"
raise RuntimeError(msg)
if self.process.stdout is None:
msg = "Machine has no stdout"
raise RuntimeError(msg)
assert self.process is not None, "Machine not started"
assert self.process.stdout is not None, "Machine has no stdout"
for line in self.process.stdout:
print(line, end="")
@@ -234,9 +230,9 @@ class Machine:
.read_text()
.split()
)
if len(childs) != 1:
msg = f"Expected exactly one child process for systemd-nspawn, got {childs}"
raise RuntimeError(msg)
assert len(childs) == 1, (
f"Expected exactly one child process for systemd-nspawn, got {childs}"
)
try:
return int(childs[0])
except ValueError as e:
@@ -256,9 +252,7 @@ class Machine:
def tuple_from_line(line: str) -> tuple[str, str]:
match = line_pattern.match(line)
if match is None:
msg = f"Failed to parse line: {line}"
raise RuntimeError(msg)
assert match is not None
return match[1], match[2]
return dict(
@@ -286,11 +280,12 @@ class Machine:
def execute(
self,
command: str,
check_return: bool = True, # noqa: ARG002
check_output: bool = True, # noqa: ARG002
check_return: bool = True,
check_output: bool = True,
timeout: int | None = 900,
) -> subprocess.CompletedProcess:
"""Execute a shell command, returning a list `(status, stdout)`.
"""
Execute a shell command, returning a list `(status, stdout)`.
Commands are run with `set -euo pipefail` set:
@@ -321,21 +316,21 @@ class Machine:
`timeout` parameter, e.g., `execute(cmd, timeout=10)` or
`execute(cmd, timeout=None)`. The default is 900 seconds.
"""
# Always run command with shell opts
command = f"set -eo pipefail; source /etc/profile; set -xu; {command}"
return subprocess.run(
proc = subprocess.run(
self.nsenter_command(command),
timeout=timeout,
check=False,
stdout=subprocess.PIPE,
text=True,
)
return proc
def nested(
self,
msg: str,
attrs: dict[str, str] | None = None,
self, msg: str, attrs: dict[str, str] | None = None
) -> _GeneratorContextManager:
if attrs is None:
attrs = {}
@@ -344,7 +339,8 @@ class Machine:
return self.logger.nested(msg, my_attrs)
def systemctl(self, q: str) -> subprocess.CompletedProcess:
"""Runs `systemctl` commands with optional support for
"""
Runs `systemctl` commands with optional support for
`systemctl --user`
```py
@@ -359,7 +355,8 @@ class Machine:
return self.execute(f"systemctl {q}")
def wait_until_succeeds(self, command: str, timeout: int = 900) -> str:
"""Repeat a shell command with 1-second intervals until it succeeds.
"""
Repeat a shell command with 1-second intervals until it succeeds.
Has a default timeout of 900 seconds which can be modified, e.g.
`wait_until_succeeds(cmd, timeout=10)`. See `execute` for details on
command execution.
@@ -377,17 +374,18 @@ class Machine:
return output
def wait_for_open_port(
self,
port: int,
addr: str = "localhost",
timeout: int = 900,
self, port: int, addr: str = "localhost", timeout: int = 900
) -> None:
"""Wait for a port to be open on the given address."""
"""
Wait for a port to be open on the given address.
"""
command = f"nc -z {shlex.quote(addr)} {port}"
self.wait_until_succeeds(command, timeout=timeout)
def wait_for_file(self, filename: str, timeout: int = 30) -> None:
"""Waits until the file exists in the machine's file system."""
"""
Waits until the file exists in the machine's file system.
"""
def check_file(_last_try: bool) -> bool:
result = self.execute(f"test -e {filename}")
@@ -397,7 +395,8 @@ class Machine:
retry(check_file, timeout)
def wait_for_unit(self, unit: str, timeout: int = 900) -> None:
"""Wait for a systemd unit to get into "active" state.
"""
Wait for a systemd unit to get into "active" state.
Throws exceptions on "failed" and "inactive" states as well as after
timing out.
"""
@@ -442,7 +441,9 @@ class Machine:
return res.stdout
def shutdown(self) -> None:
"""Shut down the machine, waiting for the VM to exit."""
"""
Shut down the machine, waiting for the VM to exit.
"""
if self.process:
self.process.terminate()
self.process.wait()
@@ -556,7 +557,7 @@ class Driver:
rootdir=tempdir_path / container.name,
out_dir=self.out_dir,
logger=self.logger,
),
)
)
def start_all(self) -> None:
@@ -574,15 +575,13 @@ class Driver:
# We lauch a sleep here, so we can pgrep the process cmdline for
# the uuid
sleep = shutil.which("sleep")
if sleep is None:
msg = "sleep command not found"
raise RuntimeError(msg)
assert sleep is not None, "sleep command not found"
machine.execute(
f"systemd-run /bin/sh -c '{sleep} 999999999 && echo {nspawn_uuid}'",
)
print(
f"To attach to container {machine.name} run on the same machine that runs the test:",
f"To attach to container {machine.name} run on the same machine that runs the test:"
)
print(
" ".join(
@@ -604,8 +603,8 @@ class Driver:
"-c",
"bash",
Style.RESET_ALL,
],
),
]
)
)
def test_symbols(self) -> dict[str, Any]:
@@ -624,13 +623,13 @@ class Driver:
"additionally exposed symbols:\n "
+ ", ".join(m.name for m in self.machines)
+ ",\n "
+ ", ".join(list(general_symbols.keys())),
+ ", ".join(list(general_symbols.keys()))
)
return {**general_symbols, **machine_symbols}
def test_script(self) -> None:
"""Run the test script"""
exec(self.testscript, self.test_symbols(), None) # noqa: S102
exec(self.testscript, self.test_symbols(), None)
def run_tests(self) -> None:
"""Run the test script (for non-interactive test runs)"""

View File

@@ -25,31 +25,27 @@ class AbstractLogger(ABC):
@abstractmethod
@contextmanager
def subtest(
self,
name: str,
attributes: dict[str, str] | None = None,
self, name: str, attributes: dict[str, str] | None = None
) -> Iterator[None]:
pass
@abstractmethod
@contextmanager
def nested(
self,
message: str,
attributes: dict[str, str] | None = None,
self, message: str, attributes: dict[str, str] | None = None
) -> Iterator[None]:
pass
@abstractmethod
def info(self, *args: Any, **kwargs: Any) -> None:
def info(self, *args: Any, **kwargs: Any) -> None: # type: ignore
pass
@abstractmethod
def warning(self, *args: Any, **kwargs: Any) -> None:
def warning(self, *args: Any, **kwargs: Any) -> None: # type: ignore
pass
@abstractmethod
def error(self, *args: Any, **kwargs: Any) -> None:
def error(self, *args: Any, **kwargs: Any) -> None: # type: ignore
pass
@abstractmethod
@@ -63,8 +59,6 @@ class AbstractLogger(ABC):
class JunitXMLLogger(AbstractLogger):
class TestCaseState:
"""State tracking for individual test cases in JUnit XML reports."""
def __init__(self) -> None:
self.stdout = ""
self.stderr = ""
@@ -72,7 +66,7 @@ class JunitXMLLogger(AbstractLogger):
def __init__(self, outfile: Path) -> None:
self.tests: dict[str, JunitXMLLogger.TestCaseState] = {
"main": self.TestCaseState(),
"main": self.TestCaseState()
}
self.currentSubtest = "main"
self.outfile: Path = outfile
@@ -80,16 +74,12 @@ class JunitXMLLogger(AbstractLogger):
atexit.register(self.close)
def log(self, message: str, attributes: dict[str, str] | None = None) -> None:
del attributes # Unused but kept for API compatibility
self.tests[self.currentSubtest].stdout += message + os.linesep
@contextmanager
def subtest(
self,
name: str,
attributes: dict[str, str] | None = None,
self, name: str, attributes: dict[str, str] | None = None
) -> Iterator[None]:
del attributes # Unused but kept for API compatibility
old_test = self.currentSubtest
self.tests.setdefault(name, self.TestCaseState())
self.currentSubtest = name
@@ -100,24 +90,18 @@ class JunitXMLLogger(AbstractLogger):
@contextmanager
def nested(
self,
message: str,
attributes: dict[str, str] | None = None,
self, message: str, attributes: dict[str, str] | None = None
) -> Iterator[None]:
del attributes # Unused but kept for API compatibility
self.log(message)
yield
def info(self, *args: Any, **kwargs: Any) -> None:
del kwargs # Unused but kept for API compatibility
self.tests[self.currentSubtest].stdout += args[0] + os.linesep
def warning(self, *args: Any, **kwargs: Any) -> None:
del kwargs # Unused but kept for API compatibility
self.tests[self.currentSubtest].stdout += args[0] + os.linesep
def error(self, *args: Any, **kwargs: Any) -> None:
del kwargs # Unused but kept for API compatibility
self.tests[self.currentSubtest].stderr += args[0] + os.linesep
self.tests[self.currentSubtest].failure = True
@@ -160,9 +144,7 @@ class CompositeLogger(AbstractLogger):
@contextmanager
def subtest(
self,
name: str,
attributes: dict[str, str] | None = None,
self, name: str, attributes: dict[str, str] | None = None
) -> Iterator[None]:
with ExitStack() as stack:
for logger in self.logger_list:
@@ -171,24 +153,22 @@ class CompositeLogger(AbstractLogger):
@contextmanager
def nested(
self,
message: str,
attributes: dict[str, str] | None = None,
self, message: str, attributes: dict[str, str] | None = None
) -> Iterator[None]:
with ExitStack() as stack:
for logger in self.logger_list:
stack.enter_context(logger.nested(message, attributes))
yield
def info(self, *args: Any, **kwargs: Any) -> None: # type: ignore[no-untyped-def]
def info(self, *args: Any, **kwargs: Any) -> None: # type: ignore
for logger in self.logger_list:
logger.info(*args, **kwargs)
def warning(self, *args: Any, **kwargs: Any) -> None: # type: ignore[no-untyped-def]
def warning(self, *args: Any, **kwargs: Any) -> None: # type: ignore
for logger in self.logger_list:
logger.warning(*args, **kwargs)
def error(self, *args: Any, **kwargs: Any) -> None: # type: ignore[no-untyped-def]
def error(self, *args: Any, **kwargs: Any) -> None: # type: ignore
for logger in self.logger_list:
logger.error(*args, **kwargs)
sys.exit(1)
@@ -220,24 +200,19 @@ class TerminalLogger(AbstractLogger):
@contextmanager
def subtest(
self,
name: str,
attributes: dict[str, str] | None = None,
self, name: str, attributes: dict[str, str] | None = None
) -> Iterator[None]:
with self.nested("subtest: " + name, attributes):
yield
@contextmanager
def nested(
self,
message: str,
attributes: dict[str, str] | None = None,
self, message: str, attributes: dict[str, str] | None = None
) -> Iterator[None]:
self._eprint(
self.maybe_prefix(
Style.BRIGHT + Fore.GREEN + message + Style.RESET_ALL,
attributes,
),
Style.BRIGHT + Fore.GREEN + message + Style.RESET_ALL, attributes
)
)
tic = time.time()
@@ -245,13 +220,13 @@ class TerminalLogger(AbstractLogger):
toc = time.time()
self.log(f"(finished: {message}, in {toc - tic:.2f} seconds)")
def info(self, *args: Any, **kwargs: Any) -> None: # type: ignore[no-untyped-def]
def info(self, *args: Any, **kwargs: Any) -> None: # type: ignore
self.log(*args, **kwargs)
def warning(self, *args: Any, **kwargs: Any) -> None: # type: ignore[no-untyped-def]
def warning(self, *args: Any, **kwargs: Any) -> None: # type: ignore
self.log(*args, **kwargs)
def error(self, *args: Any, **kwargs: Any) -> None: # type: ignore[no-untyped-def]
def error(self, *args: Any, **kwargs: Any) -> None: # type: ignore
self.log(*args, **kwargs)
def print_serial_logs(self, enable: bool) -> None:
@@ -284,9 +259,7 @@ class XMLLogger(AbstractLogger):
return "".join(ch for ch in message if unicodedata.category(ch)[0] != "C")
def maybe_prefix(
self,
message: str,
attributes: dict[str, str] | None = None,
self, message: str, attributes: dict[str, str] | None = None
) -> str:
if attributes and "machine" in attributes:
return f"{attributes['machine']}: {message}"
@@ -297,13 +270,13 @@ class XMLLogger(AbstractLogger):
self.xml.characters(message)
self.xml.endElement("line")
def info(self, *args: Any, **kwargs: Any) -> None: # type: ignore[no-untyped-def]
def info(self, *args: Any, **kwargs: Any) -> None: # type: ignore
self.log(*args, **kwargs)
def warning(self, *args: Any, **kwargs: Any) -> None: # type: ignore[no-untyped-def]
def warning(self, *args: Any, **kwargs: Any) -> None: # type: ignore
self.log(*args, **kwargs)
def error(self, *args: Any, **kwargs: Any) -> None: # type: ignore[no-untyped-def]
def error(self, *args: Any, **kwargs: Any) -> None: # type: ignore
self.log(*args, **kwargs)
def log(self, message: str, attributes: dict[str, str] | None = None) -> None:
@@ -336,18 +309,14 @@ class XMLLogger(AbstractLogger):
@contextmanager
def subtest(
self,
name: str,
attributes: dict[str, str] | None = None,
self, name: str, attributes: dict[str, str] | None = None
) -> Iterator[None]:
with self.nested("subtest: " + name, attributes):
yield
@contextmanager
def nested(
self,
message: str,
attributes: dict[str, str] | None = None,
self, message: str, attributes: dict[str, str] | None = None
) -> Iterator[None]:
if attributes is None:
attributes = {}

View File

@@ -8,10 +8,6 @@
{
imports = lib.optional (_class == "nixos") (
lib.mkIf config.clan.core.enableRecommendedDefaults {
# Enable automatic state-version generation.
clan.core.settings.state-version.enable = lib.mkDefault true;
# Use systemd during boot as well except:
# - systems with raids as this currently require manual configuration: https://github.com/NixOS/nixpkgs/issues/210210
# - for containers we currently rely on the `stage-2` init script that sets up our /etc
@@ -41,7 +37,6 @@
};
config = lib.mkIf config.clan.core.enableRecommendedDefaults {
# This disables the HTML manual and `nixos-help` command but leaves
# `man configuration.nix`
documentation.doc.enable = lib.mkDefault false;

View File

@@ -1,17 +1,40 @@
{ ... }:
{
perSystem.clan.nixosTests.machine-id = {
perSystem =
{ ... }:
{
clan.nixosTests.machine-id = {
name = "service-machine-id";
name = "service-machine-id";
clan = {
directory = ./.;
machines.server = {
clan.core.settings.machine-id.enable = true;
clan = {
directory = ./.;
# Workaround until we can use nodes.server = { };
modules."@clan/importer" = ../../../../clanServices/importer;
inventory = {
machines.server = { };
instances.importer = {
module.name = "@clan/importer";
module.input = "self";
roles.default.tags.all = { };
roles.default.extraModules = [
{
# Test machine ID generation
clan.core.settings.machine-id.enable = true;
}
];
};
};
};
# TODO: Broken. Use instead of importer after fixing.
# nodes.server = { };
# This is not an actual vm test, this is a workaround to
# generate the needed vars for the eval test.
testScript = "";
};
};
# This is not an actual vm test, this is a workaround to
# generate the needed vars for the eval test.
testScript = "";
};
}

View File

@@ -10,14 +10,30 @@
clan = {
directory = ./.;
machines.machine = {
clan.core.postgresql.enable = true;
clan.core.postgresql.users.test = { };
clan.core.postgresql.databases.test.create.options.OWNER = "test";
clan.core.settings.directory = ./.;
# Workaround until we can use nodes.machine = { };
modules."@clan/importer" = ../../../../clanServices/importer;
inventory = {
machines.machine = { };
instances.importer = {
module.name = "@clan/importer";
module.input = "self";
roles.default.tags.all = { };
roles.default.extraModules = [
{
clan.core.postgresql.enable = true;
clan.core.postgresql.users.test = { };
clan.core.postgresql.databases.test.create.options.OWNER = "test";
clan.core.settings.directory = ./.;
}
];
};
};
};
# TODO: Broken. Use instead of importer after fixing.
# nodes.machine = { };
testScript =
let
runpg = "runuser -u postgres -- /run/current-system/sw/bin/psql";

View File

@@ -9,11 +9,28 @@
clan = {
directory = ./.;
machines.server = {
clan.core.settings.state-version.enable = true;
# Workaround until we can use nodes.server = { };
modules."@clan/importer" = ../../../../clanServices/importer;
inventory = {
machines.server = { };
instances.importer = {
module.name = "@clan/importer";
module.input = "self";
roles.default.tags.all = { };
roles.default.extraModules = [
{
clan.core.settings.state-version.enable = true;
}
];
};
};
};
# TODO: Broken. Use instead of importer after fixing.
# nodes.server = { };
# This is not an actual vm test, this is a workaround to
# generate the needed vars for the eval test.
testScript = "";

View File

@@ -304,15 +304,6 @@ in
description = "The unix file mode of the file. Must be a 4-digit octal number.";
default = "0400";
};
exists = mkOption {
description = ''
Returns true if the file exists, This is used to guard against reading not set value in evaluation.
This currently only works for non secret files.
'';
type = bool;
default = if file.config.secret then throw "Cannot determine existance of secret file" else false;
defaultText = "Throws error because the existance of a secret file cannot be determined";
};
value =
mkOption {
description = ''

View File

@@ -25,7 +25,7 @@ in
);
value = mkIf (file.config.secret == false) (
# dynamically adjust priority to allow overriding with mkDefault in case the file is not found
if file.config.exists then
if (pathExists file.config.flakePath) then
# if the file is found it should have normal priority
readFile file.config.flakePath
else
@@ -34,7 +34,6 @@ in
throw "Please run `clan vars generate ${config.clan.core.settings.machine.name}` as file was not found: ${file.config.path}"
)
);
exists = mkIf (file.config.secret == false) (pathExists file.config.flakePath);
};
};
}

View File

@@ -16,10 +16,6 @@ from pathlib import Path
from tempfile import TemporaryDirectory
from typing import Any
# Constants
NODE_ID_LENGTH = 10
NETWORK_ID_LENGTH = 16
class ClanError(Exception):
pass
@@ -59,9 +55,9 @@ class Identity:
def node_id(self) -> str:
nid = self.public.split(":")[0]
if len(nid) != NODE_ID_LENGTH:
msg = f"node_id must be {NODE_ID_LENGTH} characters long, got {len(nid)}: {nid}"
raise ClanError(msg)
assert len(nid) == 10, (
f"node_id must be 10 characters long, got {len(nid)}: {nid}"
)
return nid
@@ -88,10 +84,9 @@ class ZerotierController:
headers["Content-Type"] = "application/json"
headers["X-ZT1-AUTH"] = self.authtoken
url = f"http://127.0.0.1:{self.port}{path}"
# Safe: only connecting to localhost zerotier API
req = urllib.request.Request(url, headers=headers, method=method, data=body) # noqa: S310
with urllib.request.urlopen(req, timeout=5) as resp: # noqa: S310
return json.load(resp)
req = urllib.request.Request(url, headers=headers, method=method, data=body)
resp = urllib.request.urlopen(req)
return json.load(resp)
def status(self) -> dict[str, Any]:
return self._http_request("/status")
@@ -177,9 +172,9 @@ def create_identity() -> Identity:
def compute_zerotier_ip(network_id: str, identity: Identity) -> ipaddress.IPv6Address:
if len(network_id) != NETWORK_ID_LENGTH:
msg = f"network_id must be {NETWORK_ID_LENGTH} characters long, got '{network_id}'"
raise ClanError(msg)
assert len(network_id) == 16, (
f"network_id must be 16 characters long, got '{network_id}'"
)
nwid = int(network_id, 16)
node_id = int(identity.node_id(), 16)
addr_parts = bytearray(
@@ -200,7 +195,7 @@ def compute_zerotier_ip(network_id: str, identity: Identity) -> ipaddress.IPv6Ad
(node_id >> 16) & 0xFF,
(node_id >> 8) & 0xFF,
(node_id) & 0xFF,
],
]
)
return ipaddress.IPv6Address(bytes(addr_parts))
@@ -208,10 +203,7 @@ def compute_zerotier_ip(network_id: str, identity: Identity) -> ipaddress.IPv6Ad
def main() -> None:
parser = argparse.ArgumentParser()
parser.add_argument(
"--mode",
choices=["network", "identity"],
required=True,
type=str,
"--mode", choices=["network", "identity"], required=True, type=str
)
parser.add_argument("--ip", type=Path, required=True)
parser.add_argument("--identity-secret", type=Path, required=True)

7
nixosModules/clanCore/zerotier/genmoon.py Executable file → Normal file
View File

@@ -6,12 +6,9 @@ import sys
from pathlib import Path
from tempfile import NamedTemporaryFile
# Constants
REQUIRED_ARGS = 4
def main() -> None:
if len(sys.argv) != REQUIRED_ARGS:
if len(sys.argv) != 4:
print("Usage: genmoon.py <moon.json> <endpoint.json> <moons.d>")
sys.exit(1)
moon_json_path = sys.argv[1]
@@ -20,7 +17,7 @@ def main() -> None:
moon_json = json.loads(Path(moon_json_path).read_text())
moon_json["roots"][0]["stableEndpoints"] = json.loads(
Path(endpoint_config).read_text(),
Path(endpoint_config).read_text()
)
with NamedTemporaryFile("w") as f:

View File

@@ -12,14 +12,8 @@ let
(builtins.match "linux_[0-9]+_[0-9]+" name) != null
&& (builtins.tryEval kernelPackages).success
&& (
let
zfsPackage =
if isUnstable then
kernelPackages.zfs_unstable
else
kernelPackages.${pkgs.zfs.kernelModuleAttribute};
in
!(zfsPackage.meta.broken or false)
(!isUnstable && !kernelPackages.zfs.meta.broken)
|| (isUnstable && !kernelPackages.zfs_unstable.meta.broken)
)
) pkgs.linuxKernel.packages;
latestKernelPackage = lib.last (
@@ -30,5 +24,5 @@ let
in
{
# Note this might jump back and worth as kernel get added or removed.
boot.kernelPackages = lib.mkIf (lib.meta.availableOn pkgs.hostPlatform pkgs.zfs) latestKernelPackage;
boot.kernelPackages = latestKernelPackage;
}

59
pkgs/agit/README.md Normal file
View File

@@ -0,0 +1,59 @@
# agit
A helper script for the AGit workflow with a gitea instance.
<!-- `$ agit --help` -->
```
usage: agit [-h] {create,c,list,l} ...
AGit utility for creating and pulling PRs
positional arguments:
{create,c,list,l} Commands
create (c) Create an AGit PR
list (l) List open AGit pull requests
options:
-h, --help show this help message and exit
The defaults that are assumed are:
TARGET_REMOTE_REPOSITORY = origin
DEFAULT_TARGET_BRANCH = main
Examples:
$ agit create
Opens editor to compose PR title and description (first line is title, rest is body)
$ agit create --auto
Creates PR using latest commit message automatically
$ agit create --topic "my-feature"
Set a custom topic.
$ agit create --force
Force push to a certain topic
$ agit list
Lists all open pull requests for the current repository
```
References:
- https://docs.gitea.com/usage/agit
- https://git-repo.info/en/2020/03/agit-flow-and-git-repo/
## How to fetch AGit PR's
For a hypothetical PR with the number #4077:
```
git fetch origin pull/4077/head:your-favorite-name
```
Replace `your-favorite-name` with your preferred branch name.
You can push back to the PR with with:
```
agit create --topic="The topic of the open PR"
```

570
pkgs/agit/agit.py Normal file
View File

@@ -0,0 +1,570 @@
import argparse
import contextlib
import json
import os
import subprocess
import sys
import tempfile
import urllib.error
import urllib.request
from pathlib import Path
# push origin HEAD:refs/for/main
# HEAD: The target branch
# origin: The target repository (not a fork!)
# HEAD: The local branch containing the changes you are proposing
TARGET_REMOTE_REPOSITORY = "origin"
DEFAULT_TARGET_BRANCH = "main"
def get_gitea_api_url(remote: str = "origin") -> str:
"""Parse the gitea api url, this parser is fairly naive, but should work for most setups"""
exit_code, remote_url, error = run_git_command(["git", "remote", "get-url", remote])
if exit_code != 0:
print(f"Error getting remote URL for '{remote}': {error}")
sys.exit(1)
# Parse different remote URL formats
# SSH formats: git@git.clan.lol:clan/clan-core.git or gitea@git.clan.lol:clan/clan-core.git
# HTTPS format: https://git.clan.lol/clan/clan-core.git
if (
"@" in remote_url
and ":" in remote_url
and not remote_url.startswith("https://")
):
# SSH format: [user]@git.clan.lol:clan/clan-core.git
host_and_path = remote_url.split("@")[1] # git.clan.lol:clan/clan-core.git
host = host_and_path.split(":")[0] # git.clan.lol
repo_path = host_and_path.split(":")[1] # clan/clan-core.git
if repo_path.endswith(".git"):
repo_path = repo_path[:-4] # clan/clan-core
elif remote_url.startswith("https://"):
# HTTPS format: https://git.clan.lol/clan/clan-core.git
url_parts = remote_url.replace("https://", "").split("/")
host = url_parts[0] # git.clan.lol
repo_path = "/".join(url_parts[1:]) # clan/clan-core.git
if repo_path.endswith(".git"):
repo_path = repo_path.removesuffix(".git") # clan/clan-core
else:
print(f"Unsupported remote URL format: {remote_url}")
sys.exit(1)
api_url = f"https://{host}/api/v1/repos/{repo_path}/pulls"
return api_url
def fetch_open_prs(remote: str = "origin") -> list[dict]:
"""Fetch open pull requests from the Gitea API."""
api_url = get_gitea_api_url(remote)
try:
with urllib.request.urlopen(f"{api_url}?state=open") as response:
data = json.loads(response.read().decode())
return data
except urllib.error.URLError as e:
print(f"Error fetching PRs from {api_url}: {e}")
sys.exit(1)
except json.JSONDecodeError as e:
print(f"Error parsing JSON response: {e}")
sys.exit(1)
def get_repo_info_from_api_url(api_url: str) -> tuple[str, str]:
"""Extract repository owner and name from API URL."""
# api_url format: https://git.clan.lol/api/v1/repos/clan/clan-core/pulls
parts = api_url.split("/")
if len(parts) >= 6 and "repos" in parts:
repo_index = parts.index("repos")
if repo_index + 2 < len(parts):
owner = parts[repo_index + 1]
repo_name = parts[repo_index + 2]
return owner, repo_name
msg = f"Invalid API URL format: {api_url}"
raise ValueError(msg)
def fetch_pr_statuses(
repo_owner: str, repo_name: str, commit_sha: str, host: str
) -> list[dict]:
"""Fetch CI statuses for a specific commit SHA."""
status_url = (
f"https://{host}/api/v1/repos/{repo_owner}/{repo_name}/statuses/{commit_sha}"
)
try:
request = urllib.request.Request(status_url)
with urllib.request.urlopen(request, timeout=3) as response:
data = json.loads(response.read().decode())
return data
except (urllib.error.URLError, json.JSONDecodeError, TimeoutError):
# Fail silently for individual status requests to keep listing fast
return []
def get_latest_status_by_context(statuses: list[dict]) -> dict[str, str]:
"""Group statuses by context and return the latest status for each context."""
context_statuses = {}
for status in statuses:
context = status.get("context", "unknown")
created_at = status.get("created_at", "")
status_state = status.get("status", "unknown")
if (
context not in context_statuses
or created_at > context_statuses[context]["created_at"]
):
context_statuses[context] = {
"status": status_state,
"created_at": created_at,
}
return {context: info["status"] for context, info in context_statuses.items()}
def status_to_emoji(status: str) -> str:
"""Convert status string to emoji."""
status_map = {"success": "", "failure": "", "pending": "🟡", "error": ""}
return status_map.get(status.lower(), "")
def create_osc8_link(url: str, text: str) -> str:
return f"\033]8;;{url}\033\\{text}\033]8;;\033\\"
def format_pr_with_status(pr: dict, remote: str = "origin") -> str:
"""Format PR title with status emojis and OSC8 link."""
title = pr["title"]
pr_url = pr.get("html_url", "")
commit_sha = pr.get("head", {}).get("sha")
if not commit_sha:
if pr_url:
return create_osc8_link(pr_url, title)
return title
try:
api_url = get_gitea_api_url(remote)
repo_owner, repo_name = get_repo_info_from_api_url(api_url)
host = api_url.split("/")[2]
statuses = fetch_pr_statuses(repo_owner, repo_name, commit_sha, host)
if not statuses:
if pr_url:
return create_osc8_link(pr_url, title)
return title
latest_statuses = get_latest_status_by_context(statuses)
emojis = [status_to_emoji(status) for status in latest_statuses.values()]
formatted_title = f"{title} {' '.join(emojis)}" if emojis else title
return create_osc8_link(pr_url, formatted_title) if pr_url else formatted_title
except (ValueError, IndexError):
# If there's any error in processing, just return the title with link if available
if pr_url:
return create_osc8_link(pr_url, title)
return title
def run_git_command(command: list) -> tuple[int, str, str]:
"""Run a git command and return exit code, stdout, and stderr."""
try:
result = subprocess.run(command, capture_output=True, text=True, check=False)
return result.returncode, result.stdout.strip(), result.stderr.strip()
except Exception as e:
return 1, "", str(e)
def get_current_branch_name() -> str:
exit_code, branch_name, error = run_git_command(
["git", "rev-parse", "--abbrev-ref", "HEAD"]
)
if exit_code != 0:
print(f"Error getting branch name: {error}")
sys.exit(1)
return branch_name.strip()
def get_latest_commit_info() -> tuple[str, str]:
"""Get the title and body of the latest commit."""
exit_code, commit_msg, error = run_git_command(
["git", "log", "-1", "--pretty=format:%B"]
)
if exit_code != 0:
print(f"Error getting commit info: {error}")
sys.exit(1)
lines = commit_msg.strip().split("\n")
title = lines[0].strip() if lines else ""
body_lines = []
for line in lines[1:]:
if body_lines or line.strip():
body_lines.append(line)
body = "\n".join(body_lines).strip()
return title, body
def get_commits_since_main() -> list[tuple[str, str]]:
"""Get all commits since main as (title, body) tuples."""
exit_code, commit_log, error = run_git_command(
[
"git",
"log",
"main..HEAD",
"--no-merges",
"--pretty=format:%s|%b|---END---",
]
)
if exit_code != 0:
print(f"Error getting commits since main: {error}")
return []
if not commit_log:
return []
commits = []
commit_messages = commit_log.split("---END---")
for commit_msg in commit_messages:
commit_msg = commit_msg.strip()
if not commit_msg:
continue
parts = commit_msg.split("|")
if len(parts) < 2:
continue
title = parts[0].strip()
body = parts[1].strip() if len(parts) > 1 else ""
if not title:
continue
commits.append((title, body))
return commits
def open_editor_for_pr() -> tuple[str, str]:
"""Open editor to get PR title and description. First line is title, rest is description."""
commits_since_main = get_commits_since_main()
with tempfile.NamedTemporaryFile(
mode="w+", suffix="COMMIT_EDITMSG", delete=False
) as temp_file:
temp_file.flush()
temp_file_path = temp_file.name
for title, body in commits_since_main:
temp_file.write(f"{title}\n")
if body:
temp_file.write(f"{body}\n")
temp_file.write("\n")
temp_file.write("\n")
temp_file.write("# Please enter the PR title on the first line.\n")
temp_file.write("# Lines starting with '#' will be ignored.\n")
temp_file.write("# The first line will be used as the PR title.\n")
temp_file.write("# Everything else will be used as the PR description.\n")
temp_file.write(
"# To abort creation of the PR, close editor with an error code.\n"
)
temp_file.write("# In vim for example you can use :cq!\n")
temp_file.write("#\n")
temp_file.write("# All commits since main:\n")
temp_file.write("#\n")
for i, (title, body) in enumerate(commits_since_main, 1):
temp_file.write(f"# Commit {i}:\n")
temp_file.write(f"# {title}\n")
if body:
for line in body.split("\n"):
temp_file.write(f"# {line}\n")
temp_file.write("#\n")
try:
editor = os.environ.get("EDITOR", "vim")
exit_code = subprocess.call([editor, temp_file_path])
if exit_code != 0:
print(f"Editor exited with code {exit_code}.")
print("AGit PR creation has been aborted.")
sys.exit(1)
with Path(temp_file_path).open() as f:
content = f.read()
lines = []
for line in content.split("\n"):
if not line.lstrip().startswith("#"):
lines.append(line)
cleaned_content = "\n".join(lines).strip()
if not cleaned_content:
print("No content provided, aborting.")
sys.exit(0)
content_lines = cleaned_content.split("\n")
title = content_lines[0].strip()
if not title:
print("No title provided, aborting.")
sys.exit(0)
description_lines = []
for line in content_lines[1:]:
if description_lines or line.strip():
description_lines.append(line)
description = "\n".join(description_lines).strip()
return title, description
finally:
with contextlib.suppress(OSError):
Path(temp_file_path).unlink()
def create_agit_push(
remote: str = "origin",
branch: str = "main",
topic: str | None = None,
title: str | None = None,
description: str | None = None,
force_push: bool = False,
local_branch: str = "HEAD",
) -> None:
if topic is None:
if title is not None:
topic = title
else:
topic = get_current_branch_name()
refspec = f"{local_branch}:refs/for/{branch}"
push_cmd = ["git", "push", remote, refspec]
push_cmd.extend(["-o", f"topic={topic}"])
if title:
push_cmd.extend(["-o", f"title={title}"])
if description:
escaped_desc = description.rstrip("\n").replace('"', '\\"')
push_cmd.extend(["-o", f"description={escaped_desc}"])
if force_push:
push_cmd.extend(["-o", "force-push"])
if description:
print(
f" Description: {description[:50]}..."
if len(description) > 50
else f" Description: {description}"
)
print()
exit_code, stdout, stderr = run_git_command(push_cmd)
if stdout:
print(stdout)
if stderr:
print(stderr, file=sys.stderr)
if exit_code != 0:
print("\nPush failed!")
sys.exit(exit_code)
else:
print("\nPush successful!")
def cmd_create(args: argparse.Namespace) -> None:
"""Handle the create subcommand."""
title = args.title
description = args.description
if not args.auto and (title is None or description is None):
editor_title, editor_description = open_editor_for_pr()
if title is None:
title = editor_title
if description is None:
description = editor_description
create_agit_push(
remote=args.remote,
branch=args.branch,
topic=args.topic,
title=title,
description=description,
force_push=args.force,
local_branch=args.local_branch,
)
def cmd_list(args: argparse.Namespace) -> None:
"""Handle the list subcommand."""
prs = fetch_open_prs(args.remote)
if not prs:
print("No open AGit pull requests found.")
return
# This is the only way I found to query the actual AGit PRs
# Gitea doesn't seem to have an actual api endpoint for them
filtered_prs = [pr for pr in prs if pr.get("head", {}).get("label", "") == ""]
if not filtered_prs:
print("No open AGit pull requests found.")
return
for pr in filtered_prs:
formatted_pr = format_pr_with_status(pr, args.remote)
print(formatted_pr)
def create_parser() -> argparse.ArgumentParser:
parser = argparse.ArgumentParser(
prog="agit",
description="AGit utility for creating and pulling PRs",
formatter_class=argparse.RawDescriptionHelpFormatter,
epilog=f"""
The defaults that are assumed are:
TARGET_REMOTE_REPOSITORY = {TARGET_REMOTE_REPOSITORY}
DEFAULT_TARGET_BRANCH = {DEFAULT_TARGET_BRANCH}
Examples:
$ agit create
Opens editor to compose PR title and description (first line is title, rest is body)
$ agit create --auto
Creates PR using latest commit message automatically
$ agit create --topic "my-feature"
Set a custom topic.
$ agit create --force
Force push to a certain topic
$ agit list
Lists all open pull requests for the current repository
""",
)
subparsers = parser.add_subparsers(dest="subcommand", help="Commands")
create_parser = subparsers.add_parser(
"create",
aliases=["c"],
help="Create an AGit PR",
formatter_class=argparse.RawDescriptionHelpFormatter,
epilog="""
Examples:
$ agit create
Opens editor to compose PR title and description (first line is title, rest is body).
$ agit create --auto
Creates PR using latest commit message automatically (old behavior).
$ agit create --topic "my-feature"
Set a custom topic.
$ agit create --force
Force push to a certain topic
""",
)
list_parser = subparsers.add_parser(
"list",
aliases=["l"],
help="List open AGit pull requests",
formatter_class=argparse.RawDescriptionHelpFormatter,
epilog=f"""
Examples:
$ agit list
Lists all open AGit PRs for the current repository.
$ agit list --remote upstream
Lists PRs using the 'upstream' remote instead of '{TARGET_REMOTE_REPOSITORY}'.
""",
)
list_parser.add_argument(
"-r",
"--remote",
default=TARGET_REMOTE_REPOSITORY,
help=f"Git remote to use for fetching PRs (default: {TARGET_REMOTE_REPOSITORY})",
)
create_parser.add_argument(
"-r",
"--remote",
default=TARGET_REMOTE_REPOSITORY,
help=f"Git remote to push to (default: {TARGET_REMOTE_REPOSITORY})",
)
create_parser.add_argument(
"-b",
"--branch",
default=DEFAULT_TARGET_BRANCH,
help=f"Target branch for the PR (default: {DEFAULT_TARGET_BRANCH})",
)
create_parser.add_argument(
"-l",
"--local-branch",
default="HEAD",
help="Local branch to push (default: HEAD)",
)
create_parser.add_argument(
"-t", "--topic", help="Set PR topic (default: current branch name)"
)
create_parser.add_argument(
"--title", help="Set the PR title (default: last commit title)"
)
create_parser.add_argument(
"--description", help="Override the PR description (default: commit body)"
)
create_parser.add_argument(
"-f", "--force", action="store_true", help="Force push the changes"
)
create_parser.add_argument(
"-a",
"--auto",
action="store_true",
help="Skip editor and use commit message automatically",
)
create_parser.set_defaults(func=cmd_create)
list_parser.set_defaults(func=cmd_list)
return parser
def main() -> None:
parser = create_parser()
args = parser.parse_args()
if args.subcommand is None:
parser.print_help()
sys.exit(0)
args.func(args)
if __name__ == "__main__":
main()

27
pkgs/agit/default.nix Normal file
View File

@@ -0,0 +1,27 @@
{
bash,
callPackage,
git,
lib,
openssh,
...
}:
let
writers = callPackage ../builders/script-writers.nix { };
in
writers.writePython3Bin "agit" {
flakeIgnore = [
"E501"
"W503" # treefmt reapplies the conditions to trigger this check
];
makeWrapperArgs = [
"--prefix"
"PATH"
":"
(lib.makeBinPath [
bash
git
openssh
])
];
} ./agit.py

View File

@@ -13,9 +13,7 @@ log = logging.getLogger(__name__)
def main(argv: list[str] = sys.argv) -> int:
parser = argparse.ArgumentParser(description="Clan App")
parser.add_argument(
"--content-uri",
type=str,
help="The URI of the content to display",
"--content-uri", type=str, help="The URI of the content to display"
)
parser.add_argument("--debug", action="store_true", help="Enable debug mode")
parser.add_argument(

View File

@@ -5,7 +5,7 @@ from contextlib import ExitStack
from dataclasses import dataclass, field
from typing import TYPE_CHECKING, Any
from clan_lib.api import ApiError, ApiResponse, ErrorDataClass
from clan_lib.api import ApiResponse
from clan_lib.api.tasks import WebThread
from clan_lib.async_run import set_current_thread_opkey, set_should_cancel
@@ -43,7 +43,7 @@ class ApiBridge(ABC):
def process_request(self, request: BackendRequest) -> None:
"""Process an API request through the middleware chain."""
from .middleware import MiddlewareContext # noqa: PLC0415
from .middleware import MiddlewareContext
with ExitStack() as stack:
context = MiddlewareContext(
@@ -56,25 +56,22 @@ class ApiBridge(ABC):
for middleware in self.middleware_chain:
try:
log.debug(
f"{middleware.__class__.__name__} => {request.method_name}",
f"{middleware.__class__.__name__} => {request.method_name}"
)
middleware.process(context)
except Exception as e: # noqa: BLE001
except Exception as e:
# If middleware fails, handle error
self.send_api_error_response(
request.op_key or "unknown",
str(e),
["middleware_error"],
request.op_key or "unknown", str(e), ["middleware_error"]
)
return
def send_api_error_response(
self,
op_key: str,
error_message: str,
location: list[str],
self, op_key: str, error_message: str, location: list[str]
) -> None:
"""Send an error response."""
from clan_lib.api import ApiError, ErrorDataClass
error_data = ErrorDataClass(
op_key=op_key,
status="error",
@@ -83,7 +80,7 @@ class ApiBridge(ABC):
message="An internal error occured",
description=error_message,
location=location,
),
)
],
)
@@ -110,7 +107,6 @@ class ApiBridge(ABC):
thread_name: Name for the thread (for debugging)
wait_for_completion: Whether to wait for the thread to complete
timeout: Timeout in seconds when waiting for completion
"""
op_key = request.op_key or "unknown"
@@ -120,7 +116,7 @@ class ApiBridge(ABC):
try:
log.debug(
f"Processing {request.method_name} with args {request.args} "
f"and header {request.header} in thread {thread_name}",
f"and header {request.header} in thread {thread_name}"
)
self.process_request(request)
finally:
@@ -128,9 +124,7 @@ class ApiBridge(ABC):
stop_event = threading.Event()
thread = threading.Thread(
target=thread_task,
args=(stop_event,),
name=thread_name,
target=thread_task, args=(stop_event,), name=thread_name
)
thread.start()
@@ -144,7 +138,5 @@ class ApiBridge(ABC):
if thread.is_alive():
stop_event.set() # Cancel the thread
self.send_api_error_response(
op_key,
"Request timeout",
["api_bridge", request.method_name],
op_key, "Request timeout", ["api_bridge", request.method_name]
)

View File

@@ -26,7 +26,8 @@ RESULT: dict[str, SuccessDataClass[list[str] | None] | ErrorDataClass] = {}
def get_clan_folder() -> SuccessDataClass[Flake] | ErrorDataClass:
"""Opens the clan folder using the GTK file dialog.
"""
Opens the clan folder using the GTK file dialog.
Returns the path to the clan folder or an error if it fails.
"""
file_request = FileRequest(
@@ -51,7 +52,7 @@ def get_clan_folder() -> SuccessDataClass[Flake] | ErrorDataClass:
message="No folder selected",
description="You must select a folder to open.",
location=["get_clan_folder"],
),
)
],
)
@@ -65,7 +66,7 @@ def get_clan_folder() -> SuccessDataClass[Flake] | ErrorDataClass:
message="Invalid clan folder",
description=f"The selected folder '{clan_folder}' is not a valid clan folder.",
location=["get_clan_folder"],
),
)
],
)
@@ -91,6 +92,7 @@ def get_system_file(
def gtk_open_file(file_request: FileRequest, op_key: str) -> bool:
def returns(data: SuccessDataClass | ErrorDataClass) -> None:
global RESULT
RESULT[op_key] = data
def on_file_select(file_dialog: Gtk.FileDialog, task: Gio.Task) -> None:
@@ -100,10 +102,8 @@ def gtk_open_file(file_request: FileRequest, op_key: str) -> bool:
selected_path = remove_none([gfile.get_path()])
returns(
SuccessDataClass(
op_key=op_key,
data=selected_path,
status="success",
),
op_key=op_key, data=selected_path, status="success"
)
)
except Exception as e:
log.exception("Error opening file")
@@ -116,9 +116,9 @@ def gtk_open_file(file_request: FileRequest, op_key: str) -> bool:
message=e.__class__.__name__,
description=str(e),
location=["get_system_file"],
),
)
],
),
)
)
def on_file_select_multiple(file_dialog: Gtk.FileDialog, task: Gio.Task) -> None:
@@ -128,10 +128,8 @@ def gtk_open_file(file_request: FileRequest, op_key: str) -> bool:
selected_paths = remove_none([gfile.get_path() for gfile in gfiles])
returns(
SuccessDataClass(
op_key=op_key,
data=selected_paths,
status="success",
),
op_key=op_key, data=selected_paths, status="success"
)
)
else:
returns(SuccessDataClass(op_key=op_key, data=None, status="success"))
@@ -146,9 +144,9 @@ def gtk_open_file(file_request: FileRequest, op_key: str) -> bool:
message=e.__class__.__name__,
description=str(e),
location=["get_system_file"],
),
)
],
),
)
)
def on_folder_select(file_dialog: Gtk.FileDialog, task: Gio.Task) -> None:
@@ -158,10 +156,8 @@ def gtk_open_file(file_request: FileRequest, op_key: str) -> bool:
selected_path = remove_none([gfile.get_path()])
returns(
SuccessDataClass(
op_key=op_key,
data=selected_path,
status="success",
),
op_key=op_key, data=selected_path, status="success"
)
)
else:
returns(SuccessDataClass(op_key=op_key, data=None, status="success"))
@@ -176,9 +172,9 @@ def gtk_open_file(file_request: FileRequest, op_key: str) -> bool:
message=e.__class__.__name__,
description=str(e),
location=["get_system_file"],
),
)
],
),
)
)
def on_save_finish(file_dialog: Gtk.FileDialog, task: Gio.Task) -> None:
@@ -188,10 +184,8 @@ def gtk_open_file(file_request: FileRequest, op_key: str) -> bool:
selected_path = remove_none([gfile.get_path()])
returns(
SuccessDataClass(
op_key=op_key,
data=selected_path,
status="success",
),
op_key=op_key, data=selected_path, status="success"
)
)
else:
returns(SuccessDataClass(op_key=op_key, data=None, status="success"))
@@ -206,9 +200,9 @@ def gtk_open_file(file_request: FileRequest, op_key: str) -> bool:
message=e.__class__.__name__,
description=str(e),
location=["get_system_file"],
),
)
],
),
)
)
dialog = Gtk.FileDialog()

View File

@@ -39,7 +39,7 @@ class ArgumentParsingMiddleware(Middleware):
except Exception as e:
log.exception(
f"Error while parsing arguments for {context.request.method_name}",
f"Error while parsing arguments for {context.request.method_name}"
)
context.bridge.send_api_error_response(
context.request.op_key or "unknown",

View File

@@ -23,9 +23,7 @@ class Middleware(ABC):
"""Process the request through this middleware."""
def register_context_manager(
self,
context: MiddlewareContext,
cm: AbstractContextManager[Any],
self, context: MiddlewareContext, cm: AbstractContextManager[Any]
) -> Any:
"""Register a context manager with the exit stack."""
return context.exit_stack.enter_context(cm)

View File

@@ -25,26 +25,23 @@ class LoggingMiddleware(Middleware):
try:
# Handle log group configuration
log_group: list[str] | None = context.request.header.get("logging", {}).get(
"group_path",
None,
"group_path", None
)
if log_group is not None:
if not isinstance(log_group, list):
msg = f"Expected log_group to be a list, got {type(log_group)}"
raise TypeError(msg) # noqa: TRY301
log.warning(
f"Using log group {log_group} for {context.request.method_name} with op_key {context.request.op_key}",
f"Using log group {log_group} for {context.request.method_name} with op_key {context.request.op_key}"
)
# Create log file
log_file = self.log_manager.create_log_file(
method,
op_key=context.request.op_key or "unknown",
group_path=log_group,
method, op_key=context.request.op_key or "unknown", group_path=log_group
).get_file_path()
except Exception as e:
log.exception(
f"Error while handling request header of {context.request.method_name}",
f"Error while handling request header of {context.request.method_name}"
)
context.bridge.send_api_error_response(
context.request.op_key or "unknown",
@@ -79,8 +76,7 @@ class LoggingMiddleware(Middleware):
line_buffering=True,
)
self.handler = setup_logging(
log.getEffectiveLevel(),
log_file=handler_stream,
log.getEffectiveLevel(), log_file=handler_stream
)
return self
@@ -94,10 +90,10 @@ class LoggingMiddleware(Middleware):
if self.handler:
self.handler.root_logger.removeHandler(self.handler.new_handler)
self.handler.new_handler.close()
if self.original_ctx:
set_async_ctx(self.original_ctx)
if self.log_f:
self.log_f.close()
if self.original_ctx:
set_async_ctx(self.original_ctx)
# Register the logging context manager
self.register_context_manager(context, LoggingContextManager(log_file))

View File

@@ -32,7 +32,7 @@ class MethodExecutionMiddleware(Middleware):
except Exception as e:
log.exception(
f"Error while handling result of {context.request.method_name}",
f"Error while handling result of {context.request.method_name}"
)
context.bridge.send_api_error_response(
context.request.op_key or "unknown",

View File

@@ -1,6 +1,5 @@
import logging
import os
import time
from dataclasses import dataclass
from pathlib import Path
@@ -17,7 +16,6 @@ from clan_app.api.middleware import (
LoggingMiddleware,
MethodExecutionMiddleware,
)
from clan_app.deps.http.http_server import HttpApiServer
from clan_app.deps.webview.webview import Size, SizeHint, Webview
log = logging.getLogger(__name__)
@@ -50,7 +48,7 @@ def app_run(app_opts: ClanAppOptions) -> int:
# Add a log group ["clans", <dynamic_name>, "machines", <dynamic_name>]
log_manager = LogManager(base_dir=user_data_dir() / "clan-app" / "logs")
clan_log_group = LogGroupConfig("clans", "Clans").add_child(
LogGroupConfig("machines", "Machines"),
LogGroupConfig("machines", "Machines")
)
log_manager = log_manager.add_root_group_config(clan_log_group)
# Init LogManager global in log_manager_api module
@@ -66,6 +64,8 @@ def app_run(app_opts: ClanAppOptions) -> int:
# Start HTTP API server if requested
http_server = None
if app_opts.http_api:
from clan_app.deps.http.http_server import HttpApiServer
openapi_file = os.getenv("OPENAPI_FILE", None)
swagger_dist = os.getenv("SWAGGER_UI_DIST", None)
@@ -89,12 +89,14 @@ def app_run(app_opts: ClanAppOptions) -> int:
# HTTP-only mode - keep the server running
log.info("HTTP API server running...")
log.info(
f"Swagger: http://{app_opts.http_host}:{app_opts.http_port}/api/swagger",
f"Swagger: http://{app_opts.http_host}:{app_opts.http_port}/api/swagger"
)
log.info("Press Ctrl+C to stop the server")
try:
# Keep the main thread alive
import time
while True:
time.sleep(1)
except KeyboardInterrupt:
@@ -119,7 +121,7 @@ def app_run(app_opts: ClanAppOptions) -> int:
webview.add_middleware(LoggingMiddleware(log_manager=log_manager))
webview.add_middleware(MethodExecutionMiddleware(api=API))
webview.bind_jsonschema_api(API)
webview.bind_jsonschema_api(API, log_manager=log_manager)
webview.navigate(content_uri)
webview.run()

Binary file not shown.

Before

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 726 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 375 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 717 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 717 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 KiB

View File

@@ -63,9 +63,7 @@ class HttpBridge(ApiBridge, BaseHTTPRequestHandler):
self.send_header("Access-Control-Allow-Headers", "Content-Type")
def _send_json_response_with_status(
self,
data: dict[str, Any],
status_code: int = 200,
self, data: dict[str, Any], status_code: int = 200
) -> None:
"""Send a JSON response with the given status code."""
try:
@@ -84,13 +82,11 @@ class HttpBridge(ApiBridge, BaseHTTPRequestHandler):
response_dict = dataclass_to_dict(response)
self._send_json_response_with_status(response_dict, 200)
log.debug(
f"HTTP response for {response._op_key}: {json.dumps(response_dict, indent=2)}", # noqa: SLF001
f"HTTP response for {response._op_key}: {json.dumps(response_dict, indent=2)}" # noqa: SLF001
)
def _create_success_response(
self,
op_key: str,
data: dict[str, Any],
self, op_key: str, data: dict[str, Any]
) -> BackendResponse:
"""Create a successful API response."""
return BackendResponse(
@@ -102,16 +98,14 @@ class HttpBridge(ApiBridge, BaseHTTPRequestHandler):
def _send_info_response(self) -> None:
"""Send server information response."""
response = self._create_success_response(
"info",
{"message": "Clan API Server", "version": "1.0.0"},
"info", {"message": "Clan API Server", "version": "1.0.0"}
)
self.send_api_response(response)
def _send_methods_response(self) -> None:
"""Send available API methods response."""
response = self._create_success_response(
"methods",
{"methods": list(self.api.functions.keys())},
"methods", {"methods": list(self.api.functions.keys())}
)
self.send_api_response(response)
@@ -148,8 +142,8 @@ class HttpBridge(ApiBridge, BaseHTTPRequestHandler):
self.send_header("Content-Type", content_type)
self.end_headers()
self.wfile.write(file_data)
except (OSError, json.JSONDecodeError, UnicodeDecodeError):
log.exception("Error reading Swagger file")
except Exception as e:
log.error(f"Error reading Swagger file: {e!s}")
self.send_error(500, "Internal Server Error")
def _get_swagger_file_path(self, rel_path: str) -> Path:
@@ -185,19 +179,19 @@ class HttpBridge(ApiBridge, BaseHTTPRequestHandler):
json_data = json.loads(file_data.decode("utf-8"))
server_address = getattr(self.server, "server_address", ("localhost", 80))
json_data["servers"] = [
{"url": f"http://{server_address[0]}:{server_address[1]}/api/v1/"},
{"url": f"http://{server_address[0]}:{server_address[1]}/api/v1/"}
]
file_data = json.dumps(json_data, indent=2).encode("utf-8")
return file_data
def do_OPTIONS(self) -> None:
def do_OPTIONS(self) -> None: # noqa: N802
"""Handle CORS preflight requests."""
self.send_response_only(200)
self._send_cors_headers()
self.end_headers()
def do_GET(self) -> None:
def do_GET(self) -> None: # noqa: N802
"""Handle GET requests."""
parsed_url = urlparse(self.path)
path = parsed_url.path
@@ -211,7 +205,7 @@ class HttpBridge(ApiBridge, BaseHTTPRequestHandler):
else:
self.send_api_error_response("info", "Not Found", ["http_bridge", "GET"])
def do_POST(self) -> None:
def do_POST(self) -> None: # noqa: N802
"""Handle POST requests."""
parsed_url = urlparse(self.path)
path = parsed_url.path
@@ -219,9 +213,7 @@ class HttpBridge(ApiBridge, BaseHTTPRequestHandler):
# Validate API path
if not path.startswith("/api/v1/"):
self.send_api_error_response(
"post",
f"Path not found: {path}",
["http_bridge", "POST"],
"post", f"Path not found: {path}", ["http_bridge", "POST"]
)
return
@@ -229,9 +221,7 @@ class HttpBridge(ApiBridge, BaseHTTPRequestHandler):
method_name = path[len("/api/v1/") :]
if not method_name:
self.send_api_error_response(
"post",
"Method name required",
["http_bridge", "POST"],
"post", "Method name required", ["http_bridge", "POST"]
)
return
@@ -252,7 +242,7 @@ class HttpBridge(ApiBridge, BaseHTTPRequestHandler):
gen_op_key = str(uuid.uuid4())
try:
self._handle_api_request(method_name, request_data, gen_op_key)
except RuntimeError as e:
except Exception as e:
log.exception(f"Error processing API request {method_name}")
self.send_api_error_response(
gen_op_key,
@@ -264,10 +254,10 @@ class HttpBridge(ApiBridge, BaseHTTPRequestHandler):
"""Read and parse the request body. Returns None if there was an error."""
try:
content_length = int(self.headers.get("Content-Length", 0))
if content_length == 0:
return {}
body = self.rfile.read(content_length)
return json.loads(body.decode("utf-8"))
if content_length > 0:
body = self.rfile.read(content_length)
return json.loads(body.decode("utf-8"))
return {}
except json.JSONDecodeError:
self.send_api_error_response(
"post",
@@ -275,7 +265,7 @@ class HttpBridge(ApiBridge, BaseHTTPRequestHandler):
["http_bridge", "POST", method_name],
)
return None
except (OSError, ValueError, UnicodeDecodeError) as e:
except Exception as e:
self.send_api_error_response(
"post",
f"Error reading request: {e!s}",
@@ -299,26 +289,19 @@ class HttpBridge(ApiBridge, BaseHTTPRequestHandler):
# Create API request
api_request = BackendRequest(
method_name=method_name,
args=body,
header=header,
op_key=op_key,
method_name=method_name, args=body, header=header, op_key=op_key
)
except (KeyError, TypeError, ValueError) as e:
except Exception as e:
self.send_api_error_response(
gen_op_key,
str(e),
["http_bridge", method_name],
gen_op_key, str(e), ["http_bridge", method_name]
)
return
self._process_api_request_in_thread(api_request)
self._process_api_request_in_thread(api_request, method_name)
def _parse_request_data(
self,
request_data: dict[str, Any],
gen_op_key: str,
self, request_data: dict[str, Any], gen_op_key: str
) -> tuple[dict[str, Any], dict[str, Any], str]:
"""Parse and validate request data components."""
header = request_data.get("header", {})
@@ -361,8 +344,7 @@ class HttpBridge(ApiBridge, BaseHTTPRequestHandler):
pass
def _process_api_request_in_thread(
self,
api_request: BackendRequest,
self, api_request: BackendRequest, method_name: str
) -> None:
"""Process the API request in a separate thread."""
stop_event = threading.Event()
@@ -376,7 +358,7 @@ class HttpBridge(ApiBridge, BaseHTTPRequestHandler):
log.debug(
f"Processing {request.method_name} with args {request.args} "
f"and header {request.header}",
f"and header {request.header}"
)
self.process_request(request)

View File

@@ -4,11 +4,13 @@ import json
import logging
import threading
import time
from unittest.mock import Mock
from urllib.request import Request, urlopen
import pytest
from clan_lib.api import MethodRegistry, tasks
from clan_lib.async_run import is_async_cancelled
from clan_lib.log_manager import LogManager
from clan_app.api.middleware import (
ArgumentParsingMiddleware,
@@ -51,20 +53,30 @@ def mock_api() -> MethodRegistry:
return api
@pytest.fixture
def mock_log_manager() -> Mock:
"""Create a mock log manager."""
log_manager = Mock(spec=LogManager)
log_manager.create_log_file.return_value.get_file_path.return_value = Mock()
log_manager.create_log_file.return_value.get_file_path.return_value.open.return_value = Mock()
return log_manager
@pytest.fixture
def http_bridge(
mock_api: MethodRegistry,
mock_api: MethodRegistry, mock_log_manager: Mock
) -> tuple[MethodRegistry, tuple]:
"""Create HTTP bridge dependencies for testing."""
middleware_chain = (
ArgumentParsingMiddleware(api=mock_api),
# LoggingMiddleware(log_manager=mock_log_manager),
MethodExecutionMiddleware(api=mock_api),
)
return mock_api, middleware_chain
@pytest.fixture
def http_server(mock_api: MethodRegistry) -> HttpApiServer:
def http_server(mock_api: MethodRegistry, mock_log_manager: Mock) -> HttpApiServer:
"""Create HTTP server with mock dependencies."""
server = HttpApiServer(
api=mock_api,
@@ -74,6 +86,7 @@ def http_server(mock_api: MethodRegistry) -> HttpApiServer:
# Add middleware
server.add_middleware(ArgumentParsingMiddleware(api=mock_api))
# server.add_middleware(LoggingMiddleware(log_manager=mock_log_manager))
server.add_middleware(MethodExecutionMiddleware(api=mock_api))
# Bridge will be created automatically when accessed
@@ -100,6 +113,7 @@ class TestHttpBridge:
# The actual HTTP handling will be tested through the server integration tests
assert len(middleware_chain) == 2
assert isinstance(middleware_chain[0], ArgumentParsingMiddleware)
# assert isinstance(middleware_chain[1], LoggingMiddleware)
assert isinstance(middleware_chain[1], MethodExecutionMiddleware)
@@ -156,7 +170,7 @@ class TestHttpApiServer:
data=json.dumps(request_data).encode(),
headers={"Content-Type": "application/json"},
)
response = urlopen(req) # noqa: S310
response = urlopen(req)
data = json.loads(response.read().decode())
# Response should be BackendResponse format
@@ -192,7 +206,7 @@ class TestHttpApiServer:
headers={"Content-Type": "application/json"},
)
res = urlopen(req) # noqa: S310
res = urlopen(req)
assert res.status == 200
body = json.loads(res.read().decode())["body"]
assert body["status"] == "error"
@@ -204,7 +218,7 @@ class TestHttpApiServer:
headers={"Content-Type": "application/json"},
)
res = urlopen(req) # noqa: S310
res = urlopen(req)
assert res.status == 200
body = json.loads(res.read().decode())["body"]
assert body["status"] == "error"
@@ -225,7 +239,7 @@ class TestHttpApiServer:
return "OPTIONS"
req: Request = OptionsRequest("http://127.0.0.1:8081/api/call/test_method")
response = urlopen(req) # noqa: S310
response = urlopen(req)
# Check CORS headers
headers = response.info()
@@ -242,8 +256,7 @@ class TestIntegration:
"""Integration tests for HTTP API components."""
def test_full_request_flow(
self,
mock_api: MethodRegistry,
self, mock_api: MethodRegistry, mock_log_manager: Mock
) -> None:
"""Test complete request flow from server to bridge to middleware."""
server: HttpApiServer = HttpApiServer(
@@ -254,6 +267,7 @@ class TestIntegration:
# Add middleware
server.add_middleware(ArgumentParsingMiddleware(api=mock_api))
# server.add_middleware(LoggingMiddleware(log_manager=mock_log_manager))
server.add_middleware(MethodExecutionMiddleware(api=mock_api))
# Bridge will be created automatically when accessed
@@ -273,7 +287,7 @@ class TestIntegration:
data=json.dumps(request_data).encode(),
headers={"Content-Type": "application/json"},
)
response = urlopen(req) # noqa: S310
response = urlopen(req)
data: dict = json.loads(response.read().decode())
# Verify response in BackendResponse format
@@ -287,8 +301,7 @@ class TestIntegration:
server.stop()
def test_blocking_task(
self,
mock_api: MethodRegistry,
self, mock_api: MethodRegistry, mock_log_manager: Mock
) -> None:
shared_threads: dict[str, tasks.WebThread] = {}
tasks.BAKEND_THREADS = shared_threads
@@ -303,6 +316,7 @@ class TestIntegration:
# Add middleware
server.add_middleware(ArgumentParsingMiddleware(api=mock_api))
# server.add_middleware(LoggingMiddleware(log_manager=mock_log_manager))
server.add_middleware(MethodExecutionMiddleware(api=mock_api))
# Start server
@@ -322,7 +336,7 @@ class TestIntegration:
data=json.dumps(request_data).encode(),
headers={"Content-Type": "application/json"},
)
response = urlopen(req) # noqa: S310
response = urlopen(req)
data: dict = json.loads(response.read().decode())
# thread.join()
@@ -346,7 +360,7 @@ class TestIntegration:
data=json.dumps(request_data).encode(),
headers={"Content-Type": "application/json"},
)
response = urlopen(req) # noqa: S310
response = urlopen(req)
data: dict = json.loads(response.read().decode())
assert "body" in data

View File

@@ -21,7 +21,7 @@ def _get_lib_names() -> list[str]:
machine = platform.machine().lower()
if system == "windows":
if machine in {"amd64", "x86_64"}:
if machine == "amd64" or machine == "x86_64":
return ["webview.dll", "WebView2Loader.dll"]
if machine == "arm64":
msg = "arm64 is not supported on Windows"
@@ -36,6 +36,7 @@ def _get_lib_names() -> list[str]:
def _be_sure_libraries() -> list[Path] | None:
"""Ensure libraries exist and return paths."""
lib_dir = os.environ.get("WEBVIEW_LIB_DIR")
if not lib_dir:
msg = "WEBVIEW_LIB_DIR environment variable is not set"

View File

@@ -10,13 +10,15 @@ from typing import TYPE_CHECKING, Any
from clan_lib.api import MethodRegistry, message_queue
from clan_lib.api.tasks import WebThread
from clan_lib.log_manager import LogManager
from ._webview_ffi import _encode_c_string, _webview_lib
from .webview_bridge import WebviewBridge
if TYPE_CHECKING:
from clan_app.api.middleware import Middleware
from .webview_bridge import WebviewBridge
log = logging.getLogger(__name__)
@@ -48,15 +50,14 @@ class Webview:
shared_threads: dict[str, WebThread] | None = None
# initialized later
_bridge: WebviewBridge | None = None
_bridge: "WebviewBridge | None" = None
_handle: Any | None = None
_callbacks: dict[str, Callable[..., Any]] = field(default_factory=dict)
_middleware: list["Middleware"] = field(default_factory=list)
def _create_handle(self) -> None:
# Initialize the webview handle
with_debugger = True
handle = _webview_lib.webview_create(int(with_debugger), self.window)
handle = _webview_lib.webview_create(int(self.debug), self.window)
callbacks: dict[str, Callable[..., Any]] = {}
# Since we can't use object.__setattr__, we'll initialize differently
@@ -80,7 +81,7 @@ class Webview:
msg = message_queue.get() # Blocks until available
js_code = f"window.notifyBus({json.dumps(msg)});"
self.eval(js_code)
except (json.JSONDecodeError, RuntimeError, AttributeError) as e:
except Exception as e:
print("Bridge notify error:", e)
sleep(0.01) # avoid busy loop
@@ -98,24 +99,23 @@ class Webview:
"""Get the bridge, creating it if necessary."""
if self._bridge is None:
self.create_bridge()
if self._bridge is None:
msg = "Bridge should be created"
raise RuntimeError(msg)
assert self._bridge is not None, "Bridge should be created"
return self._bridge
def api_wrapper(
self,
method_name: str,
wrap_method: Callable[..., Any],
op_key_bytes: bytes,
request_data: bytes,
arg: int,
) -> None:
"""Legacy API wrapper - delegates to the bridge."""
del arg # Unused but required for C callback signature
self.bridge.handle_webview_call(
method_name=method_name,
op_key_bytes=op_key_bytes,
request_data=request_data,
arg=arg,
)
@property
@@ -131,8 +131,10 @@ class Webview:
self._middleware.append(middleware)
def create_bridge(self) -> WebviewBridge:
def create_bridge(self) -> "WebviewBridge":
"""Create and initialize the WebviewBridge with current middleware."""
from .webview_bridge import WebviewBridge
# Use shared_threads if provided, otherwise let WebviewBridge use its default
if self.shared_threads is not None:
bridge = WebviewBridge(
@@ -142,9 +144,7 @@ class Webview:
)
else:
bridge = WebviewBridge(
webview=self,
middleware_chain=tuple(self._middleware),
threads={},
webview=self, middleware_chain=tuple(self._middleware), threads={}
)
self._bridge = bridge
@@ -154,10 +154,7 @@ class Webview:
def set_size(self, value: Size) -> None:
"""Set the webview size (legacy compatibility)."""
_webview_lib.webview_set_size(
self.handle,
value.width,
value.height,
value.hint,
self.handle, value.width, value.height, value.hint
)
def set_title(self, value: str) -> None:
@@ -182,11 +179,12 @@ class Webview:
log.info("Shutting down webview...")
self.destroy()
def bind_jsonschema_api(self, api: MethodRegistry) -> None:
for name in api.functions:
def bind_jsonschema_api(self, api: MethodRegistry, log_manager: LogManager) -> None:
for name, method in api.functions.items():
wrapper = functools.partial(
self.api_wrapper,
name,
method,
)
c_callback = _webview_lib.binding_callback_t(wrapper)
@@ -196,19 +194,16 @@ class Webview:
self._callbacks[name] = c_callback
_webview_lib.webview_bind(
self.handle,
_encode_c_string(name),
c_callback,
None,
self.handle, _encode_c_string(name), c_callback, None
)
def bind(self, name: str, callback: Callable[..., Any]) -> None:
def wrapper(seq: bytes, req: bytes, _arg: int) -> None:
def wrapper(seq: bytes, req: bytes, arg: int) -> None:
args = json.loads(req.decode())
try:
result = callback(*args)
success = True
except Exception as e: # noqa: BLE001
except Exception as e:
result = str(e)
success = False
self.return_(seq.decode(), 0 if success else 1, json.dumps(result))
@@ -224,10 +219,7 @@ class Webview:
def return_(self, seq: str, status: int, result: str) -> None:
_webview_lib.webview_return(
self.handle,
_encode_c_string(seq),
status,
_encode_c_string(result),
self.handle, _encode_c_string(seq), status, _encode_c_string(result)
)
def eval(self, source: str) -> None:

View File

@@ -8,6 +8,8 @@ from clan_lib.api.tasks import WebThread
from clan_app.api.api_bridge import ApiBridge, BackendRequest, BackendResponse
from .webview import FuncStatus
if TYPE_CHECKING:
from .webview import Webview
@@ -24,15 +26,10 @@ class WebviewBridge(ApiBridge):
def send_api_response(self, response: BackendResponse) -> None:
"""Send response back to the webview client."""
serialized = json.dumps(
dataclass_to_dict(response),
indent=4,
ensure_ascii=False,
dataclass_to_dict(response), indent=4, ensure_ascii=False
)
log.debug(f"Sending response: {serialized}")
# Import FuncStatus locally to avoid circular import
from .webview import FuncStatus # noqa: PLC0415
self.webview.return_(response._op_key, FuncStatus.SUCCESS, serialized) # noqa: SLF001
def handle_webview_call(
@@ -40,8 +37,10 @@ class WebviewBridge(ApiBridge):
method_name: str,
op_key_bytes: bytes,
request_data: bytes,
arg: int,
) -> None:
"""Handle a call from webview's JavaScript bridge."""
try:
op_key = op_key_bytes.decode()
raw_args = json.loads(request_data.decode())
@@ -69,10 +68,7 @@ class WebviewBridge(ApiBridge):
# Create API request
api_request = BackendRequest(
method_name=method_name,
args=args,
header=header,
op_key=op_key,
method_name=method_name, args=args, header=header, op_key=op_key
)
except Exception as e:
@@ -81,9 +77,7 @@ class WebviewBridge(ApiBridge):
)
log.exception(msg)
self.send_api_error_response(
op_key,
str(e),
["webview_bridge", method_name],
op_key, str(e), ["webview_bridge", method_name]
)
return

View File

@@ -31,7 +31,7 @@ let
desktop-file = makeDesktopItem {
name = "org.clan.app";
exec = "clan-app %u";
icon = "clan-app";
icon = "clan-white";
desktopName = "Clan App";
startupWMClass = "clan";
mimeTypes = [ "x-scheme-handler/clan" ];

View File

@@ -54,7 +54,8 @@ class Command:
@pytest.fixture
def command() -> Iterator[Command]:
"""Starts a background command. The process is automatically terminated in the end.
"""
Starts a background command. The process is automatically terminated in the end.
>>> p = command.run(["some", "daemon"])
>>> print(p.pid)
"""

View File

@@ -2,15 +2,12 @@ from __future__ import annotations
import logging
import subprocess
from typing import TYPE_CHECKING
from pathlib import Path
import pytest
from clan_lib.custom_logger import setup_logging
from clan_lib.nix import nix_shell
if TYPE_CHECKING:
from pathlib import Path
pytest_plugins = [
"temporary_dir",
"root",

View File

@@ -13,17 +13,23 @@ else:
@pytest.fixture(scope="session")
def project_root() -> Path:
"""Root directory the clan-cli"""
"""
Root directory the clan-cli
"""
return PROJECT_ROOT
@pytest.fixture(scope="session")
def test_root() -> Path:
"""Root directory of the tests"""
"""
Root directory of the tests
"""
return TEST_ROOT
@pytest.fixture(scope="session")
def clan_core() -> Path:
"""Directory of the clan-core flake"""
"""
Directory of the clan-core flake
"""
return CLAN_CORE

View File

@@ -24,11 +24,7 @@ def app() -> Generator[GtkProc]:
cmd = [sys.executable, "-m", "clan_app"]
print(f"Running: {cmd}")
rapp = Popen(
cmd,
text=True,
stdout=sys.stdout,
stderr=sys.stderr,
start_new_session=True,
cmd, text=True, stdout=sys.stdout, stderr=sys.stderr, start_new_session=True
)
yield GtkProc(rapp)
# Cleanup: Terminate your application

View File

@@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" width="48" height="48" fill="currentColor"><path d="M9.223 38.777h8.444V43H5V30.333h4.223zM43 43h-4.223v-8.444h-8.444V43h-4.222V21.889H43zM30.333 30.333h8.444v-4.222h-8.444zM17.667 9.223H9.223v4.221h8.444v4.223H9.223v4.222h8.444v4.222H5V5h12.667zm4.222 12.666h-4.222v-4.222h4.222zM43 17.667h-4.223V9.223h-8.444V5H43zm-21.111-4.223h-4.222V9.223h4.222z"/></svg>

Before

Width:  |  Height:  |  Size: 399 B

View File

@@ -1 +1,3 @@
<svg xmlns="http://www.w3.org/2000/svg" width="48" height="48" fill="currentColor"><path d="M27 38H6V17h4v-4h3.5V9h24v4H41v11H27v3h7v4h-3.5v3.5H27zM16.5 20.5H20V17h-3.5z"/></svg>
<svg xmlns="http://www.w3.org/2000/svg" width="48" height="48" viewBox="0 0 48 48" fill="currentColor">
<path d="M27 38H6V17H10V13H13.5V9H37.5V13H41V24H27V27H34V31H30.5V34.5H27V38ZM16.5 20.5H20V17H16.5V20.5Z"/>
</svg>

Before

Width:  |  Height:  |  Size: 178 B

After

Width:  |  Height:  |  Size: 221 B

View File

@@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" width="48" height="48" fill="currentColor"><path d="M46 46H2V2h44zM16.667 33.777h4.889V28.89h-4.889zm-4.89-4.888h4.89V24h-4.89zm9.779 0h4.888V24h-4.888zM26.444 24h4.889v-4.889h-4.889zm4.889-9.777v4.888h4.89v-4.888z"/></svg>

Before

Width:  |  Height:  |  Size: 263 B

View File

@@ -1 +1,3 @@
<svg xmlns="http://www.w3.org/2000/svg" width="48" height="48" fill="currentColor"><path d="M36.888 11H41.3v4.413h-4.412zm-4.413 8.825v-4.412h4.413v4.412zm-4.413 4.413v-4.413h4.413v4.413zM23.65 28.65h4.413v-4.412H23.65zm-4.412 4.413h4.412V28.65h-4.412zm-4.413 0v4.412h4.413v-4.413zm-4.412-4.413h4.412v4.413h-4.412zm0 0H6v-4.412h4.413z"/></svg>
<svg xmlns="http://www.w3.org/2000/svg" width="48" height="48" fill="currentColor">
<path d="M36.888 11H41.3v4.413h-4.412zm-4.413 8.825v-4.412h4.413v4.412zm-4.413 4.413v-4.413h4.413v4.413zM23.65 28.65h4.413v-4.412H23.65zm-4.412 4.413h4.412V28.65h-4.412zm-4.413 0v4.412h4.413v-4.413zm-4.412-4.413h4.412v4.413h-4.412zm0 0H6v-4.412h4.413z"/>
</svg>

Before

Width:  |  Height:  |  Size: 343 B

After

Width:  |  Height:  |  Size: 349 B

View File

@@ -1 +1,10 @@
<svg xmlns="http://www.w3.org/2000/svg" width="72" height="89" fill="currentColor"><g clip-path="url(#a)"><path d="M57.709 20.105H68.62c1.157 0 2.099-.94 2.099-2.095V9.632a2.1 2.1 0 0 0-2.099-2.095h-3.439c-1.111 0-2.014-.9-2.014-2.01V2.095A2.1 2.1 0 0 0 61.07 0H30.02a2.1 2.1 0 0 0-2.098 2.095v3.432c0 1.11-.903 2.01-2.014 2.01H22.47c-1.157 0-2.098.94-2.098 2.095v3.432c0 1.11-.903 2.01-2.014 2.01h-3.439c-1.157 0-2.099.94-2.099 2.094 0 0-.503-1.272-.503 22.493 0 21.247.503 19.38.503 19.38 0 1.156.942 2.096 2.1 2.096h3.438c1.111 0 2.014.9 2.014 2.01v3.517c0 1.109.902 2.01 2.013 2.01h3.524c1.111 0 2.014.9 2.014 2.01v3.432a2.1 2.1 0 0 0 2.098 2.094h30.211c1.157 0 2.099-.94 2.099-2.094v-3.433c0-1.11.902-2.01 2.013-2.01h5.557c1.158 0 2.099-.94 2.099-2.094v-9.984a2.1 2.1 0 0 0-2.099-2.095h-13.03c-1.157 0-2.098.94-2.098 2.095v5.044c0 1.11-.902 2.01-2.014 2.01H37.488c-1.111 0-2.013-.9-2.013-2.01v-5.11a2.1 2.1 0 0 0-2.099-2.094h-5.119c-1.111 0-1.739.163-2.014-2.01-.085-.698-.13-1.553-.196-2.695-.163-2.878-.307-1.723-.307-10.369 0-12.085.314-15.563.503-17.24.19-1.677.903-2.01 2.014-2.01h5.12c1.156 0 2.098-.94 2.098-2.094v-3.433c0-1.109.902-2.01 2.013-2.01h16.116c1.111 0 2.014.901 2.014 2.01v3.433c0 1.155.94 2.094 2.098 2.094zM18.626 73.757h-2.478a.87.87 0 0 1-.87-.868v-2.473c0-.96-.777-1.743-1.745-1.743H6.838c-.96 0-1.745.777-1.745 1.743v2.473a.87.87 0 0 1-.87.868H1.746c-.961 0-1.746.776-1.746 1.742v6.682c0 .96.778 1.742 1.746 1.742h2.477c.484 0 .87.392.87.868v2.473c0 .96.778 1.743 1.745 1.743h6.695c.961 0 1.746-.777 1.746-1.743v-2.473c0-.483.392-.868.87-.868h2.477c.961 0 1.746-.776 1.746-1.742v-6.682c0-.96-.778-1.742-1.746-1.742"/></g><defs><clipPath id="a"><path d="M0 0h72v89H0z"/></clipPath></defs></svg>
<svg xmlns="http://www.w3.org/2000/svg" width="72" height="89" fill="currentColor">
<g clip-path="url(#a)">
<path d="M57.709 20.105H68.62c1.157 0 2.099-.94 2.099-2.095V9.632a2.1 2.1 0 0 0-2.099-2.095h-3.439c-1.111 0-2.014-.9-2.014-2.01V2.095A2.1 2.1 0 0 0 61.07 0H30.02a2.1 2.1 0 0 0-2.098 2.095v3.432c0 1.11-.903 2.01-2.014 2.01H22.47c-1.157 0-2.098.94-2.098 2.095v3.432c0 1.11-.903 2.01-2.014 2.01h-3.439c-1.157 0-2.099.94-2.099 2.094 0 0-.503-1.272-.503 22.493 0 21.247.503 19.38.503 19.38 0 1.156.942 2.096 2.1 2.096h3.438c1.111 0 2.014.9 2.014 2.01v3.517c0 1.109.902 2.01 2.013 2.01h3.524c1.111 0 2.014.9 2.014 2.01v3.432a2.1 2.1 0 0 0 2.098 2.094h30.211c1.157 0 2.099-.94 2.099-2.094v-3.433c0-1.11.902-2.01 2.013-2.01h5.557c1.158 0 2.099-.94 2.099-2.094v-9.984a2.1 2.1 0 0 0-2.099-2.095h-13.03c-1.157 0-2.098.94-2.098 2.095v5.044c0 1.11-.902 2.01-2.014 2.01H37.488c-1.111 0-2.013-.9-2.013-2.01v-5.11a2.1 2.1 0 0 0-2.099-2.094h-5.119c-1.111 0-1.739.163-2.014-2.01-.085-.698-.13-1.553-.196-2.695-.163-2.878-.307-1.723-.307-10.369 0-12.085.314-15.563.503-17.24.19-1.677.903-2.01 2.014-2.01h5.12c1.156 0 2.098-.94 2.098-2.094v-3.433c0-1.109.902-2.01 2.013-2.01h16.116c1.111 0 2.014.901 2.014 2.01v3.433c0 1.155.94 2.094 2.098 2.094zM18.626 73.757h-2.478a.87.87 0 0 1-.87-.868v-2.473c0-.96-.777-1.743-1.745-1.743H6.838c-.96 0-1.745.777-1.745 1.743v2.473a.87.87 0 0 1-.87.868H1.746c-.961 0-1.746.776-1.746 1.742v6.682c0 .96.778 1.742 1.746 1.742h2.477c.484 0 .87.392.87.868v2.473c0 .96.778 1.743 1.745 1.743h6.695c.961 0 1.746-.777 1.746-1.743v-2.473c0-.483.392-.868.87-.868h2.477c.961 0 1.746-.776 1.746-1.742v-6.682c0-.96-.778-1.742-1.746-1.742"/>
</g>
<defs>
<clipPath id="a">
<path d="M0 0h72v89H0z"/>
</clipPath>
</defs>
</svg>

Before

Width:  |  Height:  |  Size: 1.7 KiB

After

Width:  |  Height:  |  Size: 1.7 KiB

View File

@@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" width="223" height="89" fill="currentColor"><g clip-path="url(#a)"><path d="M55.503 18.696h10.104a1.946 1.946 0 0 0 1.943-1.948v-7.79c0-1.075-.87-1.947-1.943-1.947h-3.186a1.863 1.863 0 0 1-1.866-1.87V1.947C60.555.872 59.685 0 58.612 0h-27.98a1.946 1.946 0 0 0-1.944 1.947v3.194c0 1.036-.832 1.87-1.865 1.87h-3.187a1.946 1.946 0 0 0-1.943 1.947v3.194c0 1.036-.832 1.87-1.866 1.87h-3.186a1.946 1.946 0 0 0-1.943 1.947s-.467 1.153-.467 23.253c0 19.763.467 21.913.467 21.913 0 1.075.87 1.948 1.943 1.948h3.186c1.034 0 1.866.833 1.866 1.87v3.271c0 1.036.831 1.87 1.865 1.87h3.265c1.033 0 1.865.833 1.865 1.87v3.193c0 1.075.87 1.948 1.943 1.948h27.981a1.946 1.946 0 0 0 1.943-1.948v-3.194c0-1.036.832-1.87 1.866-1.87h5.145a1.946 1.946 0 0 0 1.943-1.947v-9.285c0-1.075-.87-1.948-1.943-1.948H55.503a1.946 1.946 0 0 0-1.943 1.948v4.69c0 1.035-.832 1.869-1.866 1.869H37.55a1.863 1.863 0 0 1-1.866-1.87v-4.752c0-1.075-.87-1.947-1.943-1.947H29c-1.034 0-1.609.148-1.865-1.87-.078-.646-.125-1.44-.18-2.508-.147-2.68-.287-5.5-.287-13.539 0-11.24.288-16.81.466-18.369.18-1.558.832-1.87 1.866-1.87h4.741a1.946 1.946 0 0 0 1.943-1.947v-3.193c0-1.037.832-1.87 1.866-1.87h14.145c1.034 0 1.866.833 1.866 1.87v3.193c0 1.075.87 1.948 1.943 1.948M20.247 74.822h-2.293a.814.814 0 0 1-.808-.81v-2.298c0-.896-.723-1.62-1.617-1.62H9.327c-.894 0-1.617.724-1.617 1.62v2.298c0 .444-.365.81-.808.81H4.609c-.894 0-1.617.725-1.617 1.62v6.217c0 .896.723 1.62 1.617 1.62h2.293c.443 0 .808.366.808.81v2.299c0 .895.723 1.62 1.617 1.62h6.202c.894 0 1.617-.725 1.617-1.62v-2.299c0-.444.365-.81.808-.81h2.293c.894 0 1.617-.724 1.617-1.62v-6.216c0-.896-.723-1.62-1.617-1.62M221.135 35.04h-1.71a1.863 1.863 0 0 1-1.866-1.87v-3.272c0-1.036-.831-1.87-1.865-1.87h-3.265a1.863 1.863 0 0 1-1.865-1.87v-3.271c0-1.036-.832-1.87-1.865-1.87h-20.971a1.863 1.863 0 0 0-1.865 1.87v3.965c0 .514-.42.935-.933.935h-3.559c-.513 0-.84-.32-.933-.935l-.622-3.918c-.148-1.099-.676-1.777-1.788-1.777l-3.653-.14h-2.052a3.736 3.736 0 0 0-3.73 3.74V61.68a3.736 3.736 0 0 1-3.731 3.739h-8.394a1.863 1.863 0 0 1-1.866-1.87V36.714c0-11.825-7.461-18.813-22.556-18.813-13.718 0-20.325 5.04-21.203 14.443-.109 1.153.552 1.815 1.702 1.815l7.757.569c1.143.1 1.594-.554 1.811-1.652.77-3.74 4.174-5.827 9.933-5.827 7.081 0 10.042 3.358 10.042 9.076v3.014c0 1.036-.831 1.87-1.865 1.87l-.342-.024h-9.715c-15.421 0-22.984 5.983-22.984 17.956 0 3.802.778 7.058 2.254 9.738h-.59c-1.765-1.27-2.457-2.236-3.055-2.93-.256-.295-.653-.537-1.345-.537h-1.717l-5.993.008h-3.264a3.736 3.736 0 0 1-3.731-3.74V1.769C89.74.654 89.072 0 87.969 0H79.55c-1.034 0-1.865.732-1.865 1.768l-.024 54.304v13.554c0 4.13 3.343 7.479 7.462 7.479h50.84c8.448-.429 8.604-3.42 9.436-4.542.645 3.56 1.865 4.347 4.71 4.518 8.137.117 18.343.032 18.49.024h4.975c4.119 0 6.684-3.35 6.684-7.479l.777-27.264c0-1.036.832-1.87 1.866-1.87h2.021a1.56 1.56 0 0 0 1.554-1.558v-3.583c0-1.036.832-1.87 1.866-1.87h11.868a3.37 3.37 0 0 1 3.366 3.373v3.249c0 1.075.87 1.947 1.943 1.947h4.119c.513 0 .933.42.933.935v32.25c0 1.036.831 1.87 1.865 1.87h6.84a3.736 3.736 0 0 0 3.731-3.74V36.91c0-1.036-.832-1.87-1.866-1.87zM142.64 54.225c0 8.927-6.132 14.715-15.335 14.715-6.606 0-9.793-2.953-9.793-8.748 0-6.442 3.832-9.636 11.62-9.636h13.508v3.669"/></g><defs><clipPath id="a"><path d="M0 0h223v89H0z"/></clipPath></defs></svg>

Before

Width:  |  Height:  |  Size: 3.3 KiB

View File

@@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" width="48" height="48" fill="currentColor"><path d="M35.667 7.667h4.666v4.666H45v23.334h-4.667v4.666h-4.666V45H12.333v-4.667H7.667v-4.666H3V12.333h4.667V7.667h4.666V3h23.334zM15 29.4V33h3.6v-3.6zm14.4 0V33H33v-3.6zm-10.8-3.6v3.6h3.6v-3.6zm7.2 0v3.6h3.6v-3.6zm-3.6-3.6v3.6h3.6v-3.6zm-3.6-3.6v3.6h3.6v-3.6zm7.2 0v3.6h3.6v-3.6zM15 15v3.6h3.6V15zm14.4 0v3.6H33V15z"/></svg>

Before

Width:  |  Height:  |  Size: 409 B

View File

@@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" width="48" height="48" fill="currentColor"><path d="M15.6 9h4.2v4.286h-4.2zM11.4 13.286h4.2v4.285h-4.2zM7.2 17.571h4.2v4.286H7.2zM3 21.857h4.2v4.286H3zM7.2 26.143h4.2v4.286H7.2zM11.4 30.429h4.2v4.285h-4.2zM15.6 34.714h4.2V39h-4.2zM32.4 9h-4.2v4.286h4.2zM36.6 13.286h-4.2v4.285h4.2zM40.8 17.571h-4.2v4.286h4.2zM45 21.857h-4.2v4.286H45zM40.8 26.143h-4.2v4.286h4.2z"/><path d="M36.6 30.429h-4.2v4.285h4.2zM32.4 34.714h-4.2V39h4.2z"/></svg>

Before

Width:  |  Height:  |  Size: 476 B

View File

@@ -1 +1,25 @@
<svg xmlns="http://www.w3.org/2000/svg" width="48" height="48" fill="currentColor"><path d="M8 9h6v6H8zM14 9h6v6h-6zM20 9h6v6h-6zM14 15h6v6h-6zM26 21h6v6h-6zM26 15h6v6h-6zM20 27h6v6h-6zM20 21h6v6h-6zM20 15h6v6h-6zM8 3h6v6H8zM14 3h6v6h-6zM32 21h6v6h-6zM8 15h6v6H8zM14 21h6v6h-6zM8 21h6v6H8zM8 27h6v6H8zM8 33h6v6H8zM8 39h6v6H8zM14 27h6v6h-6zM26 27h6v6h-6zM32 27h6v6h-6z"/><path d="M37 27h6v6h-6zM14 33h6v6h-6z"/></svg>
<svg xmlns="http://www.w3.org/2000/svg" width="35" height="42" viewBox="0 0 35 42" fill="currentColor">
<rect y="6" width="6" height="6"/>
<rect x="6" y="6" width="6" height="6"/>
<rect x="12" y="6" width="6" height="6"/>
<rect x="6" y="12" width="6" height="6"/>
<rect x="18" y="18" width="6" height="6"/>
<rect x="18" y="12" width="6" height="6"/>
<rect x="12" y="24" width="6" height="6"/>
<rect x="12" y="18" width="6" height="6"/>
<rect x="12" y="12" width="6" height="6"/>
<rect width="6" height="6"/>
<rect x="6" width="6" height="6"/>
<rect x="24" y="18" width="6" height="6"/>
<rect y="12" width="6" height="6"/>
<rect x="6" y="18" width="6" height="6"/>
<rect y="18" width="6" height="6"/>
<rect y="24" width="6" height="6"/>
<rect y="30" width="6" height="6"/>
<rect y="36" width="6" height="6"/>
<rect x="6" y="24" width="6" height="6"/>
<rect x="18" y="24" width="6" height="6"/>
<rect x="24" y="24" width="6" height="6"/>
<rect x="29" y="24" width="6" height="6"/>
<rect x="6" y="30" width="6" height="6"/>
</svg>

Before

Width:  |  Height:  |  Size: 416 B

After

Width:  |  Height:  |  Size: 1.1 KiB

View File

@@ -1 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="48" height="48" fill="currentColor"><path fill-rule="evenodd" d="M11.7 9h8.1v5.625H42v3.75H19.8V24h-8.1v-5.625H5v-3.75h6.7zm15.5 15h8.1v5.625H42v3.75h-6.7V39h-8.1v-5.625H5v-3.75h22.2z" clip-rule="evenodd"/></svg>
<svg xmlns="http://www.w3.org/2000/svg" width="48" height="48" fill="currentColor"><path fill-rule="evenodd" d="M8.7 9h11.1v5.625H42v3.75H19.8V24H8.7v-5.625H5v-3.75h3.7zm3.7 3.75v7.5h3.7v-7.5zM27.2 24h11.1v5.625H42v3.75h-3.7V39H27.2v-5.625H5v-3.75h22.2zm3.7 3.75v7.5h3.7v-7.5z" clip-rule="evenodd"/></svg>

Before

Width:  |  Height:  |  Size: 259 B

After

Width:  |  Height:  |  Size: 305 B

View File

@@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" width="48" height="48" fill="currentColor"><path d="M34.25 9.3H45v34.4H2.001v-4.3H2V13.6h.001V9.3H12.75V5h21.5zM19.201 30.8v4.3h8.6v-4.3zm-4.3-4.3v4.3h4.3v-4.3zm12.9 0v4.3h4.3v-4.3zm-12.9-8.6v4.3h4.3v-4.3zm12.9 0v4.3h4.3v-4.3z"/></svg>

Before

Width:  |  Height:  |  Size: 275 B

View File

@@ -1 +1,3 @@
<svg xmlns="http://www.w3.org/2000/svg" width="48" height="48" fill="currentColor"><path d="M20.2 12.8H23v2.8h2.8v-1.4h2.8V10h5.6v2.8H37v2.8h2.8V24H37v2.8h-2.8v2.8h-2.8v2.8h-2.8v2.8h-2.8V38H23v-2.8h-2.8v-2.8h-2.8v-2.8h-2.8v-2.8h-2.8V24H9v-8.4h2.8v-2.8h2.8V10h5.6z"/></svg>
<svg xmlns="http://www.w3.org/2000/svg" width="48" height="48" viewBox="0 0 48 48" fill="currentColor">
<path d="M20.2002 12.7998H23V15.5996H25.8008V14.2002H28.6006V10H34.2002V12.7998H37V15.5996H39.8008V24H37V26.7998H34.2002V29.5996H31.4004V32.4004H28.6006V35.2002H25.8008V38H23V35.2002H20.2002V32.4004H17.4004V29.5996H14.6006V26.7998H11.8008V24H9V15.5996H11.8008V12.7998H14.6006V10H20.2002V12.7998Z"/>
</svg>

Before

Width:  |  Height:  |  Size: 272 B

After

Width:  |  Height:  |  Size: 413 B

View File

@@ -1 +1,3 @@
<svg xmlns="http://www.w3.org/2000/svg" width="48" height="48" fill="currentColor"><path d="M38.666 5v34.667h.001V5H43v39H4V5zm-26 30.334h4.333V31h-4.333zm17.333 0h4.334V31h-4.334zm-8.666-8.667h4.333v-4.333h-4.333zM12.666 18h4.333v-4.333h-4.333zm17.333 0h4.334v-4.333h-4.334z"/></svg>
<svg xmlns="http://www.w3.org/2000/svg" width="48" height="48" viewBox="0 0 48 48" fill="currentColor">
<path d="M38.666 5V39.667H38.667V5H43V44H4V5H38.666ZM12.666 35.334H16.999V31H12.666V35.334ZM29.999 35.334H34.333V31H29.999V35.334ZM21.333 26.667H25.666V22.334H21.333V26.667ZM12.666 18H16.999V13.667H12.666V18ZM29.999 18H34.333V13.667H29.999V18Z"/>
</svg>

Before

Width:  |  Height:  |  Size: 284 B

After

Width:  |  Height:  |  Size: 361 B

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