Compare commits
1 Commits
fix-module
...
serve-json
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3ac9167f18 |
2
.github/workflows/repo-sync.yml
vendored
@@ -10,7 +10,7 @@ jobs:
|
|||||||
if: github.repository_owner == 'clan-lol'
|
if: github.repository_owner == 'clan-lol'
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v5
|
- uses: actions/checkout@v4
|
||||||
with:
|
with:
|
||||||
persist-credentials: false
|
persist-credentials: false
|
||||||
- uses: actions/create-github-app-token@v2
|
- uses: actions/create-github-app-token@v2
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ Our mission is simple: to democratize computing by providing tools that empower
|
|||||||
|
|
||||||
## Features of Clan
|
## 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 Clan’s toolkit alongside Nix's reliability to build and manage systems effortlessly.
|
||||||
- **Overlay Networks:** Secure, private communication channels between devices.
|
- **Overlay Networks:** Secure, private communication channels between devices.
|
||||||
- **Virtual Machine Integration:** Seamless operation of VM applications within the main operating system.
|
- **Virtual Machine Integration:** Seamless operation of VM applications within the main operating system.
|
||||||
- **Robust Backup Management:** Long-term, self-hosted data preservation.
|
- **Robust Backup Management:** Long-term, self-hosted data preservation.
|
||||||
|
|||||||
@@ -55,8 +55,7 @@
|
|||||||
closureInfo = pkgs.closureInfo { rootPaths = dependencies; };
|
closureInfo = pkgs.closureInfo { rootPaths = dependencies; };
|
||||||
in
|
in
|
||||||
{
|
{
|
||||||
# Skip flash test on aarch64-linux for now as it's too slow
|
checks = pkgs.lib.mkIf pkgs.stdenv.isLinux {
|
||||||
checks = lib.optionalAttrs (pkgs.stdenv.isLinux && pkgs.hostPlatform.system != "aarch64-linux") {
|
|
||||||
nixos-test-flash = self.clanLib.test.baseTest {
|
nixos-test-flash = self.clanLib.test.baseTest {
|
||||||
name = "flash";
|
name = "flash";
|
||||||
nodes.target = {
|
nodes.target = {
|
||||||
|
|||||||
@@ -232,7 +232,6 @@
|
|||||||
"-i", ssh_conn.ssh_key,
|
"-i", ssh_conn.ssh_key,
|
||||||
"--option", "store", os.environ['CLAN_TEST_STORE'],
|
"--option", "store", os.environ['CLAN_TEST_STORE'],
|
||||||
"--update-hardware-config", "nixos-facter",
|
"--update-hardware-config", "nixos-facter",
|
||||||
"--no-persist-state",
|
|
||||||
]
|
]
|
||||||
|
|
||||||
subprocess.run(clan_cmd, check=True)
|
subprocess.run(clan_cmd, check=True)
|
||||||
@@ -276,7 +275,7 @@
|
|||||||
"${self.checks.x86_64-linux.clan-core-for-checks}",
|
"${self.checks.x86_64-linux.clan-core-for-checks}",
|
||||||
"${closureInfo}"
|
"${closureInfo}"
|
||||||
)
|
)
|
||||||
|
|
||||||
# Set up SSH connection
|
# Set up SSH connection
|
||||||
ssh_conn = setup_ssh_connection(
|
ssh_conn = setup_ssh_connection(
|
||||||
target,
|
target,
|
||||||
@@ -302,8 +301,7 @@
|
|||||||
"test-install-machine-without-system",
|
"test-install-machine-without-system",
|
||||||
"-i", ssh_conn.ssh_key,
|
"-i", ssh_conn.ssh_key,
|
||||||
"--option", "store", os.environ['CLAN_TEST_STORE'],
|
"--option", "store", os.environ['CLAN_TEST_STORE'],
|
||||||
"--target-host", f"nonrootuser@localhost:{ssh_conn.host_port}",
|
f"nonrootuser@localhost:{ssh_conn.host_port}"
|
||||||
"--yes"
|
|
||||||
]
|
]
|
||||||
|
|
||||||
result = subprocess.run(clan_cmd, capture_output=True, cwd=flake_dir)
|
result = subprocess.run(clan_cmd, capture_output=True, cwd=flake_dir)
|
||||||
@@ -327,9 +325,7 @@
|
|||||||
"test-install-machine-without-system",
|
"test-install-machine-without-system",
|
||||||
"-i", ssh_conn.ssh_key,
|
"-i", ssh_conn.ssh_key,
|
||||||
"--option", "store", os.environ['CLAN_TEST_STORE'],
|
"--option", "store", os.environ['CLAN_TEST_STORE'],
|
||||||
"--target-host",
|
f"nonrootuser@localhost:{ssh_conn.host_port}"
|
||||||
f"nonrootuser@localhost:{ssh_conn.host_port}",
|
|
||||||
"--yes"
|
|
||||||
]
|
]
|
||||||
|
|
||||||
result = subprocess.run(clan_cmd, capture_output=True, cwd=flake_dir)
|
result = subprocess.run(clan_cmd, capture_output=True, cwd=flake_dir)
|
||||||
|
|||||||
@@ -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" ];
|
|
||||||
};
|
|
||||||
};
|
|
||||||
};
|
|
||||||
};
|
|
||||||
```
|
|
||||||
@@ -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);
|
|
||||||
};
|
|
||||||
};
|
|
||||||
};
|
|
||||||
};
|
|
||||||
};
|
|
||||||
}
|
|
||||||
@@ -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"
|
|
||||||
|
|
||||||
'';
|
|
||||||
}
|
|
||||||
@@ -11,8 +11,7 @@
|
|||||||
...
|
...
|
||||||
}:
|
}:
|
||||||
let
|
let
|
||||||
jsonpath = "/tmp/telegraf.json";
|
jsonpath = /tmp/telegraf.json;
|
||||||
auth_user = "prometheus";
|
|
||||||
in
|
in
|
||||||
{
|
{
|
||||||
|
|
||||||
@@ -20,24 +19,18 @@
|
|||||||
builtins.listToAttrs (
|
builtins.listToAttrs (
|
||||||
map (name: {
|
map (name: {
|
||||||
inherit name;
|
inherit name;
|
||||||
value.allowedTCPPorts = [
|
value.allowedTCPPorts = [ 9273 9990 ];
|
||||||
9273
|
|
||||||
9990
|
|
||||||
];
|
|
||||||
}) settings.interfaces
|
}) settings.interfaces
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
|
|
||||||
networking.firewall.allowedTCPPorts = lib.mkIf (settings.allowAllInterfaces == true) [
|
systemd.services.telegsaf-json.script = "${pkgs.miniserve}/bin/miniserve -p 9990 ${jsonpath}";
|
||||||
9273
|
|
||||||
9990
|
|
||||||
];
|
|
||||||
|
|
||||||
clan.core.vars.generators."telegraf" = {
|
networking.firewall.allowedTCPPorts = lib.mkIf (settings.allowAllInterfaces == true) [ 9273 ];
|
||||||
|
|
||||||
files.password.restartUnits = [ "telegraf.service" ];
|
clan.core.vars.generators."telegraf-password" = {
|
||||||
files.password-env.restartUnits = [ "telegraf.service" ];
|
files.telegraf-password.neededFor = "users";
|
||||||
files.miniserve-auth.restartUnits = [ "telegraf.service" ];
|
files.telegraf-password.restartUnits = [ "telegraf.service" ];
|
||||||
|
|
||||||
runtimeInputs = [
|
runtimeInputs = [
|
||||||
pkgs.coreutils
|
pkgs.coreutils
|
||||||
@@ -47,22 +40,16 @@
|
|||||||
|
|
||||||
script = ''
|
script = ''
|
||||||
PASSWORD=$(xkcdpass --numwords 4 --delimiter - --count 1 | tr -d "\n")
|
PASSWORD=$(xkcdpass --numwords 4 --delimiter - --count 1 | tr -d "\n")
|
||||||
echo "BASIC_AUTH_PWD=$PASSWORD" > "$out"/password-env
|
echo "BASIC_AUTH_PWD=$PASSWORD" > "$out"/telegraf-password
|
||||||
echo "${auth_user}:$PASSWORD" > "$out"/miniserve-auth
|
|
||||||
echo "$PASSWORD" | tr -d "\n" > "$out"/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 = {
|
services.telegraf = {
|
||||||
enable = true;
|
enable = true;
|
||||||
environmentFiles = [
|
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 = {
|
extraConfig = {
|
||||||
agent.interval = "60s";
|
agent.interval = "60s";
|
||||||
@@ -77,35 +64,32 @@
|
|||||||
|
|
||||||
exec =
|
exec =
|
||||||
let
|
let
|
||||||
nixosSystems = pkgs.writeShellScript "current-system" ''
|
currentSystemScript = pkgs.writeShellScript "current-system" ''
|
||||||
printf "nixos_systems,current_system=%s,booted_system=%s,current_kernel=%s,booted_kernel=%s present=0\n" \
|
printf "current_system,path=%s present=0\n" $(readlink /run/current-system)
|
||||||
"$(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/*))"
|
|
||||||
'';
|
'';
|
||||||
in
|
in
|
||||||
[
|
[
|
||||||
{
|
{
|
||||||
# Expose the path to current-system as metric. We use
|
# Expose the path to current-system as metric. We use
|
||||||
# this to check if the machine is up-to-date.
|
# this to check if the machine is up-to-date.
|
||||||
commands = [ nixosSystems ];
|
commands = [ currentSystemScript ];
|
||||||
data_format = "influx";
|
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 = {
|
outputs.file = {
|
||||||
files = [ jsonpath ];
|
files = [ jsonpath ];
|
||||||
data_format = "json";
|
data_format = "json";
|
||||||
json_timestamp_units = "1s";
|
json_timestamp_units = "1s";
|
||||||
};
|
};
|
||||||
|
|
||||||
|
outputs.prometheus_client = {
|
||||||
|
listen = ":9273";
|
||||||
|
metric_version = 2;
|
||||||
|
basic_username = "prometheus";
|
||||||
|
basic_password = "$${BASIC_AUTH_PWD}";
|
||||||
|
};
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|||||||
37
clanServices/state-version/README.md
Normal 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.
|
||||||
50
clanServices/state-version/default.nix
Normal 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
|
||||||
|
'';
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
}
|
||||||
@@ -3,16 +3,14 @@ let
|
|||||||
module = lib.modules.importApply ./default.nix { };
|
module = lib.modules.importApply ./default.nix { };
|
||||||
in
|
in
|
||||||
{
|
{
|
||||||
clan.modules = {
|
clan.modules.state-version = module;
|
||||||
coredns = module;
|
|
||||||
};
|
|
||||||
perSystem =
|
perSystem =
|
||||||
{ ... }:
|
{ ... }:
|
||||||
{
|
{
|
||||||
clan.nixosTests.coredns = {
|
clan.nixosTests.state-version = {
|
||||||
imports = [ ./tests/vm/default.nix ];
|
imports = [ ./tests/vm/default.nix ];
|
||||||
|
|
||||||
clan.modules."@clan/coredns" = module;
|
clan.modules."@clan/state-version" = module;
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
22
clanServices/state-version/tests/vm/default.nix
Normal 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()
|
||||||
|
'';
|
||||||
|
}
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
25.11
|
||||||
@@ -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.
|
# Deploy user bob only on his laptop. Prompt for a password.
|
||||||
user-bob = {
|
user-bob = {
|
||||||
module = {
|
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
|
|
||||||
```
|
|
||||||
|
|||||||
@@ -59,17 +59,6 @@
|
|||||||
- "input" - Allows the user to access input devices.
|
- "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}" = {
|
clan.core.vars.generators."user-password-${settings.user}" = {
|
||||||
|
|
||||||
files.user-password-hash.neededFor = "users";
|
files.user-password-hash.neededFor = "users";
|
||||||
files.user-password-hash.restartUnits = lib.optional (config.services.userborn.enable) "userborn.service";
|
files.user-password-hash.restartUnits = lib.optional (config.services.userborn.enable) "userborn.service";
|
||||||
files.user-password.deploy = false;
|
files.user-password.deploy = false;
|
||||||
@@ -117,8 +107,6 @@
|
|||||||
pkgs.mkpasswd
|
pkgs.mkpasswd
|
||||||
];
|
];
|
||||||
|
|
||||||
share = settings.share;
|
|
||||||
|
|
||||||
script =
|
script =
|
||||||
(
|
(
|
||||||
if settings.prompt then
|
if settings.prompt then
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
#!/usr/bin/env python3
|
#!/usr/bin/env python3
|
||||||
"""IPv6 address allocator for WireGuard networks.
|
"""
|
||||||
|
IPv6 address allocator for WireGuard networks.
|
||||||
|
|
||||||
Network layout:
|
Network layout:
|
||||||
- Base network: /40 ULA prefix (fd00::/8 + 32 bits from hash)
|
- Base network: /40 ULA prefix (fd00::/8 + 32 bits from hash)
|
||||||
@@ -12,11 +13,6 @@ import ipaddress
|
|||||||
import sys
|
import sys
|
||||||
from pathlib import Path
|
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:
|
def hash_string(s: str) -> str:
|
||||||
"""Generate SHA256 hash of string."""
|
"""Generate SHA256 hash of string."""
|
||||||
@@ -24,7 +20,8 @@ def hash_string(s: str) -> str:
|
|||||||
|
|
||||||
|
|
||||||
def generate_ula_prefix(instance_name: str) -> ipaddress.IPv6Network:
|
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
|
Format: fd{32-bit hash}/40
|
||||||
This gives us fd00:0000:0000::/40 through fdff:ffff:ff00::/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 = f"fd{prefix_bits:08x}"
|
||||||
prefix_formatted = f"{prefix[:4]}:{prefix[4:8]}::/40"
|
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(
|
def generate_controller_subnet(
|
||||||
base_network: ipaddress.IPv6Network,
|
base_network: ipaddress.IPv6Network, controller_name: str
|
||||||
controller_name: str,
|
|
||||||
) -> ipaddress.IPv6Network:
|
) -> 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.
|
We have 16 bits (40 to 56) to allocate controller subnets.
|
||||||
This allows for 65,536 possible 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
|
# The controller subnet is at base_prefix:controller_id::/56
|
||||||
base_int = int(base_network.network_address)
|
base_int = int(base_network.network_address)
|
||||||
controller_subnet_int = base_int | (controller_id << (128 - 56))
|
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:
|
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.
|
This suffix will be used in all controller subnets to create unique addresses.
|
||||||
Format: :xxxx:xxxx:xxxx:xxxx (64 bits)
|
Format: :xxxx:xxxx:xxxx:xxxx (64 bits)
|
||||||
@@ -78,13 +79,14 @@ def generate_peer_suffix(peer_name: str) -> str:
|
|||||||
suffix_bits = h[:16]
|
suffix_bits = h[:16]
|
||||||
|
|
||||||
# Format as IPv6 suffix without leading colon
|
# 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:
|
def main() -> None:
|
||||||
if len(sys.argv) < MIN_ARGS_BASE:
|
if len(sys.argv) < 4:
|
||||||
print(
|
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)
|
sys.exit(1)
|
||||||
|
|
||||||
@@ -96,7 +98,7 @@ def main() -> None:
|
|||||||
base_network = generate_ula_prefix(instance_name)
|
base_network = generate_ula_prefix(instance_name)
|
||||||
|
|
||||||
if node_type == "controller":
|
if node_type == "controller":
|
||||||
if len(sys.argv) < MIN_ARGS_CONTROLLER:
|
if len(sys.argv) < 5:
|
||||||
print("Controller name required")
|
print("Controller name required")
|
||||||
sys.exit(1)
|
sys.exit(1)
|
||||||
|
|
||||||
@@ -112,7 +114,7 @@ def main() -> None:
|
|||||||
(output_dir / "prefix").write_text(prefix_str)
|
(output_dir / "prefix").write_text(prefix_str)
|
||||||
|
|
||||||
elif node_type == "peer":
|
elif node_type == "peer":
|
||||||
if len(sys.argv) < MIN_ARGS_PEER:
|
if len(sys.argv) < 5:
|
||||||
print("Peer name required")
|
print("Peer name required")
|
||||||
sys.exit(1)
|
sys.exit(1)
|
||||||
|
|
||||||
|
|||||||
18
devFlake/flake.lock
generated
@@ -3,10 +3,10 @@
|
|||||||
"clan-core-for-checks": {
|
"clan-core-for-checks": {
|
||||||
"flake": false,
|
"flake": false,
|
||||||
"locked": {
|
"locked": {
|
||||||
"lastModified": 1756166884,
|
"lastModified": 1755649112,
|
||||||
"narHash": "sha256-skg4rwpbCjhpLlrv/Pndd43FoEgrJz98WARtGLhCSzo=",
|
"narHash": "sha256-Tk/Sjlb7W+5usS4upH0la4aGYs2velkHADnhhn2xO88=",
|
||||||
"ref": "main",
|
"ref": "main",
|
||||||
"rev": "f7414d7e6e58709af27b6fe16eb530278e81eaaf",
|
"rev": "bdab3e23af4a9715dfa6346f344f1bd428508672",
|
||||||
"shallow": true,
|
"shallow": true,
|
||||||
"type": "git",
|
"type": "git",
|
||||||
"url": "https://git.clan.lol/clan/clan-core"
|
"url": "https://git.clan.lol/clan/clan-core"
|
||||||
@@ -84,11 +84,11 @@
|
|||||||
},
|
},
|
||||||
"nixpkgs-dev": {
|
"nixpkgs-dev": {
|
||||||
"locked": {
|
"locked": {
|
||||||
"lastModified": 1756578978,
|
"lastModified": 1755628699,
|
||||||
"narHash": "sha256-dLgwMLIMyHlSeIDsoT2OcZBkuruIbjhIAv1sGANwtes=",
|
"narHash": "sha256-IAM29K+Cz9pu90lDDkJpmOpWzQ9Ed0FNkRJcereY+rM=",
|
||||||
"owner": "NixOS",
|
"owner": "NixOS",
|
||||||
"repo": "nixpkgs",
|
"repo": "nixpkgs",
|
||||||
"rev": "a85a50bef870537a9705f64ed75e54d1f4bf9c23",
|
"rev": "01b1b3809cfa3b0fbca8d75c9cf4b2efdb8182cf",
|
||||||
"type": "github"
|
"type": "github"
|
||||||
},
|
},
|
||||||
"original": {
|
"original": {
|
||||||
@@ -165,11 +165,11 @@
|
|||||||
"nixpkgs": []
|
"nixpkgs": []
|
||||||
},
|
},
|
||||||
"locked": {
|
"locked": {
|
||||||
"lastModified": 1755934250,
|
"lastModified": 1754847726,
|
||||||
"narHash": "sha256-CsDojnMgYsfshQw3t4zjRUkmMmUdZGthl16bXVWgRYU=",
|
"narHash": "sha256-2vX8QjO5lRsDbNYvN9hVHXLU6oMl+V/PsmIiJREG4rE=",
|
||||||
"owner": "numtide",
|
"owner": "numtide",
|
||||||
"repo": "treefmt-nix",
|
"repo": "treefmt-nix",
|
||||||
"rev": "74e1a52d5bd9430312f8d1b8b0354c92c17453e5",
|
"rev": "7d81f6fb2e19bf84f1c65135d1060d829fae2408",
|
||||||
"type": "github"
|
"type": "github"
|
||||||
},
|
},
|
||||||
"original": {
|
"original": {
|
||||||
|
|||||||
@@ -33,6 +33,7 @@
|
|||||||
self'.packages.tea-create-pr
|
self'.packages.tea-create-pr
|
||||||
self'.packages.merge-after-ci
|
self'.packages.merge-after-ci
|
||||||
self'.packages.pending-reviews
|
self'.packages.pending-reviews
|
||||||
|
self'.packages.agit
|
||||||
# treefmt with config defined in ./flake-parts/formatting.nix
|
# treefmt with config defined in ./flake-parts/formatting.nix
|
||||||
config.treefmt.build.wrapper
|
config.treefmt.build.wrapper
|
||||||
];
|
];
|
||||||
@@ -45,7 +46,7 @@
|
|||||||
ln -sfT ${inputs.nix-select} "$PRJ_ROOT/pkgs/clan-cli/clan_lib/select"
|
ln -sfT ${inputs.nix-select} "$PRJ_ROOT/pkgs/clan-cli/clan_lib/select"
|
||||||
|
|
||||||
# Generate classes.py from schemas
|
# 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
@@ -1,5 +1,5 @@
|
|||||||
/site/reference
|
/site/reference
|
||||||
/site/static
|
/site/static
|
||||||
/site/options
|
/site/options-page
|
||||||
/site/openapi.json
|
/site/openapi.json
|
||||||
!/site/static/extra.css
|
!/site/static/extra.css
|
||||||
|
|||||||
@@ -1,11 +1,13 @@
|
|||||||
{
|
{
|
||||||
lib,
|
lib,
|
||||||
|
config,
|
||||||
...
|
...
|
||||||
}:
|
}:
|
||||||
let
|
let
|
||||||
|
suffix = config.clan.core.vars.generators.disk-id.files.diskId.value;
|
||||||
mirrorBoot = idx: {
|
mirrorBoot = idx: {
|
||||||
# suffix is to prevent disk name collisions
|
# suffix is to prevent disk name collisions
|
||||||
name = idx;
|
name = idx + suffix;
|
||||||
type = "disk";
|
type = "disk";
|
||||||
device = "/dev/disk/by-id/${idx}";
|
device = "/dev/disk/by-id/${idx}";
|
||||||
content = {
|
content = {
|
||||||
|
|||||||
@@ -1,11 +1,13 @@
|
|||||||
{
|
{
|
||||||
lib,
|
lib,
|
||||||
|
config,
|
||||||
...
|
...
|
||||||
}:
|
}:
|
||||||
let
|
let
|
||||||
|
suffix = config.clan.core.vars.generators.disk-id.files.diskId.value;
|
||||||
mirrorBoot = idx: {
|
mirrorBoot = idx: {
|
||||||
# suffix is to prevent disk name collisions
|
# suffix is to prevent disk name collisions
|
||||||
name = idx;
|
name = idx + suffix;
|
||||||
type = "disk";
|
type = "disk";
|
||||||
device = "/dev/disk/by-id/${idx}";
|
device = "/dev/disk/by-id/${idx}";
|
||||||
content = {
|
content = {
|
||||||
|
|||||||
@@ -2,11 +2,11 @@ site_name: Clan Documentation
|
|||||||
site_url: https://docs.clan.lol
|
site_url: https://docs.clan.lol
|
||||||
repo_url: https://git.clan.lol/clan/clan-core/
|
repo_url: https://git.clan.lol/clan/clan-core/
|
||||||
repo_name: "_>"
|
repo_name: "_>"
|
||||||
edit_uri: _edit/main/docs/site/
|
edit_uri: _edit/main/docs/docs/
|
||||||
|
|
||||||
validation:
|
validation:
|
||||||
omitted_files: warn
|
omitted_files: warn
|
||||||
absolute_links: ignore
|
absolute_links: warn
|
||||||
unrecognized_links: warn
|
unrecognized_links: warn
|
||||||
|
|
||||||
markdown_extensions:
|
markdown_extensions:
|
||||||
@@ -59,15 +59,14 @@ nav:
|
|||||||
- Configure Disk Config: guides/getting-started/choose-disk.md
|
- Configure Disk Config: guides/getting-started/choose-disk.md
|
||||||
- Update Machine: guides/getting-started/update.md
|
- Update Machine: guides/getting-started/update.md
|
||||||
- Continuous Integration: guides/getting-started/flake-check.md
|
- Continuous Integration: guides/getting-started/flake-check.md
|
||||||
- Convert Existing NixOS Config: guides/getting-started/convert-flake.md
|
- Using Services: guides/clanServices.md
|
||||||
- ClanServices: guides/clanServices.md
|
|
||||||
- Backup & Restore: guides/backups.md
|
- Backup & Restore: guides/backups.md
|
||||||
- Disk Encryption: guides/disk-encryption.md
|
- Disk Encryption: guides/disk-encryption.md
|
||||||
- Age Plugins: guides/age-plugins.md
|
- Age Plugins: guides/age-plugins.md
|
||||||
- Secrets management: guides/secrets.md
|
- Secrets management: guides/secrets.md
|
||||||
- Networking: guides/networking.md
|
- Target Host: guides/target-host.md
|
||||||
- Zerotier VPN: guides/mesh-vpn.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
|
- Flake-parts: guides/flake-parts.md
|
||||||
- macOS: guides/macos.md
|
- macOS: guides/macos.md
|
||||||
- Contributing:
|
- Contributing:
|
||||||
@@ -78,7 +77,8 @@ nav:
|
|||||||
- Writing a Service Module: guides/services/community.md
|
- Writing a Service Module: guides/services/community.md
|
||||||
- Writing a Disko Template: guides/disko-templates/community.md
|
- Writing a Disko Template: guides/disko-templates/community.md
|
||||||
- Migrations:
|
- 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
|
- Facts Vars Migration: guides/migrations/migration-facts-vars.md
|
||||||
- Disk id: guides/migrations/disk-id.md
|
- Disk id: guides/migrations/disk-id.md
|
||||||
- Concepts:
|
- Concepts:
|
||||||
@@ -88,13 +88,12 @@ nav:
|
|||||||
- Templates: concepts/templates.md
|
- Templates: concepts/templates.md
|
||||||
- Reference:
|
- Reference:
|
||||||
- Overview: reference/index.md
|
- Overview: reference/index.md
|
||||||
- Browse Options: "/options"
|
- Clan Options: options.md
|
||||||
- Services:
|
- Services:
|
||||||
- Overview:
|
- Overview:
|
||||||
- reference/clanServices/index.md
|
- reference/clanServices/index.md
|
||||||
- reference/clanServices/admin.md
|
- reference/clanServices/admin.md
|
||||||
- reference/clanServices/borgbackup.md
|
- reference/clanServices/borgbackup.md
|
||||||
- reference/clanServices/coredns.md
|
|
||||||
- reference/clanServices/data-mesher.md
|
- reference/clanServices/data-mesher.md
|
||||||
- reference/clanServices/dyndns.md
|
- reference/clanServices/dyndns.md
|
||||||
- reference/clanServices/emergency-access.md
|
- reference/clanServices/emergency-access.md
|
||||||
@@ -107,6 +106,7 @@ nav:
|
|||||||
- reference/clanServices/monitoring.md
|
- reference/clanServices/monitoring.md
|
||||||
- reference/clanServices/packages.md
|
- reference/clanServices/packages.md
|
||||||
- reference/clanServices/sshd.md
|
- reference/clanServices/sshd.md
|
||||||
|
- reference/clanServices/state-version.md
|
||||||
- reference/clanServices/syncthing.md
|
- reference/clanServices/syncthing.md
|
||||||
- reference/clanServices/trusted-nix-caches.md
|
- reference/clanServices/trusted-nix-caches.md
|
||||||
- reference/clanServices/users.md
|
- reference/clanServices/users.md
|
||||||
@@ -155,7 +155,6 @@ nav:
|
|||||||
- 05-deployment-parameters: decisions/05-deployment-parameters.md
|
- 05-deployment-parameters: decisions/05-deployment-parameters.md
|
||||||
- Template: decisions/_template.md
|
- Template: decisions/_template.md
|
||||||
- Glossary: reference/glossary.md
|
- Glossary: reference/glossary.md
|
||||||
- Browse Options: "/options"
|
|
||||||
|
|
||||||
docs_dir: site
|
docs_dir: site
|
||||||
site_dir: out
|
site_dir: out
|
||||||
@@ -173,7 +172,6 @@ theme:
|
|||||||
- content.code.annotate
|
- content.code.annotate
|
||||||
- content.code.copy
|
- content.code.copy
|
||||||
- content.tabs.link
|
- content.tabs.link
|
||||||
- content.action.edit
|
|
||||||
icon:
|
icon:
|
||||||
repo: fontawesome/brands/git
|
repo: fontawesome/brands/git
|
||||||
custom_dir: overrides
|
custom_dir: overrides
|
||||||
|
|||||||
@@ -54,9 +54,9 @@ pkgs.stdenv.mkDerivation {
|
|||||||
chmod -R +w ./site/reference
|
chmod -R +w ./site/reference
|
||||||
echo "Generated API documentation in './site/reference/' "
|
echo "Generated API documentation in './site/reference/' "
|
||||||
|
|
||||||
rm -rf ./site/options
|
rm -r ./site/options-page || true
|
||||||
cp -r ${docs-options} ./site/options
|
cp -r ${docs-options} ./site/options-page
|
||||||
chmod -R +w ./site/options
|
chmod -R +w ./site/options-page
|
||||||
|
|
||||||
mkdir -p ./site/static/asciinema-player
|
mkdir -p ./site/static/asciinema-player
|
||||||
ln -snf ${asciinema-player-js} ./site/static/asciinema-player/asciinema-player.min.js
|
ln -snf ${asciinema-player-js} ./site/static/asciinema-player/asciinema-player.min.js
|
||||||
|
|||||||
@@ -25,7 +25,7 @@
|
|||||||
|
|
||||||
serviceModules = self.clan.modules;
|
serviceModules = self.clan.modules;
|
||||||
|
|
||||||
baseHref = "/options/";
|
baseHref = "/options-page/";
|
||||||
|
|
||||||
getRoles =
|
getRoles =
|
||||||
module:
|
module:
|
||||||
@@ -126,7 +126,7 @@
|
|||||||
nestedSettingsOption = mkOption {
|
nestedSettingsOption = mkOption {
|
||||||
type = types.raw;
|
type = types.raw;
|
||||||
description = ''
|
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 {
|
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
|
in
|
||||||
{
|
{
|
||||||
# Uncomment for debugging
|
# Uncomment for debugging
|
||||||
@@ -211,17 +175,10 @@
|
|||||||
# scopes = mapAttrsToList mkScope serviceModules;
|
# scopes = mapAttrsToList mkScope serviceModules;
|
||||||
scopes = [
|
scopes = [
|
||||||
{
|
{
|
||||||
inherit baseHref;
|
name = "Clan";
|
||||||
name = "Flake Options (clan.nix file)";
|
|
||||||
modules = docModules;
|
modules = docModules;
|
||||||
urlPrefix = "https://git.clan.lol/clan/clan-core/src/branch/main/";
|
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/";
|
|
||||||
|
|
||||||
}
|
|
||||||
];
|
];
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,5 +1,3 @@
|
|||||||
"""Module for rendering NixOS options documentation from JSON format."""
|
|
||||||
|
|
||||||
# Options are available in the following format:
|
# Options are available in the following format:
|
||||||
# https://github.com/nixos/nixpkgs/blob/master/nixos/lib/make-options-doc/default.nix
|
# 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.errors import ClanError
|
||||||
from clan_lib.services.modules import (
|
from clan_lib.services.modules import (
|
||||||
CategoryInfo,
|
CategoryInfo,
|
||||||
ModuleManifest,
|
Frontmatter,
|
||||||
)
|
)
|
||||||
|
|
||||||
# Get environment variables
|
# 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")
|
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:
|
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:
|
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:
|
Args:
|
||||||
lines (list of str): The lines of text to join.
|
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:
|
Returns:
|
||||||
str: The indented and concatenated string.
|
str: The indented and concatenated string.
|
||||||
|
|
||||||
"""
|
"""
|
||||||
# Create the indentation string (e.g., four spaces)
|
# Create the indentation string (e.g., four spaces)
|
||||||
indent_str = " " * indent
|
indent_str = " " * indent
|
||||||
@@ -163,10 +161,7 @@ def render_option(
|
|||||||
|
|
||||||
|
|
||||||
def print_options(
|
def print_options(
|
||||||
options_file: str,
|
options_file: str, head: str, no_options: str, replace_prefix: str | None = None
|
||||||
head: str,
|
|
||||||
no_options: str,
|
|
||||||
replace_prefix: str | None = None,
|
|
||||||
) -> str:
|
) -> str:
|
||||||
res = ""
|
res = ""
|
||||||
with (Path(options_file) / "share/doc/nixos/options.json").open() as f:
|
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
|
res += head if len(options.items()) else no_options
|
||||||
for option_name, info in options.items():
|
for option_name, info in options.items():
|
||||||
if replace_prefix:
|
if replace_prefix:
|
||||||
display_name = option_name.replace(replace_prefix + ".", "")
|
option_name = option_name.replace(replace_prefix + ".", "")
|
||||||
else:
|
|
||||||
display_name = option_name
|
|
||||||
|
|
||||||
res += render_option(display_name, info, 4)
|
res += render_option(option_name, info, 4)
|
||||||
return res
|
return res
|
||||||
|
|
||||||
|
|
||||||
def module_header(module_name: str) -> str:
|
def module_header(module_name: str, has_inventory_feature: bool = False) -> str:
|
||||||
return f"# {module_name}\n\n"
|
indicator = " 🔹" if has_inventory_feature else ""
|
||||||
|
return f"# {module_name}{indicator}\n\n"
|
||||||
|
|
||||||
|
|
||||||
clan_core_descr = """
|
clan_core_descr = """
|
||||||
@@ -242,7 +236,7 @@ def produce_clan_core_docs() -> None:
|
|||||||
for submodule_name, split_options in split.items():
|
for submodule_name, split_options in split.items():
|
||||||
outfile = f"{module_name}/{submodule_name}.md"
|
outfile = f"{module_name}/{submodule_name}.md"
|
||||||
print(
|
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
|
init_level = 1
|
||||||
root = options_to_tree(split_options, debug=True)
|
root = options_to_tree(split_options, debug=True)
|
||||||
@@ -277,9 +271,56 @@ def produce_clan_core_docs() -> None:
|
|||||||
of.write(output)
|
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(
|
def render_categories(
|
||||||
categories: list[str],
|
categories: list[str], categories_info: dict[str, CategoryInfo]
|
||||||
categories_info: dict[str, CategoryInfo],
|
|
||||||
) -> str:
|
) -> str:
|
||||||
res = """<div style="display: grid; grid-template-columns: repeat(3, 1fr); gap: 10px;">"""
|
res = """<div style="display: grid; grid-template-columns: repeat(3, 1fr); gap: 10px;">"""
|
||||||
for cat in categories:
|
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"`clan.modules.{module_name}`\n"
|
||||||
output += f"*{module_info['manifest']['description']}*\n"
|
output += f"*{module_info['manifest']['description']}*\n"
|
||||||
|
|
||||||
|
fm = Frontmatter("")
|
||||||
# output += "## Categories\n\n"
|
# output += "## Categories\n\n"
|
||||||
output += render_categories(
|
output += render_categories(
|
||||||
module_info["manifest"]["categories"],
|
module_info["manifest"]["categories"], fm.categories_info
|
||||||
ModuleManifest.categories_info(),
|
|
||||||
)
|
)
|
||||||
|
|
||||||
output += f"{module_info['manifest']['readme']}\n"
|
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"
|
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"
|
output += f"- {role_name}\n"
|
||||||
|
|
||||||
for role_name, role_filename in module_info["roles"].items():
|
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)
|
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]]:
|
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": { Data }
|
||||||
"a.b": { 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:
|
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
|
# Helper function to create nested structure
|
||||||
def add_to_tree(path_parts: list[str], info: Any, current_node: Option) -> None:
|
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(
|
def options_docs_from_tree(
|
||||||
root: Option,
|
root: Option, init_level: int = 1, prefix: list[str] | None = None
|
||||||
init_level: int = 1,
|
|
||||||
prefix: list[str] | None = None,
|
|
||||||
) -> str:
|
) -> str:
|
||||||
"""Eender the options from the tree structure.
|
"""
|
||||||
|
eender the options from the tree structure.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
root (Option): The root option node.
|
root (Option): The root option node.
|
||||||
init_level (int): The initial level of indentation.
|
init_level (int): The initial level of indentation.
|
||||||
prefix (list str): Will be printed as common prefix of all attribute names.
|
prefix (list str): Will be printed as common prefix of all attribute names.
|
||||||
|
|
||||||
"""
|
"""
|
||||||
|
|
||||||
def render_tree(option: Option, level: int = init_level) -> str:
|
def render_tree(option: Option, level: int = init_level) -> str:
|
||||||
output = ""
|
output = ""
|
||||||
|
|
||||||
should_render = not option.name.startswith("<") and not option.name.startswith(
|
should_render = not option.name.startswith("<") and not option.name.startswith(
|
||||||
"_",
|
"_"
|
||||||
)
|
)
|
||||||
if should_render:
|
if should_render:
|
||||||
# short_name = option_short_name(option.name)
|
# short_name = option_short_name(option.name)
|
||||||
@@ -551,10 +619,11 @@ def options_docs_from_tree(
|
|||||||
|
|
||||||
return output
|
return output
|
||||||
|
|
||||||
return render_tree(root)
|
md = render_tree(root)
|
||||||
|
return md
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__": #
|
||||||
produce_clan_core_docs()
|
produce_clan_core_docs()
|
||||||
|
|
||||||
produce_clan_service_author_docs()
|
produce_clan_service_author_docs()
|
||||||
|
|||||||
@@ -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`
|
- [x] `machines/{machineName}/configuration.nix`
|
||||||
Main configuration file for the machine.
|
- [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}/hardware-configuration.nix`
|
- [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).
|
||||||
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.
|
|
||||||
|
|||||||
@@ -1,22 +1,16 @@
|
|||||||
# Using the Inventory
|
# Using `clanServices`
|
||||||
|
|
||||||
Clan's inventory system is a composable way to define and deploy services across
|
Clan’s `clanServices` system is a composable way to define and deploy services across machines.
|
||||||
machines.
|
|
||||||
|
|
||||||
This guide shows how to **instantiate** a `clanService`, explains how service
|
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.
|
||||||
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
|
The term **Multi-host-modules** was introduced previously in the [nixus repository](https://github.com/infinisil/nixus) and represents a similar concept.
|
||||||
repository](https://github.com/infinisil/nixus) and represents a similar
|
|
||||||
concept.
|
|
||||||
|
|
||||||
______________________________________________________________________
|
---
|
||||||
|
|
||||||
## Overview
|
## Overview
|
||||||
|
|
||||||
Services are used in `inventory.instances`, and assigned to *roles* and
|
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.
|
||||||
*machines* -- meaning you decide which machines run which part of the service.
|
|
||||||
|
|
||||||
For example:
|
For example:
|
||||||
|
|
||||||
@@ -24,138 +18,119 @@ For example:
|
|||||||
inventory.instances = {
|
inventory.instances = {
|
||||||
borgbackup = {
|
borgbackup = {
|
||||||
roles.client.machines."laptop" = {};
|
roles.client.machines."laptop" = {};
|
||||||
roles.client.machines."workstation" = {};
|
roles.client.machines."server1" = {};
|
||||||
|
|
||||||
roles.server.machines."backup-box" = {};
|
roles.server.machines."backup-box" = {};
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
This says: "Run borgbackup as a *client* on my *laptop* and *workstation*, and
|
This says: “Run borgbackup as a *client* on my *laptop* and *server1*, and as a *server* on *backup-box*.”
|
||||||
as a *server* on *backup-box*". `client` and `server` are roles defined by the
|
|
||||||
`borgbackup` service.
|
|
||||||
|
|
||||||
## Module source specification
|
## Module source specification
|
||||||
|
|
||||||
Each instance includes a reference to a **module specification** -- this is how
|
Each instance includes a reference to a **module specification** — this is how Clan knows which service module to use and where it came from.
|
||||||
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
|
## Override Example
|
||||||
instance.
|
|
||||||
|
|
||||||
Example of instantiating a `borgbackup` service using `clan-core`:
|
Example of instantiating a `borgbackup` service using `clan-core`:
|
||||||
|
|
||||||
```nix
|
```nix
|
||||||
inventory.instances = {
|
inventory.instances = {
|
||||||
|
# Instance Name: Different name for this 'borgbackup' instance
|
||||||
borgbackup = { # <- Instance name
|
borgbackup = {
|
||||||
|
# Since this is instances."borgbackup" the whole `module = { ... }` below is equivalent and optional.
|
||||||
# This can be partially/fully specified,
|
module = {
|
||||||
# - If the instance name is not the name of the module
|
name = "borgbackup"; # <-- Name of the module (optional)
|
||||||
# - If the input is not clan-core
|
input = "clan-core"; # <-- The flake input where the service is defined (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
|
# 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.client.machines."machine-a" = {};
|
||||||
roles.server.machines."backup-host" = {};
|
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
|
```nix
|
||||||
reference](../reference/clanServices/index.md) for all available options.
|
# ↓ 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
|
## Simplified Example
|
||||||
machines that are assigned to that role.
|
|
||||||
|
|
||||||
|
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
|
```nix
|
||||||
inventory.instances = {
|
inventory.instances = {
|
||||||
borgbackup = {
|
borgbackup-example = {
|
||||||
# Settings for 'machine-a'
|
module = {
|
||||||
|
name = "borgbackup";
|
||||||
|
input = "clan-core";
|
||||||
|
};
|
||||||
roles.client.machines."machine-a" = {
|
roles.client.machines."machine-a" = {
|
||||||
|
# 'client' -Settings of 'machine-a'
|
||||||
settings = {
|
settings = {
|
||||||
backupFolders = [
|
backupFolders = [
|
||||||
/home
|
/home
|
||||||
/var
|
/var
|
||||||
];
|
];
|
||||||
};
|
};
|
||||||
|
# ---------------------------
|
||||||
};
|
};
|
||||||
|
roles.server.machines."backup-host" = {};
|
||||||
# Settings for all machines of the role "server"
|
|
||||||
roles.server.settings = {
|
|
||||||
directory = "/var/lib/borgbackup";
|
|
||||||
};
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
## Tags
|
## Tags
|
||||||
|
|
||||||
Tags can be used to assign multiple machines to a role at once. It can be thought of as a grouping mechanism.
|
Multiple members can be defined using tags as follows
|
||||||
|
|
||||||
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
|
|
||||||
|
|
||||||
```nix
|
```nix
|
||||||
inventory.instances = {
|
inventory.instances = {
|
||||||
borgbackup = {
|
borgbackup-example = {
|
||||||
# "All" machines are assigned to the borgbackup 'client' role
|
module = {
|
||||||
roles.client.tags = [ "all" ];
|
name = "borgbackup";
|
||||||
|
input = "clan-core";
|
||||||
# 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 ];
|
|
||||||
}
|
|
||||||
];
|
|
||||||
};
|
};
|
||||||
|
#
|
||||||
|
# The 'all' -tag targets all machines
|
||||||
|
roles.client.tags."all" = {};
|
||||||
|
# ---------------------------
|
||||||
|
roles.server.machines."backup-host" = {};
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
## Picking a clanService
|
## Picking a clanService
|
||||||
|
|
||||||
You can use services exposed by Clan's core module library, `clan-core`.
|
You can use services exposed by Clan’s core module library, `clan-core`.
|
||||||
|
|
||||||
🔗 See: [List of Available Services in clan-core](../reference/clanServices/index.md)
|
🔗 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.
|
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
|
## 💡 Tips for Working with clanServices
|
||||||
|
|
||||||
- You can add multiple inputs to your flake (`clan-core`, `your-org-modules`, etc.) to mix and match services.
|
* 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.
|
* 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.
|
* Roles can target different machines or be scoped dynamically.
|
||||||
|
|
||||||
______________________________________________________________________
|
---
|
||||||
|
|
||||||
## What's Next?
|
## What’s Next?
|
||||||
|
|
||||||
- [Author your own clanService →](../guides/services/community.md)
|
|
||||||
- [Migrate from clanModules →](../guides/migrations/migrate-inventory-services.md)
|
|
||||||
|
|
||||||
|
* [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) -->
|
<!-- TODO: * [Understand the architecture →](../explanation/clan-architecture.md) -->
|
||||||
|
|||||||
@@ -27,7 +27,7 @@ inputs = {
|
|||||||
|
|
||||||
## Import the Clan flake-parts Module
|
## 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
|
```nix
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -2,9 +2,9 @@
|
|||||||
|
|
||||||
Machines can be added using the following methods
|
Machines can be added using the following methods
|
||||||
|
|
||||||
- Create a file `machines/{machine_name}/configuration.nix` (See: [File Autoincludes](../../concepts/autoincludes.md))
|
- Editing nix expressions in flake.nix (i.e. via `clan-core.lib.clan`)
|
||||||
- Imperative via cli command: `clan machines create`
|
- Editing machines/`machine_name`/configuration.nix (automatically included if it exists)
|
||||||
- Editing nix expressions in flake.nix See [`clan-core.lib.clan`](/options/?scope=Flake Options (clan.nix file))
|
- `clan machines create` (imperative)
|
||||||
|
|
||||||
See the complete [list](../../concepts/autoincludes.md) of auto-loaded files.
|
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`
|
The imperative command might create a machine folder in `machines/jon`
|
||||||
And might persist information in `inventory.json`
|
And might persist information in `inventory.json`
|
||||||
|
|
||||||
|
|
||||||
### Configuring a machine
|
### Configuring a machine
|
||||||
|
|
||||||
!!! Note
|
!!! Note
|
||||||
|
|||||||
@@ -1,15 +1,12 @@
|
|||||||
|
|
||||||
# Update Machines
|
# Update Your Machines
|
||||||
|
|
||||||
The Clan command line interface enables you to update machines remotely over SSH.
|
Clan CLI enables you to remotely update your machines over SSH. This requires setting up a target address for each target machine.
|
||||||
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.
|
|
||||||
|
|
||||||
|
### 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 machine’s `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"}
|
```{.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.
|
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.
|
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
|
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.
|
||||||
Execute the following command to update the specified machine:
|
During an update, the cli will ssh into the build host and run `nixos-rebuild` from there.
|
||||||
|
|
||||||
```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 machine’s Nix configuration, **not** under `inventory.machines`.
|
|
||||||
|
|
||||||
|
|
||||||
```{.nix hl_lines="5" .no-copy}
|
```{.nix hl_lines="5" .no-copy}
|
||||||
@@ -75,11 +45,7 @@ buildClan {
|
|||||||
};
|
};
|
||||||
```
|
```
|
||||||
|
|
||||||
### Overriding configuration with CLI flags
|
You can also override the build host via the command line:
|
||||||
|
|
||||||
`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)
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Build on a remote host
|
# Build on a remote host
|
||||||
@@ -90,9 +56,23 @@ clan machines update jon --build-host local
|
|||||||
```
|
```
|
||||||
|
|
||||||
!!! Note
|
!!! 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`
|
### 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
|
### Uploading Flake Inputs
|
||||||
|
|
||||||
When updating remote machines, flake inputs are usually fetched by the build host.
|
When updating remote machines, flake inputs are usually fetched by the build host.
|
||||||
However, if flake inputs require authentication (e.g., private repositories),
|
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:
|
||||||
Use the `--upload-inputs` flag to upload all inputs from your local machine:
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
clan machines update jon --upload-inputs
|
clan machines update jon --upload-inputs
|
||||||
```
|
```
|
||||||
|
|
||||||
This is particularly useful when:
|
This is particularly useful when:
|
||||||
- The flake references private Git repositories
|
- Your flake references private Git repositories
|
||||||
- Authentication credentials are only available on local machine
|
- Authentication credentials are only available on your local machine
|
||||||
- The build host doesn't have access to certain network resources
|
- The build host doesn't have access to certain network resources
|
||||||
|
|||||||
@@ -254,7 +254,7 @@ The following table shows the migration status of each deprecated clanModule:
|
|||||||
| `data-mesher` | ✅ [Migrated](../../reference/clanServices/data-mesher.md) | |
|
| `data-mesher` | ✅ [Migrated](../../reference/clanServices/data-mesher.md) | |
|
||||||
| `deltachat` | ❌ Removed | |
|
| `deltachat` | ❌ Removed | |
|
||||||
| `disk-id` | ❌ Removed | |
|
| `disk-id` | ❌ Removed | |
|
||||||
| `dyndns` | ✅ [Migrated](../../reference/clanServices/dyndns.md) | |
|
| `dyndns` | [Being Migrated](https://git.clan.lol/clan/clan-core/pulls/4390) | |
|
||||||
| `ergochat` | ❌ Removed | |
|
| `ergochat` | ❌ Removed | |
|
||||||
| `garage` | ✅ [Migrated](../../reference/clanServices/garage.md) | |
|
| `garage` | ✅ [Migrated](../../reference/clanServices/garage.md) | |
|
||||||
| `golem-provider` | ❌ Removed | |
|
| `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 |
|
| `iwd` | ❌ Removed | Use [wifi service](../../reference/clanServices/wifi.md) instead |
|
||||||
| `localbackup` | ✅ [Migrated](../../reference/clanServices/localbackup.md) | |
|
| `localbackup` | ✅ [Migrated](../../reference/clanServices/localbackup.md) | |
|
||||||
| `localsend` | ❌ Removed | |
|
| `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) | |
|
| `matrix-synapse` | ✅ [Migrated](../../reference/clanServices/matrix-synapse.md) | |
|
||||||
| `moonlight` | ❌ Removed | |
|
| `moonlight` | ❌ Removed | |
|
||||||
| `mumble` | ❌ Removed | |
|
| `mumble` | ❌ Removed | |
|
||||||
| `mycelium` | ✅ [Migrated](../../reference/clanServices/mycelium.md) | |
|
| `mycelium` | ✅ [Migrated](../../reference/clanServices/mycelium.md) | |
|
||||||
| `nginx` | ❌ Removed | |
|
| `nginx` | ❌ Removed | |
|
||||||
| `packages` | ✅ [Migrated](../../reference/clanServices/packages.md) | |
|
| `packages` | ✅ [Migrated](../../reference/clanServices/packages.md) | |
|
||||||
| `postgresql` | ✅ [Migrated](../../reference/clan.core/settings.md) | Now an [option](../../reference/clan.core/settings.md) |
|
| `postgresql` | ❌ Removed | 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) |
|
| `root-password` | ✅ [Migrated](../../reference/clanServices/users.md) | |
|
||||||
| `single-disk` | ❌ Removed | |
|
| `single-disk` | ❌ Removed | |
|
||||||
| `sshd` | ✅ [Migrated](../../reference/clanServices/sshd.md) | |
|
| `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 | |
|
| `static-hosts` | ❌ Removed | |
|
||||||
| `sunshine` | ❌ Removed | |
|
| `sunshine` | ❌ Removed | |
|
||||||
| `syncthing-static-peers` | ❌ Removed | |
|
| `syncthing-static-peers` | ❌ Removed | |
|
||||||
|
|||||||
@@ -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
|
!!! Warning
|
||||||
Migrating instead of starting new can be trickier and might lead to bugs or
|
Migrating instead of starting new can be trickier and might lead to bugs or
|
||||||
unexpected issues. We recommend reading the [Getting Started](./index.md) guide first.
|
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.
|
||||||
|
|
||||||
Once you have a working setup and understand the concepts transfering your NixOS configurations over is easy.
|
|
||||||
|
|
||||||
## Back up your existing configuration
|
|
||||||
|
|
||||||
|
## Back up your existing configuration!
|
||||||
Before you start, it is strongly recommended to back up your existing
|
Before you start, it is strongly recommended to back up your existing
|
||||||
configuration in any form you see fit. If you use version control to manage
|
configuration in any form you see fit. If you use version control to manage
|
||||||
your configuration changes, it is also a good idea to follow the migration
|
your configuration changes, it is also a good idea to follow the migration
|
||||||
guide in a separte branch until everything works as expected.
|
guide in a separte branch until everything works as expected.
|
||||||
|
|
||||||
|
|
||||||
## Starting Point
|
## Starting Point
|
||||||
|
|
||||||
We assume you are already using NixOS flakes to manage your configuration. If
|
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
|
```nix
|
||||||
inputs.clan-core = {
|
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
|
To be able to access our newly added dependency, it has to be added to the
|
||||||
output parameters.
|
output parameters.
|
||||||
@@ -104,23 +103,26 @@ For the provide flake example, your flake should now look like this:
|
|||||||
};
|
};
|
||||||
in
|
in
|
||||||
{
|
{
|
||||||
inherit (clan.config) nixosConfigurations nixosModules clanInternals;
|
nixosConfigurations = clan.nixosConfigurations;
|
||||||
clan = clan.config;
|
|
||||||
|
inherit (clan) clanInternals;
|
||||||
|
|
||||||
|
clan = {
|
||||||
|
inherit (clan) templates;
|
||||||
|
};
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
✅ Et voilà! Your existing hosts are now part of a clan.
|
Et voilà! Your existing hosts are now part of a clan. Existing Nix tooling
|
||||||
|
|
||||||
Existing Nix tooling
|
|
||||||
should still work as normal. To check that you didn't make any errors, run `nix
|
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
|
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
|
❯ nix flake show
|
||||||
git+file:///my-nixos-config
|
git+file:///my-nixos-config
|
||||||
├───clan: unknown
|
├───clanInternals: unknown
|
||||||
└───nixosConfigurations
|
└───nixosConfigurations
|
||||||
├───berlin: NixOS configuration
|
├───berlin: NixOS configuration
|
||||||
└───cologne: 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
|
Of course you can also rebuild your configuration using `nixos-rebuild` and
|
||||||
veryify everything still works.
|
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
|
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
|
recommended to expose it via a `devShell` in your flake. It is also possible to
|
||||||
@@ -161,8 +163,8 @@ cologne
|
|||||||
|
|
||||||
## Specify Targets
|
## Specify Targets
|
||||||
|
|
||||||
Clan needs to know where it can reach your hosts. For testing purpose set
|
Clan needs to know where it can reach your hosts. For each of your hosts, set
|
||||||
`clan.core.networking.targetHost` to the machines adress or hostname.
|
`clan.core.networking.targetHost` to its adress or hostname.
|
||||||
|
|
||||||
```nix
|
```nix
|
||||||
# machines/berlin/configuration.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
|
## Next Steps
|
||||||
|
|
||||||
You are now fully set up. Use the CLI to manage your hosts or proceed to
|
You are now fully set up. Use the CLI to manage your hosts or proceed to
|
||||||
@@ -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.
|
|
||||||
@@ -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
|
The benefit of this approach is that downstream users can override the value of `myClan` by using `mkForce` or other priority modifiers.
|
||||||
`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 = { };
|
|
||||||
};
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Further Reading
|
## Further
|
||||||
|
|
||||||
- [Reference Documentation for Service Authors](../../reference/clanServices/clan-service-author-interface.md)
|
- [Reference Documentation for Service Authors](../../reference/clanServices/clan-service-author-interface.md)
|
||||||
- [Migration Guide from ClanModules to ClanServices](../../guides/migrations/migrate-inventory-services.md)
|
- [Migration Guide from ClanModules to ClanServices](../../guides/migrations/migrate-inventory-services.md)
|
||||||
|
|||||||
84
docs/site/guides/target-host.md
Normal 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 doesn’t 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 machine’s 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 it’s 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
|
||||||
|
|
||||||
|
We’re working on a new networking module that will automatically do all of this for you.
|
||||||
|
|
||||||
|
- Easier to use
|
||||||
|
- Sane defaults: You’ll 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
@@ -0,0 +1,6 @@
|
|||||||
|
---
|
||||||
|
template: options.html
|
||||||
|
---
|
||||||
|
|
||||||
|
|
||||||
|
<iframe src="/options-page/" height="1000" width="100%"></iframe>
|
||||||
@@ -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)
|
- Learn how to use the [Clan CLI](./cli/index.md)
|
||||||
- Explore available [services](./clanServices/index.md)
|
- Explore available [services](./clanServices/index.md)
|
||||||
- [NixOS Configuration Options](./clan.core/index.md) - Additional options avilable on a NixOS machine.
|
- [NixOS Configuration Options](./clan.core/index.md) - Additional options avilable on a NixOS machine.
|
||||||
|
|||||||
40
flake.lock
generated
@@ -13,11 +13,11 @@
|
|||||||
]
|
]
|
||||||
},
|
},
|
||||||
"locked": {
|
"locked": {
|
||||||
"lastModified": 1756091210,
|
"lastModified": 1753067306,
|
||||||
"narHash": "sha256-oEUEAZnLbNHi8ti4jY8x10yWcIkYoFc5XD+2hjmOS04=",
|
"narHash": "sha256-jyoEbaXa8/MwVQ+PajUdT63y3gYhgD9o7snO/SLaikw=",
|
||||||
"rev": "eb831bca21476fa8f6df26cb39e076842634700d",
|
"rev": "18dfd42bdb2cfff510b8c74206005f733e38d8b9",
|
||||||
"type": "tarball",
|
"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": {
|
"original": {
|
||||||
"type": "tarball",
|
"type": "tarball",
|
||||||
@@ -31,11 +31,11 @@
|
|||||||
]
|
]
|
||||||
},
|
},
|
||||||
"locked": {
|
"locked": {
|
||||||
"lastModified": 1756115622,
|
"lastModified": 1755519972,
|
||||||
"narHash": "sha256-iv8xVtmLMNLWFcDM/HcAPLRGONyTRpzL9NS09RnryRM=",
|
"narHash": "sha256-bU4nqi3IpsUZJeyS8Jk85ytlX61i4b0KCxXX9YcOgVc=",
|
||||||
"owner": "nix-community",
|
"owner": "nix-community",
|
||||||
"repo": "disko",
|
"repo": "disko",
|
||||||
"rev": "bafad29f89e83b2d861b493aa23034ea16595560",
|
"rev": "4073ff2f481f9ef3501678ff479ed81402caae6d",
|
||||||
"type": "github"
|
"type": "github"
|
||||||
},
|
},
|
||||||
"original": {
|
"original": {
|
||||||
@@ -71,11 +71,11 @@
|
|||||||
]
|
]
|
||||||
},
|
},
|
||||||
"locked": {
|
"locked": {
|
||||||
"lastModified": 1755825449,
|
"lastModified": 1755275010,
|
||||||
"narHash": "sha256-XkiN4NM9Xdy59h69Pc+Vg4PxkSm9EWl6u7k6D5FZ5cM=",
|
"narHash": "sha256-lEApCoWUEWh0Ifc3k1JdVjpMtFFXeL2gG1qvBnoRc2I=",
|
||||||
"owner": "nix-darwin",
|
"owner": "nix-darwin",
|
||||||
"repo": "nix-darwin",
|
"repo": "nix-darwin",
|
||||||
"rev": "8df64f819698c1fee0c2969696f54a843b2231e8",
|
"rev": "7220b01d679e93ede8d7b25d6f392855b81dd475",
|
||||||
"type": "github"
|
"type": "github"
|
||||||
},
|
},
|
||||||
"original": {
|
"original": {
|
||||||
@@ -86,11 +86,11 @@
|
|||||||
},
|
},
|
||||||
"nix-select": {
|
"nix-select": {
|
||||||
"locked": {
|
"locked": {
|
||||||
"lastModified": 1755887746,
|
"lastModified": 1745005516,
|
||||||
"narHash": "sha256-lzWbpHKX0WAn/jJDoCijIDss3rqYIPawe46GDaE6U3g=",
|
"narHash": "sha256-IVaoOGDIvAa/8I0sdiiZuKptDldrkDWUNf/+ezIRhyc=",
|
||||||
"rev": "92c2574c5e113281591be01e89bb9ddb31d19156",
|
"rev": "69d8bf596194c5c35a4e90dd02c52aa530caddf8",
|
||||||
"type": "tarball",
|
"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": {
|
"original": {
|
||||||
"type": "tarball",
|
"type": "tarball",
|
||||||
@@ -99,11 +99,11 @@
|
|||||||
},
|
},
|
||||||
"nixos-facter-modules": {
|
"nixos-facter-modules": {
|
||||||
"locked": {
|
"locked": {
|
||||||
"lastModified": 1756291602,
|
"lastModified": 1755504238,
|
||||||
"narHash": "sha256-FYhiArSzcx60OwoH3JBp5Ho1D5HEwmZx6WoquauDv3g=",
|
"narHash": "sha256-mw7q5DPdmz/1au8mY0u1DztRgVyJToGJfJszxjKSNes=",
|
||||||
"owner": "nix-community",
|
"owner": "nix-community",
|
||||||
"repo": "nixos-facter-modules",
|
"repo": "nixos-facter-modules",
|
||||||
"rev": "5c37cee817c94f50710ab11c25de572bc3604bd5",
|
"rev": "354ed498c9628f32383c3bf5b6668a17cdd72a28",
|
||||||
"type": "github"
|
"type": "github"
|
||||||
},
|
},
|
||||||
"original": {
|
"original": {
|
||||||
@@ -181,11 +181,11 @@
|
|||||||
]
|
]
|
||||||
},
|
},
|
||||||
"locked": {
|
"locked": {
|
||||||
"lastModified": 1755934250,
|
"lastModified": 1754847726,
|
||||||
"narHash": "sha256-CsDojnMgYsfshQw3t4zjRUkmMmUdZGthl16bXVWgRYU=",
|
"narHash": "sha256-2vX8QjO5lRsDbNYvN9hVHXLU6oMl+V/PsmIiJREG4rE=",
|
||||||
"owner": "numtide",
|
"owner": "numtide",
|
||||||
"repo": "treefmt-nix",
|
"repo": "treefmt-nix",
|
||||||
"rev": "74e1a52d5bd9430312f8d1b8b0354c92c17453e5",
|
"rev": "7d81f6fb2e19bf84f1c65135d1060d829fae2408",
|
||||||
"type": "github"
|
"type": "github"
|
||||||
},
|
},
|
||||||
"original": {
|
"original": {
|
||||||
|
|||||||
@@ -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.
|
# 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
|
# 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" = []
|
# We assign "type" = []
|
||||||
# This means any value is valid — or like TypeScript's unknown.
|
# This means any value is valid — or like TypeScript’s unknown.
|
||||||
# We can assign the type later, when we know the exact interface.
|
# 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
|
# tsType = "unknown" is a type that we preload for json2ts, such that it gets the correct type in typescript
|
||||||
(option.type.name == "deferredModule")
|
(option.type.name == "deferredModule")
|
||||||
|
|||||||
@@ -245,8 +245,6 @@ in
|
|||||||
in
|
in
|
||||||
{ config, ... }:
|
{ config, ... }:
|
||||||
{
|
{
|
||||||
staticModules = clan-core.clan.modules;
|
|
||||||
|
|
||||||
distributedServices = clanLib.inventory.mapInstances {
|
distributedServices = clanLib.inventory.mapInstances {
|
||||||
inherit (clanConfig) inventory exportsModule;
|
inherit (clanConfig) inventory exportsModule;
|
||||||
inherit flakeInputs directory;
|
inherit flakeInputs directory;
|
||||||
|
|||||||
@@ -639,7 +639,7 @@ in
|
|||||||
|
|
||||||
Exports are used to share and expose information between instances.
|
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:
|
Accessing the exports:
|
||||||
|
|
||||||
|
|||||||
@@ -21,14 +21,14 @@ let
|
|||||||
"secrets"
|
"secrets"
|
||||||
"templates"
|
"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";
|
name = "clan-schema-files";
|
||||||
buildInputs = [ pkgs.cue ];
|
buildInputs = [ pkgs.cue ];
|
||||||
src = ./.;
|
src = ./.;
|
||||||
buildPhase = ''
|
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
|
cp $SCHEMA schema.json
|
||||||
# Also generate a CUE schema version that is derived from the JSON schema
|
# Also generate a CUE schema version that is derived from the JSON schema
|
||||||
cue import -f -p compose -l '#Root:' schema.json
|
cue import -f -p compose -l '#Root:' schema.json
|
||||||
@@ -41,7 +41,7 @@ in
|
|||||||
{
|
{
|
||||||
inherit
|
inherit
|
||||||
flakeOptions
|
flakeOptions
|
||||||
clanSchemaNix
|
clanSchema
|
||||||
clanSchemaJson
|
clan-schema-abstract
|
||||||
;
|
;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -27,9 +27,7 @@ in
|
|||||||
default = { };
|
default = { };
|
||||||
};
|
};
|
||||||
tags = lib.mkOption {
|
tags = lib.mkOption {
|
||||||
type = types.coercedTo (types.listOf types.str) (t: lib.genAttrs t (_: { })) (
|
type = types.attrsOf (types.submodule { });
|
||||||
types.attrsOf (types.submodule { })
|
|
||||||
);
|
|
||||||
default = { };
|
default = { };
|
||||||
};
|
};
|
||||||
settings =
|
settings =
|
||||||
|
|||||||
@@ -23,12 +23,6 @@ let
|
|||||||
};
|
};
|
||||||
in
|
in
|
||||||
{
|
{
|
||||||
options.staticModules = lib.mkOption {
|
|
||||||
readOnly = true;
|
|
||||||
type = lib.types.raw;
|
|
||||||
|
|
||||||
apply = moduleSet: lib.mapAttrs (inspectModule "<clan-core>") moduleSet;
|
|
||||||
};
|
|
||||||
options.modulesPerSource = lib.mkOption {
|
options.modulesPerSource = lib.mkOption {
|
||||||
# { sourceName :: { moduleName :: {} }}
|
# { sourceName :: { moduleName :: {} }}
|
||||||
readOnly = true;
|
readOnly = true;
|
||||||
|
|||||||
@@ -1,5 +1,3 @@
|
|||||||
"""Test driver for container-based NixOS testing."""
|
|
||||||
|
|
||||||
import argparse
|
import argparse
|
||||||
import ctypes
|
import ctypes
|
||||||
import os
|
import os
|
||||||
@@ -13,7 +11,7 @@ import uuid
|
|||||||
from collections.abc import Callable
|
from collections.abc import Callable
|
||||||
from contextlib import _GeneratorContextManager
|
from contextlib import _GeneratorContextManager
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
from functools import cache, cached_property
|
from functools import cached_property
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from tempfile import NamedTemporaryFile, TemporaryDirectory
|
from tempfile import NamedTemporaryFile, TemporaryDirectory
|
||||||
from typing import Any
|
from typing import Any
|
||||||
@@ -22,21 +20,23 @@ from colorama import Fore, Style
|
|||||||
|
|
||||||
from .logger import AbstractLogger, CompositeLogger, TerminalLogger
|
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:
|
def init_test_environment() -> None:
|
||||||
"""Set up the test environment (network bridge, /etc/passwd) once."""
|
"""Set up the test environment (network bridge, /etc/passwd) once."""
|
||||||
|
global _test_env_initialized
|
||||||
|
if _test_env_initialized:
|
||||||
|
return
|
||||||
|
|
||||||
# Set up network bridge
|
# Set up network bridge
|
||||||
subprocess.run(
|
subprocess.run(
|
||||||
["ip", "link", "add", "br0", "type", "bridge"],
|
["ip", "link", "add", "br0", "type", "bridge"], check=True, text=True
|
||||||
check=True,
|
|
||||||
text=True,
|
|
||||||
)
|
)
|
||||||
subprocess.run(["ip", "link", "set", "br0", "up"], check=True, text=True)
|
subprocess.run(["ip", "link", "set", "br0", "up"], check=True, text=True)
|
||||||
subprocess.run(
|
subprocess.run(
|
||||||
["ip", "addr", "add", "192.168.1.254/24", "dev", "br0"],
|
["ip", "addr", "add", "192.168.1.254/24", "dev", "br0"], check=True, text=True
|
||||||
check=True,
|
|
||||||
text=True,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
# Set up minimal passwd file for unprivileged operations
|
# 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
|
passwd_content = """root:x:0:0:Root:/root:/bin/sh
|
||||||
nixbld:x:1000:100:Nix build user:/tmp:/bin/sh
|
nixbld:x:1000:100:Nix build user:/tmp:/bin/sh
|
||||||
nobody:x:65534:65534:Nobody:/:/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:
|
with NamedTemporaryFile(mode="w", delete=False, prefix="test-passwd-") as f:
|
||||||
f.write(passwd_content)
|
f.write(passwd_content)
|
||||||
@@ -84,6 +84,8 @@ nogroup:x:65534:
|
|||||||
errno = ctypes.get_errno()
|
errno = ctypes.get_errno()
|
||||||
raise OSError(errno, os.strerror(errno), "Failed to mount group")
|
raise OSError(errno, os.strerror(errno), "Failed to mount group")
|
||||||
|
|
||||||
|
_test_env_initialized = True
|
||||||
|
|
||||||
|
|
||||||
# Load the C library
|
# Load the C library
|
||||||
libc = ctypes.CDLL("libc.so.6", use_errno=True)
|
libc = ctypes.CDLL("libc.so.6", use_errno=True)
|
||||||
@@ -109,7 +111,8 @@ def mount(
|
|||||||
mountflags: int = 0,
|
mountflags: int = 0,
|
||||||
data: str | None = None,
|
data: str | None = 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 source: The source of the file system (e.g., device name, remote filesystem).
|
||||||
:param target: The mount point (an existing directory).
|
:param target: The mount point (an existing directory).
|
||||||
@@ -126,11 +129,7 @@ def mount(
|
|||||||
|
|
||||||
# Call the mount system call
|
# Call the mount system call
|
||||||
result = libc.mount(
|
result = libc.mount(
|
||||||
source_c,
|
source_c, target_c, fstype_c, ctypes.c_ulong(mountflags), data_c
|
||||||
target_c,
|
|
||||||
fstype_c,
|
|
||||||
ctypes.c_ulong(mountflags),
|
|
||||||
data_c,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
if result != 0:
|
if result != 0:
|
||||||
@@ -142,11 +141,11 @@ class Error(Exception):
|
|||||||
pass
|
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.mkdir(parents=True, exist_ok=True)
|
||||||
root.joinpath("etc").mkdir(parents=True, exist_ok=True)
|
root.joinpath("etc").mkdir(parents=True, exist_ok=True)
|
||||||
root.joinpath(".env").write_text(
|
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,
|
"""Call the given function repeatedly, with 1 second intervals,
|
||||||
until it returns True or a timeout is reached.
|
until it returns True or a timeout is reached.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
for _ in range(timeout):
|
for _ in range(timeout):
|
||||||
if fn(False):
|
if fn(False):
|
||||||
return
|
return
|
||||||
@@ -189,7 +189,7 @@ class Machine:
|
|||||||
return self.get_systemd_process()
|
return self.get_systemd_process()
|
||||||
|
|
||||||
def start(self) -> None:
|
def start(self) -> None:
|
||||||
prepare_machine_root(self.rootdir)
|
prepare_machine_root(self.name, self.rootdir)
|
||||||
init_test_environment()
|
init_test_environment()
|
||||||
cmd = [
|
cmd = [
|
||||||
"systemd-nspawn",
|
"systemd-nspawn",
|
||||||
@@ -212,12 +212,8 @@ class Machine:
|
|||||||
self.process = subprocess.Popen(cmd, stdout=subprocess.PIPE, text=True, env=env)
|
self.process = subprocess.Popen(cmd, stdout=subprocess.PIPE, text=True, env=env)
|
||||||
|
|
||||||
def get_systemd_process(self) -> int:
|
def get_systemd_process(self) -> int:
|
||||||
if self.process is None:
|
assert self.process is not None, "Machine not started"
|
||||||
msg = "Machine not started"
|
assert self.process.stdout is not None, "Machine has no stdout"
|
||||||
raise RuntimeError(msg)
|
|
||||||
if self.process.stdout is None:
|
|
||||||
msg = "Machine has no stdout"
|
|
||||||
raise RuntimeError(msg)
|
|
||||||
|
|
||||||
for line in self.process.stdout:
|
for line in self.process.stdout:
|
||||||
print(line, end="")
|
print(line, end="")
|
||||||
@@ -234,9 +230,9 @@ class Machine:
|
|||||||
.read_text()
|
.read_text()
|
||||||
.split()
|
.split()
|
||||||
)
|
)
|
||||||
if len(childs) != 1:
|
assert len(childs) == 1, (
|
||||||
msg = f"Expected exactly one child process for systemd-nspawn, got {childs}"
|
f"Expected exactly one child process for systemd-nspawn, got {childs}"
|
||||||
raise RuntimeError(msg)
|
)
|
||||||
try:
|
try:
|
||||||
return int(childs[0])
|
return int(childs[0])
|
||||||
except ValueError as e:
|
except ValueError as e:
|
||||||
@@ -256,9 +252,7 @@ class Machine:
|
|||||||
|
|
||||||
def tuple_from_line(line: str) -> tuple[str, str]:
|
def tuple_from_line(line: str) -> tuple[str, str]:
|
||||||
match = line_pattern.match(line)
|
match = line_pattern.match(line)
|
||||||
if match is None:
|
assert match is not None
|
||||||
msg = f"Failed to parse line: {line}"
|
|
||||||
raise RuntimeError(msg)
|
|
||||||
return match[1], match[2]
|
return match[1], match[2]
|
||||||
|
|
||||||
return dict(
|
return dict(
|
||||||
@@ -286,11 +280,12 @@ class Machine:
|
|||||||
def execute(
|
def execute(
|
||||||
self,
|
self,
|
||||||
command: str,
|
command: str,
|
||||||
check_return: bool = True, # noqa: ARG002
|
check_return: bool = True,
|
||||||
check_output: bool = True, # noqa: ARG002
|
check_output: bool = True,
|
||||||
timeout: int | None = 900,
|
timeout: int | None = 900,
|
||||||
) -> subprocess.CompletedProcess:
|
) -> 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:
|
Commands are run with `set -euo pipefail` set:
|
||||||
|
|
||||||
@@ -321,21 +316,21 @@ class Machine:
|
|||||||
`timeout` parameter, e.g., `execute(cmd, timeout=10)` or
|
`timeout` parameter, e.g., `execute(cmd, timeout=10)` or
|
||||||
`execute(cmd, timeout=None)`. The default is 900 seconds.
|
`execute(cmd, timeout=None)`. The default is 900 seconds.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
# Always run command with shell opts
|
# Always run command with shell opts
|
||||||
command = f"set -eo pipefail; source /etc/profile; set -xu; {command}"
|
command = f"set -eo pipefail; source /etc/profile; set -xu; {command}"
|
||||||
|
|
||||||
return subprocess.run(
|
proc = subprocess.run(
|
||||||
self.nsenter_command(command),
|
self.nsenter_command(command),
|
||||||
timeout=timeout,
|
timeout=timeout,
|
||||||
check=False,
|
check=False,
|
||||||
stdout=subprocess.PIPE,
|
stdout=subprocess.PIPE,
|
||||||
text=True,
|
text=True,
|
||||||
)
|
)
|
||||||
|
return proc
|
||||||
|
|
||||||
def nested(
|
def nested(
|
||||||
self,
|
self, msg: str, attrs: dict[str, str] | None = None
|
||||||
msg: str,
|
|
||||||
attrs: dict[str, str] | None = None,
|
|
||||||
) -> _GeneratorContextManager:
|
) -> _GeneratorContextManager:
|
||||||
if attrs is None:
|
if attrs is None:
|
||||||
attrs = {}
|
attrs = {}
|
||||||
@@ -344,7 +339,8 @@ class Machine:
|
|||||||
return self.logger.nested(msg, my_attrs)
|
return self.logger.nested(msg, my_attrs)
|
||||||
|
|
||||||
def systemctl(self, q: str) -> subprocess.CompletedProcess:
|
def systemctl(self, q: str) -> subprocess.CompletedProcess:
|
||||||
"""Runs `systemctl` commands with optional support for
|
"""
|
||||||
|
Runs `systemctl` commands with optional support for
|
||||||
`systemctl --user`
|
`systemctl --user`
|
||||||
|
|
||||||
```py
|
```py
|
||||||
@@ -359,7 +355,8 @@ class Machine:
|
|||||||
return self.execute(f"systemctl {q}")
|
return self.execute(f"systemctl {q}")
|
||||||
|
|
||||||
def wait_until_succeeds(self, command: str, timeout: int = 900) -> str:
|
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.
|
Has a default timeout of 900 seconds which can be modified, e.g.
|
||||||
`wait_until_succeeds(cmd, timeout=10)`. See `execute` for details on
|
`wait_until_succeeds(cmd, timeout=10)`. See `execute` for details on
|
||||||
command execution.
|
command execution.
|
||||||
@@ -377,17 +374,18 @@ class Machine:
|
|||||||
return output
|
return output
|
||||||
|
|
||||||
def wait_for_open_port(
|
def wait_for_open_port(
|
||||||
self,
|
self, port: int, addr: str = "localhost", timeout: int = 900
|
||||||
port: int,
|
|
||||||
addr: str = "localhost",
|
|
||||||
timeout: int = 900,
|
|
||||||
) -> None:
|
) -> 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}"
|
command = f"nc -z {shlex.quote(addr)} {port}"
|
||||||
self.wait_until_succeeds(command, timeout=timeout)
|
self.wait_until_succeeds(command, timeout=timeout)
|
||||||
|
|
||||||
def wait_for_file(self, filename: str, timeout: int = 30) -> None:
|
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:
|
def check_file(_last_try: bool) -> bool:
|
||||||
result = self.execute(f"test -e {filename}")
|
result = self.execute(f"test -e {filename}")
|
||||||
@@ -397,7 +395,8 @@ class Machine:
|
|||||||
retry(check_file, timeout)
|
retry(check_file, timeout)
|
||||||
|
|
||||||
def wait_for_unit(self, unit: str, timeout: int = 900) -> None:
|
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
|
Throws exceptions on "failed" and "inactive" states as well as after
|
||||||
timing out.
|
timing out.
|
||||||
"""
|
"""
|
||||||
@@ -442,7 +441,9 @@ class Machine:
|
|||||||
return res.stdout
|
return res.stdout
|
||||||
|
|
||||||
def shutdown(self) -> None:
|
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:
|
if self.process:
|
||||||
self.process.terminate()
|
self.process.terminate()
|
||||||
self.process.wait()
|
self.process.wait()
|
||||||
@@ -556,7 +557,7 @@ class Driver:
|
|||||||
rootdir=tempdir_path / container.name,
|
rootdir=tempdir_path / container.name,
|
||||||
out_dir=self.out_dir,
|
out_dir=self.out_dir,
|
||||||
logger=self.logger,
|
logger=self.logger,
|
||||||
),
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
def start_all(self) -> None:
|
def start_all(self) -> None:
|
||||||
@@ -574,15 +575,13 @@ class Driver:
|
|||||||
# We lauch a sleep here, so we can pgrep the process cmdline for
|
# We lauch a sleep here, so we can pgrep the process cmdline for
|
||||||
# the uuid
|
# the uuid
|
||||||
sleep = shutil.which("sleep")
|
sleep = shutil.which("sleep")
|
||||||
if sleep is None:
|
assert sleep is not None, "sleep command not found"
|
||||||
msg = "sleep command not found"
|
|
||||||
raise RuntimeError(msg)
|
|
||||||
machine.execute(
|
machine.execute(
|
||||||
f"systemd-run /bin/sh -c '{sleep} 999999999 && echo {nspawn_uuid}'",
|
f"systemd-run /bin/sh -c '{sleep} 999999999 && echo {nspawn_uuid}'",
|
||||||
)
|
)
|
||||||
|
|
||||||
print(
|
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(
|
print(
|
||||||
" ".join(
|
" ".join(
|
||||||
@@ -604,8 +603,8 @@ class Driver:
|
|||||||
"-c",
|
"-c",
|
||||||
"bash",
|
"bash",
|
||||||
Style.RESET_ALL,
|
Style.RESET_ALL,
|
||||||
],
|
]
|
||||||
),
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
def test_symbols(self) -> dict[str, Any]:
|
def test_symbols(self) -> dict[str, Any]:
|
||||||
@@ -624,13 +623,13 @@ class Driver:
|
|||||||
"additionally exposed symbols:\n "
|
"additionally exposed symbols:\n "
|
||||||
+ ", ".join(m.name for m in self.machines)
|
+ ", ".join(m.name for m in self.machines)
|
||||||
+ ",\n "
|
+ ",\n "
|
||||||
+ ", ".join(list(general_symbols.keys())),
|
+ ", ".join(list(general_symbols.keys()))
|
||||||
)
|
)
|
||||||
return {**general_symbols, **machine_symbols}
|
return {**general_symbols, **machine_symbols}
|
||||||
|
|
||||||
def test_script(self) -> None:
|
def test_script(self) -> None:
|
||||||
"""Run the test script"""
|
"""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:
|
def run_tests(self) -> None:
|
||||||
"""Run the test script (for non-interactive test runs)"""
|
"""Run the test script (for non-interactive test runs)"""
|
||||||
|
|||||||
@@ -25,31 +25,27 @@ class AbstractLogger(ABC):
|
|||||||
@abstractmethod
|
@abstractmethod
|
||||||
@contextmanager
|
@contextmanager
|
||||||
def subtest(
|
def subtest(
|
||||||
self,
|
self, name: str, attributes: dict[str, str] | None = None
|
||||||
name: str,
|
|
||||||
attributes: dict[str, str] | None = None,
|
|
||||||
) -> Iterator[None]:
|
) -> Iterator[None]:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
@abstractmethod
|
@abstractmethod
|
||||||
@contextmanager
|
@contextmanager
|
||||||
def nested(
|
def nested(
|
||||||
self,
|
self, message: str, attributes: dict[str, str] | None = None
|
||||||
message: str,
|
|
||||||
attributes: dict[str, str] | None = None,
|
|
||||||
) -> Iterator[None]:
|
) -> Iterator[None]:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
@abstractmethod
|
@abstractmethod
|
||||||
def info(self, *args: Any, **kwargs: Any) -> None:
|
def info(self, *args: Any, **kwargs: Any) -> None: # type: ignore
|
||||||
pass
|
pass
|
||||||
|
|
||||||
@abstractmethod
|
@abstractmethod
|
||||||
def warning(self, *args: Any, **kwargs: Any) -> None:
|
def warning(self, *args: Any, **kwargs: Any) -> None: # type: ignore
|
||||||
pass
|
pass
|
||||||
|
|
||||||
@abstractmethod
|
@abstractmethod
|
||||||
def error(self, *args: Any, **kwargs: Any) -> None:
|
def error(self, *args: Any, **kwargs: Any) -> None: # type: ignore
|
||||||
pass
|
pass
|
||||||
|
|
||||||
@abstractmethod
|
@abstractmethod
|
||||||
@@ -63,8 +59,6 @@ class AbstractLogger(ABC):
|
|||||||
|
|
||||||
class JunitXMLLogger(AbstractLogger):
|
class JunitXMLLogger(AbstractLogger):
|
||||||
class TestCaseState:
|
class TestCaseState:
|
||||||
"""State tracking for individual test cases in JUnit XML reports."""
|
|
||||||
|
|
||||||
def __init__(self) -> None:
|
def __init__(self) -> None:
|
||||||
self.stdout = ""
|
self.stdout = ""
|
||||||
self.stderr = ""
|
self.stderr = ""
|
||||||
@@ -72,7 +66,7 @@ class JunitXMLLogger(AbstractLogger):
|
|||||||
|
|
||||||
def __init__(self, outfile: Path) -> None:
|
def __init__(self, outfile: Path) -> None:
|
||||||
self.tests: dict[str, JunitXMLLogger.TestCaseState] = {
|
self.tests: dict[str, JunitXMLLogger.TestCaseState] = {
|
||||||
"main": self.TestCaseState(),
|
"main": self.TestCaseState()
|
||||||
}
|
}
|
||||||
self.currentSubtest = "main"
|
self.currentSubtest = "main"
|
||||||
self.outfile: Path = outfile
|
self.outfile: Path = outfile
|
||||||
@@ -80,16 +74,12 @@ class JunitXMLLogger(AbstractLogger):
|
|||||||
atexit.register(self.close)
|
atexit.register(self.close)
|
||||||
|
|
||||||
def log(self, message: str, attributes: dict[str, str] | None = None) -> None:
|
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
|
self.tests[self.currentSubtest].stdout += message + os.linesep
|
||||||
|
|
||||||
@contextmanager
|
@contextmanager
|
||||||
def subtest(
|
def subtest(
|
||||||
self,
|
self, name: str, attributes: dict[str, str] | None = None
|
||||||
name: str,
|
|
||||||
attributes: dict[str, str] | None = None,
|
|
||||||
) -> Iterator[None]:
|
) -> Iterator[None]:
|
||||||
del attributes # Unused but kept for API compatibility
|
|
||||||
old_test = self.currentSubtest
|
old_test = self.currentSubtest
|
||||||
self.tests.setdefault(name, self.TestCaseState())
|
self.tests.setdefault(name, self.TestCaseState())
|
||||||
self.currentSubtest = name
|
self.currentSubtest = name
|
||||||
@@ -100,24 +90,18 @@ class JunitXMLLogger(AbstractLogger):
|
|||||||
|
|
||||||
@contextmanager
|
@contextmanager
|
||||||
def nested(
|
def nested(
|
||||||
self,
|
self, message: str, attributes: dict[str, str] | None = None
|
||||||
message: str,
|
|
||||||
attributes: dict[str, str] | None = None,
|
|
||||||
) -> Iterator[None]:
|
) -> Iterator[None]:
|
||||||
del attributes # Unused but kept for API compatibility
|
|
||||||
self.log(message)
|
self.log(message)
|
||||||
yield
|
yield
|
||||||
|
|
||||||
def info(self, *args: Any, **kwargs: Any) -> None:
|
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
|
self.tests[self.currentSubtest].stdout += args[0] + os.linesep
|
||||||
|
|
||||||
def warning(self, *args: Any, **kwargs: Any) -> None:
|
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
|
self.tests[self.currentSubtest].stdout += args[0] + os.linesep
|
||||||
|
|
||||||
def error(self, *args: Any, **kwargs: Any) -> None:
|
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].stderr += args[0] + os.linesep
|
||||||
self.tests[self.currentSubtest].failure = True
|
self.tests[self.currentSubtest].failure = True
|
||||||
|
|
||||||
@@ -160,9 +144,7 @@ class CompositeLogger(AbstractLogger):
|
|||||||
|
|
||||||
@contextmanager
|
@contextmanager
|
||||||
def subtest(
|
def subtest(
|
||||||
self,
|
self, name: str, attributes: dict[str, str] | None = None
|
||||||
name: str,
|
|
||||||
attributes: dict[str, str] | None = None,
|
|
||||||
) -> Iterator[None]:
|
) -> Iterator[None]:
|
||||||
with ExitStack() as stack:
|
with ExitStack() as stack:
|
||||||
for logger in self.logger_list:
|
for logger in self.logger_list:
|
||||||
@@ -171,24 +153,22 @@ class CompositeLogger(AbstractLogger):
|
|||||||
|
|
||||||
@contextmanager
|
@contextmanager
|
||||||
def nested(
|
def nested(
|
||||||
self,
|
self, message: str, attributes: dict[str, str] | None = None
|
||||||
message: str,
|
|
||||||
attributes: dict[str, str] | None = None,
|
|
||||||
) -> Iterator[None]:
|
) -> Iterator[None]:
|
||||||
with ExitStack() as stack:
|
with ExitStack() as stack:
|
||||||
for logger in self.logger_list:
|
for logger in self.logger_list:
|
||||||
stack.enter_context(logger.nested(message, attributes))
|
stack.enter_context(logger.nested(message, attributes))
|
||||||
yield
|
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:
|
for logger in self.logger_list:
|
||||||
logger.info(*args, **kwargs)
|
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:
|
for logger in self.logger_list:
|
||||||
logger.warning(*args, **kwargs)
|
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:
|
for logger in self.logger_list:
|
||||||
logger.error(*args, **kwargs)
|
logger.error(*args, **kwargs)
|
||||||
sys.exit(1)
|
sys.exit(1)
|
||||||
@@ -220,24 +200,19 @@ class TerminalLogger(AbstractLogger):
|
|||||||
|
|
||||||
@contextmanager
|
@contextmanager
|
||||||
def subtest(
|
def subtest(
|
||||||
self,
|
self, name: str, attributes: dict[str, str] | None = None
|
||||||
name: str,
|
|
||||||
attributes: dict[str, str] | None = None,
|
|
||||||
) -> Iterator[None]:
|
) -> Iterator[None]:
|
||||||
with self.nested("subtest: " + name, attributes):
|
with self.nested("subtest: " + name, attributes):
|
||||||
yield
|
yield
|
||||||
|
|
||||||
@contextmanager
|
@contextmanager
|
||||||
def nested(
|
def nested(
|
||||||
self,
|
self, message: str, attributes: dict[str, str] | None = None
|
||||||
message: str,
|
|
||||||
attributes: dict[str, str] | None = None,
|
|
||||||
) -> Iterator[None]:
|
) -> Iterator[None]:
|
||||||
self._eprint(
|
self._eprint(
|
||||||
self.maybe_prefix(
|
self.maybe_prefix(
|
||||||
Style.BRIGHT + Fore.GREEN + message + Style.RESET_ALL,
|
Style.BRIGHT + Fore.GREEN + message + Style.RESET_ALL, attributes
|
||||||
attributes,
|
)
|
||||||
),
|
|
||||||
)
|
)
|
||||||
|
|
||||||
tic = time.time()
|
tic = time.time()
|
||||||
@@ -245,13 +220,13 @@ class TerminalLogger(AbstractLogger):
|
|||||||
toc = time.time()
|
toc = time.time()
|
||||||
self.log(f"(finished: {message}, in {toc - tic:.2f} seconds)")
|
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)
|
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)
|
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)
|
self.log(*args, **kwargs)
|
||||||
|
|
||||||
def print_serial_logs(self, enable: bool) -> None:
|
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")
|
return "".join(ch for ch in message if unicodedata.category(ch)[0] != "C")
|
||||||
|
|
||||||
def maybe_prefix(
|
def maybe_prefix(
|
||||||
self,
|
self, message: str, attributes: dict[str, str] | None = None
|
||||||
message: str,
|
|
||||||
attributes: dict[str, str] | None = None,
|
|
||||||
) -> str:
|
) -> str:
|
||||||
if attributes and "machine" in attributes:
|
if attributes and "machine" in attributes:
|
||||||
return f"{attributes['machine']}: {message}"
|
return f"{attributes['machine']}: {message}"
|
||||||
@@ -297,13 +270,13 @@ class XMLLogger(AbstractLogger):
|
|||||||
self.xml.characters(message)
|
self.xml.characters(message)
|
||||||
self.xml.endElement("line")
|
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)
|
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)
|
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)
|
self.log(*args, **kwargs)
|
||||||
|
|
||||||
def log(self, message: str, attributes: dict[str, str] | None = None) -> None:
|
def log(self, message: str, attributes: dict[str, str] | None = None) -> None:
|
||||||
@@ -336,18 +309,14 @@ class XMLLogger(AbstractLogger):
|
|||||||
|
|
||||||
@contextmanager
|
@contextmanager
|
||||||
def subtest(
|
def subtest(
|
||||||
self,
|
self, name: str, attributes: dict[str, str] | None = None
|
||||||
name: str,
|
|
||||||
attributes: dict[str, str] | None = None,
|
|
||||||
) -> Iterator[None]:
|
) -> Iterator[None]:
|
||||||
with self.nested("subtest: " + name, attributes):
|
with self.nested("subtest: " + name, attributes):
|
||||||
yield
|
yield
|
||||||
|
|
||||||
@contextmanager
|
@contextmanager
|
||||||
def nested(
|
def nested(
|
||||||
self,
|
self, message: str, attributes: dict[str, str] | None = None
|
||||||
message: str,
|
|
||||||
attributes: dict[str, str] | None = None,
|
|
||||||
) -> Iterator[None]:
|
) -> Iterator[None]:
|
||||||
if attributes is None:
|
if attributes is None:
|
||||||
attributes = {}
|
attributes = {}
|
||||||
|
|||||||
@@ -8,10 +8,6 @@
|
|||||||
{
|
{
|
||||||
imports = lib.optional (_class == "nixos") (
|
imports = lib.optional (_class == "nixos") (
|
||||||
lib.mkIf config.clan.core.enableRecommendedDefaults {
|
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:
|
# Use systemd during boot as well except:
|
||||||
# - systems with raids as this currently require manual configuration: https://github.com/NixOS/nixpkgs/issues/210210
|
# - 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
|
# - 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 {
|
config = lib.mkIf config.clan.core.enableRecommendedDefaults {
|
||||||
|
|
||||||
# This disables the HTML manual and `nixos-help` command but leaves
|
# This disables the HTML manual and `nixos-help` command but leaves
|
||||||
# `man configuration.nix`
|
# `man configuration.nix`
|
||||||
documentation.doc.enable = lib.mkDefault false;
|
documentation.doc.enable = lib.mkDefault false;
|
||||||
|
|||||||
@@ -1,17 +1,40 @@
|
|||||||
|
{ ... }:
|
||||||
{
|
{
|
||||||
perSystem.clan.nixosTests.machine-id = {
|
perSystem =
|
||||||
|
{ ... }:
|
||||||
|
{
|
||||||
|
clan.nixosTests.machine-id = {
|
||||||
|
|
||||||
name = "service-machine-id";
|
name = "service-machine-id";
|
||||||
|
|
||||||
clan = {
|
clan = {
|
||||||
directory = ./.;
|
directory = ./.;
|
||||||
machines.server = {
|
|
||||||
clan.core.settings.machine-id.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 = [
|
||||||
|
{
|
||||||
|
# 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 = "";
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,14 +10,30 @@
|
|||||||
clan = {
|
clan = {
|
||||||
directory = ./.;
|
directory = ./.;
|
||||||
|
|
||||||
machines.machine = {
|
# Workaround until we can use nodes.machine = { };
|
||||||
clan.core.postgresql.enable = true;
|
modules."@clan/importer" = ../../../../clanServices/importer;
|
||||||
clan.core.postgresql.users.test = { };
|
|
||||||
clan.core.postgresql.databases.test.create.options.OWNER = "test";
|
inventory = {
|
||||||
clan.core.settings.directory = ./.;
|
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 =
|
testScript =
|
||||||
let
|
let
|
||||||
runpg = "runuser -u postgres -- /run/current-system/sw/bin/psql";
|
runpg = "runuser -u postgres -- /run/current-system/sw/bin/psql";
|
||||||
|
|||||||
@@ -9,11 +9,28 @@
|
|||||||
|
|
||||||
clan = {
|
clan = {
|
||||||
directory = ./.;
|
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
|
# This is not an actual vm test, this is a workaround to
|
||||||
# generate the needed vars for the eval test.
|
# generate the needed vars for the eval test.
|
||||||
testScript = "";
|
testScript = "";
|
||||||
|
|||||||
@@ -304,15 +304,6 @@ in
|
|||||||
description = "The unix file mode of the file. Must be a 4-digit octal number.";
|
description = "The unix file mode of the file. Must be a 4-digit octal number.";
|
||||||
default = "0400";
|
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 =
|
value =
|
||||||
mkOption {
|
mkOption {
|
||||||
description = ''
|
description = ''
|
||||||
|
|||||||
@@ -25,7 +25,7 @@ in
|
|||||||
);
|
);
|
||||||
value = mkIf (file.config.secret == false) (
|
value = mkIf (file.config.secret == false) (
|
||||||
# dynamically adjust priority to allow overriding with mkDefault in case the file is not found
|
# 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
|
# if the file is found it should have normal priority
|
||||||
readFile file.config.flakePath
|
readFile file.config.flakePath
|
||||||
else
|
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}"
|
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);
|
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -16,10 +16,6 @@ from pathlib import Path
|
|||||||
from tempfile import TemporaryDirectory
|
from tempfile import TemporaryDirectory
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
# Constants
|
|
||||||
NODE_ID_LENGTH = 10
|
|
||||||
NETWORK_ID_LENGTH = 16
|
|
||||||
|
|
||||||
|
|
||||||
class ClanError(Exception):
|
class ClanError(Exception):
|
||||||
pass
|
pass
|
||||||
@@ -59,9 +55,9 @@ class Identity:
|
|||||||
|
|
||||||
def node_id(self) -> str:
|
def node_id(self) -> str:
|
||||||
nid = self.public.split(":")[0]
|
nid = self.public.split(":")[0]
|
||||||
if len(nid) != NODE_ID_LENGTH:
|
assert len(nid) == 10, (
|
||||||
msg = f"node_id must be {NODE_ID_LENGTH} characters long, got {len(nid)}: {nid}"
|
f"node_id must be 10 characters long, got {len(nid)}: {nid}"
|
||||||
raise ClanError(msg)
|
)
|
||||||
return nid
|
return nid
|
||||||
|
|
||||||
|
|
||||||
@@ -88,10 +84,9 @@ class ZerotierController:
|
|||||||
headers["Content-Type"] = "application/json"
|
headers["Content-Type"] = "application/json"
|
||||||
headers["X-ZT1-AUTH"] = self.authtoken
|
headers["X-ZT1-AUTH"] = self.authtoken
|
||||||
url = f"http://127.0.0.1:{self.port}{path}"
|
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)
|
||||||
req = urllib.request.Request(url, headers=headers, method=method, data=body) # noqa: S310
|
resp = urllib.request.urlopen(req)
|
||||||
with urllib.request.urlopen(req, timeout=5) as resp: # noqa: S310
|
return json.load(resp)
|
||||||
return json.load(resp)
|
|
||||||
|
|
||||||
def status(self) -> dict[str, Any]:
|
def status(self) -> dict[str, Any]:
|
||||||
return self._http_request("/status")
|
return self._http_request("/status")
|
||||||
@@ -177,9 +172,9 @@ def create_identity() -> Identity:
|
|||||||
|
|
||||||
|
|
||||||
def compute_zerotier_ip(network_id: str, identity: Identity) -> ipaddress.IPv6Address:
|
def compute_zerotier_ip(network_id: str, identity: Identity) -> ipaddress.IPv6Address:
|
||||||
if len(network_id) != NETWORK_ID_LENGTH:
|
assert len(network_id) == 16, (
|
||||||
msg = f"network_id must be {NETWORK_ID_LENGTH} characters long, got '{network_id}'"
|
f"network_id must be 16 characters long, got '{network_id}'"
|
||||||
raise ClanError(msg)
|
)
|
||||||
nwid = int(network_id, 16)
|
nwid = int(network_id, 16)
|
||||||
node_id = int(identity.node_id(), 16)
|
node_id = int(identity.node_id(), 16)
|
||||||
addr_parts = bytearray(
|
addr_parts = bytearray(
|
||||||
@@ -200,7 +195,7 @@ def compute_zerotier_ip(network_id: str, identity: Identity) -> ipaddress.IPv6Ad
|
|||||||
(node_id >> 16) & 0xFF,
|
(node_id >> 16) & 0xFF,
|
||||||
(node_id >> 8) & 0xFF,
|
(node_id >> 8) & 0xFF,
|
||||||
(node_id) & 0xFF,
|
(node_id) & 0xFF,
|
||||||
],
|
]
|
||||||
)
|
)
|
||||||
return ipaddress.IPv6Address(bytes(addr_parts))
|
return ipaddress.IPv6Address(bytes(addr_parts))
|
||||||
|
|
||||||
@@ -208,10 +203,7 @@ def compute_zerotier_ip(network_id: str, identity: Identity) -> ipaddress.IPv6Ad
|
|||||||
def main() -> None:
|
def main() -> None:
|
||||||
parser = argparse.ArgumentParser()
|
parser = argparse.ArgumentParser()
|
||||||
parser.add_argument(
|
parser.add_argument(
|
||||||
"--mode",
|
"--mode", choices=["network", "identity"], required=True, type=str
|
||||||
choices=["network", "identity"],
|
|
||||||
required=True,
|
|
||||||
type=str,
|
|
||||||
)
|
)
|
||||||
parser.add_argument("--ip", type=Path, required=True)
|
parser.add_argument("--ip", type=Path, required=True)
|
||||||
parser.add_argument("--identity-secret", type=Path, required=True)
|
parser.add_argument("--identity-secret", type=Path, required=True)
|
||||||
|
|||||||
7
nixosModules/clanCore/zerotier/genmoon.py
Executable file → Normal file
@@ -6,12 +6,9 @@ import sys
|
|||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from tempfile import NamedTemporaryFile
|
from tempfile import NamedTemporaryFile
|
||||||
|
|
||||||
# Constants
|
|
||||||
REQUIRED_ARGS = 4
|
|
||||||
|
|
||||||
|
|
||||||
def main() -> None:
|
def main() -> None:
|
||||||
if len(sys.argv) != REQUIRED_ARGS:
|
if len(sys.argv) != 4:
|
||||||
print("Usage: genmoon.py <moon.json> <endpoint.json> <moons.d>")
|
print("Usage: genmoon.py <moon.json> <endpoint.json> <moons.d>")
|
||||||
sys.exit(1)
|
sys.exit(1)
|
||||||
moon_json_path = sys.argv[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 = json.loads(Path(moon_json_path).read_text())
|
||||||
moon_json["roots"][0]["stableEndpoints"] = json.loads(
|
moon_json["roots"][0]["stableEndpoints"] = json.loads(
|
||||||
Path(endpoint_config).read_text(),
|
Path(endpoint_config).read_text()
|
||||||
)
|
)
|
||||||
|
|
||||||
with NamedTemporaryFile("w") as f:
|
with NamedTemporaryFile("w") as f:
|
||||||
|
|||||||
@@ -12,14 +12,8 @@ let
|
|||||||
(builtins.match "linux_[0-9]+_[0-9]+" name) != null
|
(builtins.match "linux_[0-9]+_[0-9]+" name) != null
|
||||||
&& (builtins.tryEval kernelPackages).success
|
&& (builtins.tryEval kernelPackages).success
|
||||||
&& (
|
&& (
|
||||||
let
|
(!isUnstable && !kernelPackages.zfs.meta.broken)
|
||||||
zfsPackage =
|
|| (isUnstable && !kernelPackages.zfs_unstable.meta.broken)
|
||||||
if isUnstable then
|
|
||||||
kernelPackages.zfs_unstable
|
|
||||||
else
|
|
||||||
kernelPackages.${pkgs.zfs.kernelModuleAttribute};
|
|
||||||
in
|
|
||||||
!(zfsPackage.meta.broken or false)
|
|
||||||
)
|
)
|
||||||
) pkgs.linuxKernel.packages;
|
) pkgs.linuxKernel.packages;
|
||||||
latestKernelPackage = lib.last (
|
latestKernelPackage = lib.last (
|
||||||
@@ -30,5 +24,5 @@ let
|
|||||||
in
|
in
|
||||||
{
|
{
|
||||||
# Note this might jump back and worth as kernel get added or removed.
|
# 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
@@ -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
@@ -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
@@ -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
|
||||||
@@ -13,9 +13,7 @@ log = logging.getLogger(__name__)
|
|||||||
def main(argv: list[str] = sys.argv) -> int:
|
def main(argv: list[str] = sys.argv) -> int:
|
||||||
parser = argparse.ArgumentParser(description="Clan App")
|
parser = argparse.ArgumentParser(description="Clan App")
|
||||||
parser.add_argument(
|
parser.add_argument(
|
||||||
"--content-uri",
|
"--content-uri", type=str, help="The URI of the content to display"
|
||||||
type=str,
|
|
||||||
help="The URI of the content to display",
|
|
||||||
)
|
)
|
||||||
parser.add_argument("--debug", action="store_true", help="Enable debug mode")
|
parser.add_argument("--debug", action="store_true", help="Enable debug mode")
|
||||||
parser.add_argument(
|
parser.add_argument(
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ from contextlib import ExitStack
|
|||||||
from dataclasses import dataclass, field
|
from dataclasses import dataclass, field
|
||||||
from typing import TYPE_CHECKING, Any
|
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.api.tasks import WebThread
|
||||||
from clan_lib.async_run import set_current_thread_opkey, set_should_cancel
|
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:
|
def process_request(self, request: BackendRequest) -> None:
|
||||||
"""Process an API request through the middleware chain."""
|
"""Process an API request through the middleware chain."""
|
||||||
from .middleware import MiddlewareContext # noqa: PLC0415
|
from .middleware import MiddlewareContext
|
||||||
|
|
||||||
with ExitStack() as stack:
|
with ExitStack() as stack:
|
||||||
context = MiddlewareContext(
|
context = MiddlewareContext(
|
||||||
@@ -56,25 +56,22 @@ class ApiBridge(ABC):
|
|||||||
for middleware in self.middleware_chain:
|
for middleware in self.middleware_chain:
|
||||||
try:
|
try:
|
||||||
log.debug(
|
log.debug(
|
||||||
f"{middleware.__class__.__name__} => {request.method_name}",
|
f"{middleware.__class__.__name__} => {request.method_name}"
|
||||||
)
|
)
|
||||||
middleware.process(context)
|
middleware.process(context)
|
||||||
except Exception as e: # noqa: BLE001
|
except Exception as e:
|
||||||
# If middleware fails, handle error
|
# If middleware fails, handle error
|
||||||
self.send_api_error_response(
|
self.send_api_error_response(
|
||||||
request.op_key or "unknown",
|
request.op_key or "unknown", str(e), ["middleware_error"]
|
||||||
str(e),
|
|
||||||
["middleware_error"],
|
|
||||||
)
|
)
|
||||||
return
|
return
|
||||||
|
|
||||||
def send_api_error_response(
|
def send_api_error_response(
|
||||||
self,
|
self, op_key: str, error_message: str, location: list[str]
|
||||||
op_key: str,
|
|
||||||
error_message: str,
|
|
||||||
location: list[str],
|
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Send an error response."""
|
"""Send an error response."""
|
||||||
|
from clan_lib.api import ApiError, ErrorDataClass
|
||||||
|
|
||||||
error_data = ErrorDataClass(
|
error_data = ErrorDataClass(
|
||||||
op_key=op_key,
|
op_key=op_key,
|
||||||
status="error",
|
status="error",
|
||||||
@@ -83,7 +80,7 @@ class ApiBridge(ABC):
|
|||||||
message="An internal error occured",
|
message="An internal error occured",
|
||||||
description=error_message,
|
description=error_message,
|
||||||
location=location,
|
location=location,
|
||||||
),
|
)
|
||||||
],
|
],
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -110,7 +107,6 @@ class ApiBridge(ABC):
|
|||||||
thread_name: Name for the thread (for debugging)
|
thread_name: Name for the thread (for debugging)
|
||||||
wait_for_completion: Whether to wait for the thread to complete
|
wait_for_completion: Whether to wait for the thread to complete
|
||||||
timeout: Timeout in seconds when waiting for completion
|
timeout: Timeout in seconds when waiting for completion
|
||||||
|
|
||||||
"""
|
"""
|
||||||
op_key = request.op_key or "unknown"
|
op_key = request.op_key or "unknown"
|
||||||
|
|
||||||
@@ -120,7 +116,7 @@ class ApiBridge(ABC):
|
|||||||
try:
|
try:
|
||||||
log.debug(
|
log.debug(
|
||||||
f"Processing {request.method_name} with args {request.args} "
|
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)
|
self.process_request(request)
|
||||||
finally:
|
finally:
|
||||||
@@ -128,9 +124,7 @@ class ApiBridge(ABC):
|
|||||||
|
|
||||||
stop_event = threading.Event()
|
stop_event = threading.Event()
|
||||||
thread = threading.Thread(
|
thread = threading.Thread(
|
||||||
target=thread_task,
|
target=thread_task, args=(stop_event,), name=thread_name
|
||||||
args=(stop_event,),
|
|
||||||
name=thread_name,
|
|
||||||
)
|
)
|
||||||
thread.start()
|
thread.start()
|
||||||
|
|
||||||
@@ -144,7 +138,5 @@ class ApiBridge(ABC):
|
|||||||
if thread.is_alive():
|
if thread.is_alive():
|
||||||
stop_event.set() # Cancel the thread
|
stop_event.set() # Cancel the thread
|
||||||
self.send_api_error_response(
|
self.send_api_error_response(
|
||||||
op_key,
|
op_key, "Request timeout", ["api_bridge", request.method_name]
|
||||||
"Request timeout",
|
|
||||||
["api_bridge", request.method_name],
|
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -26,7 +26,8 @@ RESULT: dict[str, SuccessDataClass[list[str] | None] | ErrorDataClass] = {}
|
|||||||
|
|
||||||
|
|
||||||
def get_clan_folder() -> SuccessDataClass[Flake] | 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.
|
Returns the path to the clan folder or an error if it fails.
|
||||||
"""
|
"""
|
||||||
file_request = FileRequest(
|
file_request = FileRequest(
|
||||||
@@ -51,7 +52,7 @@ def get_clan_folder() -> SuccessDataClass[Flake] | ErrorDataClass:
|
|||||||
message="No folder selected",
|
message="No folder selected",
|
||||||
description="You must select a folder to open.",
|
description="You must select a folder to open.",
|
||||||
location=["get_clan_folder"],
|
location=["get_clan_folder"],
|
||||||
),
|
)
|
||||||
],
|
],
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -65,7 +66,7 @@ def get_clan_folder() -> SuccessDataClass[Flake] | ErrorDataClass:
|
|||||||
message="Invalid clan folder",
|
message="Invalid clan folder",
|
||||||
description=f"The selected folder '{clan_folder}' is not a valid clan folder.",
|
description=f"The selected folder '{clan_folder}' is not a valid clan folder.",
|
||||||
location=["get_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 gtk_open_file(file_request: FileRequest, op_key: str) -> bool:
|
||||||
def returns(data: SuccessDataClass | ErrorDataClass) -> None:
|
def returns(data: SuccessDataClass | ErrorDataClass) -> None:
|
||||||
|
global RESULT
|
||||||
RESULT[op_key] = data
|
RESULT[op_key] = data
|
||||||
|
|
||||||
def on_file_select(file_dialog: Gtk.FileDialog, task: Gio.Task) -> None:
|
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()])
|
selected_path = remove_none([gfile.get_path()])
|
||||||
returns(
|
returns(
|
||||||
SuccessDataClass(
|
SuccessDataClass(
|
||||||
op_key=op_key,
|
op_key=op_key, data=selected_path, status="success"
|
||||||
data=selected_path,
|
)
|
||||||
status="success",
|
|
||||||
),
|
|
||||||
)
|
)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
log.exception("Error opening file")
|
log.exception("Error opening file")
|
||||||
@@ -116,9 +116,9 @@ def gtk_open_file(file_request: FileRequest, op_key: str) -> bool:
|
|||||||
message=e.__class__.__name__,
|
message=e.__class__.__name__,
|
||||||
description=str(e),
|
description=str(e),
|
||||||
location=["get_system_file"],
|
location=["get_system_file"],
|
||||||
),
|
)
|
||||||
],
|
],
|
||||||
),
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
def on_file_select_multiple(file_dialog: Gtk.FileDialog, task: Gio.Task) -> None:
|
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])
|
selected_paths = remove_none([gfile.get_path() for gfile in gfiles])
|
||||||
returns(
|
returns(
|
||||||
SuccessDataClass(
|
SuccessDataClass(
|
||||||
op_key=op_key,
|
op_key=op_key, data=selected_paths, status="success"
|
||||||
data=selected_paths,
|
)
|
||||||
status="success",
|
|
||||||
),
|
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
returns(SuccessDataClass(op_key=op_key, data=None, status="success"))
|
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__,
|
message=e.__class__.__name__,
|
||||||
description=str(e),
|
description=str(e),
|
||||||
location=["get_system_file"],
|
location=["get_system_file"],
|
||||||
),
|
)
|
||||||
],
|
],
|
||||||
),
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
def on_folder_select(file_dialog: Gtk.FileDialog, task: Gio.Task) -> None:
|
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()])
|
selected_path = remove_none([gfile.get_path()])
|
||||||
returns(
|
returns(
|
||||||
SuccessDataClass(
|
SuccessDataClass(
|
||||||
op_key=op_key,
|
op_key=op_key, data=selected_path, status="success"
|
||||||
data=selected_path,
|
)
|
||||||
status="success",
|
|
||||||
),
|
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
returns(SuccessDataClass(op_key=op_key, data=None, status="success"))
|
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__,
|
message=e.__class__.__name__,
|
||||||
description=str(e),
|
description=str(e),
|
||||||
location=["get_system_file"],
|
location=["get_system_file"],
|
||||||
),
|
)
|
||||||
],
|
],
|
||||||
),
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
def on_save_finish(file_dialog: Gtk.FileDialog, task: Gio.Task) -> None:
|
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()])
|
selected_path = remove_none([gfile.get_path()])
|
||||||
returns(
|
returns(
|
||||||
SuccessDataClass(
|
SuccessDataClass(
|
||||||
op_key=op_key,
|
op_key=op_key, data=selected_path, status="success"
|
||||||
data=selected_path,
|
)
|
||||||
status="success",
|
|
||||||
),
|
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
returns(SuccessDataClass(op_key=op_key, data=None, status="success"))
|
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__,
|
message=e.__class__.__name__,
|
||||||
description=str(e),
|
description=str(e),
|
||||||
location=["get_system_file"],
|
location=["get_system_file"],
|
||||||
),
|
)
|
||||||
],
|
],
|
||||||
),
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
dialog = Gtk.FileDialog()
|
dialog = Gtk.FileDialog()
|
||||||
|
|||||||
@@ -39,7 +39,7 @@ class ArgumentParsingMiddleware(Middleware):
|
|||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
log.exception(
|
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.bridge.send_api_error_response(
|
||||||
context.request.op_key or "unknown",
|
context.request.op_key or "unknown",
|
||||||
|
|||||||
@@ -23,9 +23,7 @@ class Middleware(ABC):
|
|||||||
"""Process the request through this middleware."""
|
"""Process the request through this middleware."""
|
||||||
|
|
||||||
def register_context_manager(
|
def register_context_manager(
|
||||||
self,
|
self, context: MiddlewareContext, cm: AbstractContextManager[Any]
|
||||||
context: MiddlewareContext,
|
|
||||||
cm: AbstractContextManager[Any],
|
|
||||||
) -> Any:
|
) -> Any:
|
||||||
"""Register a context manager with the exit stack."""
|
"""Register a context manager with the exit stack."""
|
||||||
return context.exit_stack.enter_context(cm)
|
return context.exit_stack.enter_context(cm)
|
||||||
|
|||||||
@@ -25,26 +25,23 @@ class LoggingMiddleware(Middleware):
|
|||||||
try:
|
try:
|
||||||
# Handle log group configuration
|
# Handle log group configuration
|
||||||
log_group: list[str] | None = context.request.header.get("logging", {}).get(
|
log_group: list[str] | None = context.request.header.get("logging", {}).get(
|
||||||
"group_path",
|
"group_path", None
|
||||||
None,
|
|
||||||
)
|
)
|
||||||
if log_group is not None:
|
if log_group is not None:
|
||||||
if not isinstance(log_group, list):
|
if not isinstance(log_group, list):
|
||||||
msg = f"Expected log_group to be a list, got {type(log_group)}"
|
msg = f"Expected log_group to be a list, got {type(log_group)}"
|
||||||
raise TypeError(msg) # noqa: TRY301
|
raise TypeError(msg) # noqa: TRY301
|
||||||
log.warning(
|
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
|
# Create log file
|
||||||
log_file = self.log_manager.create_log_file(
|
log_file = self.log_manager.create_log_file(
|
||||||
method,
|
method, op_key=context.request.op_key or "unknown", group_path=log_group
|
||||||
op_key=context.request.op_key or "unknown",
|
|
||||||
group_path=log_group,
|
|
||||||
).get_file_path()
|
).get_file_path()
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
log.exception(
|
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.bridge.send_api_error_response(
|
||||||
context.request.op_key or "unknown",
|
context.request.op_key or "unknown",
|
||||||
@@ -79,8 +76,7 @@ class LoggingMiddleware(Middleware):
|
|||||||
line_buffering=True,
|
line_buffering=True,
|
||||||
)
|
)
|
||||||
self.handler = setup_logging(
|
self.handler = setup_logging(
|
||||||
log.getEffectiveLevel(),
|
log.getEffectiveLevel(), log_file=handler_stream
|
||||||
log_file=handler_stream,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
return self
|
return self
|
||||||
@@ -94,10 +90,10 @@ class LoggingMiddleware(Middleware):
|
|||||||
if self.handler:
|
if self.handler:
|
||||||
self.handler.root_logger.removeHandler(self.handler.new_handler)
|
self.handler.root_logger.removeHandler(self.handler.new_handler)
|
||||||
self.handler.new_handler.close()
|
self.handler.new_handler.close()
|
||||||
if self.original_ctx:
|
|
||||||
set_async_ctx(self.original_ctx)
|
|
||||||
if self.log_f:
|
if self.log_f:
|
||||||
self.log_f.close()
|
self.log_f.close()
|
||||||
|
if self.original_ctx:
|
||||||
|
set_async_ctx(self.original_ctx)
|
||||||
|
|
||||||
# Register the logging context manager
|
# Register the logging context manager
|
||||||
self.register_context_manager(context, LoggingContextManager(log_file))
|
self.register_context_manager(context, LoggingContextManager(log_file))
|
||||||
|
|||||||
@@ -32,7 +32,7 @@ class MethodExecutionMiddleware(Middleware):
|
|||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
log.exception(
|
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.bridge.send_api_error_response(
|
||||||
context.request.op_key or "unknown",
|
context.request.op_key or "unknown",
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
import logging
|
import logging
|
||||||
import os
|
import os
|
||||||
import time
|
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
@@ -17,7 +16,6 @@ from clan_app.api.middleware import (
|
|||||||
LoggingMiddleware,
|
LoggingMiddleware,
|
||||||
MethodExecutionMiddleware,
|
MethodExecutionMiddleware,
|
||||||
)
|
)
|
||||||
from clan_app.deps.http.http_server import HttpApiServer
|
|
||||||
from clan_app.deps.webview.webview import Size, SizeHint, Webview
|
from clan_app.deps.webview.webview import Size, SizeHint, Webview
|
||||||
|
|
||||||
log = logging.getLogger(__name__)
|
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>]
|
# Add a log group ["clans", <dynamic_name>, "machines", <dynamic_name>]
|
||||||
log_manager = LogManager(base_dir=user_data_dir() / "clan-app" / "logs")
|
log_manager = LogManager(base_dir=user_data_dir() / "clan-app" / "logs")
|
||||||
clan_log_group = LogGroupConfig("clans", "Clans").add_child(
|
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)
|
log_manager = log_manager.add_root_group_config(clan_log_group)
|
||||||
# Init LogManager global in log_manager_api module
|
# 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
|
# Start HTTP API server if requested
|
||||||
http_server = None
|
http_server = None
|
||||||
if app_opts.http_api:
|
if app_opts.http_api:
|
||||||
|
from clan_app.deps.http.http_server import HttpApiServer
|
||||||
|
|
||||||
openapi_file = os.getenv("OPENAPI_FILE", None)
|
openapi_file = os.getenv("OPENAPI_FILE", None)
|
||||||
swagger_dist = os.getenv("SWAGGER_UI_DIST", 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
|
# HTTP-only mode - keep the server running
|
||||||
log.info("HTTP API server running...")
|
log.info("HTTP API server running...")
|
||||||
log.info(
|
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")
|
log.info("Press Ctrl+C to stop the server")
|
||||||
try:
|
try:
|
||||||
# Keep the main thread alive
|
# Keep the main thread alive
|
||||||
|
import time
|
||||||
|
|
||||||
while True:
|
while True:
|
||||||
time.sleep(1)
|
time.sleep(1)
|
||||||
except KeyboardInterrupt:
|
except KeyboardInterrupt:
|
||||||
@@ -119,7 +121,7 @@ def app_run(app_opts: ClanAppOptions) -> int:
|
|||||||
webview.add_middleware(LoggingMiddleware(log_manager=log_manager))
|
webview.add_middleware(LoggingMiddleware(log_manager=log_manager))
|
||||||
webview.add_middleware(MethodExecutionMiddleware(api=API))
|
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.navigate(content_uri)
|
||||||
webview.run()
|
webview.run()
|
||||||
|
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 20 KiB |
|
After Width: | Height: | Size: 3.1 KiB |
|
Before Width: | Height: | Size: 726 B |
|
After Width: | Height: | Size: 375 B |
|
Before Width: | Height: | Size: 1.8 KiB |
|
After Width: | Height: | Size: 717 B |
|
Before Width: | Height: | Size: 3.4 KiB |
|
After Width: | Height: | Size: 717 B |
|
Before Width: | Height: | Size: 5.4 KiB |
|
After Width: | Height: | Size: 1.5 KiB |
@@ -63,9 +63,7 @@ class HttpBridge(ApiBridge, BaseHTTPRequestHandler):
|
|||||||
self.send_header("Access-Control-Allow-Headers", "Content-Type")
|
self.send_header("Access-Control-Allow-Headers", "Content-Type")
|
||||||
|
|
||||||
def _send_json_response_with_status(
|
def _send_json_response_with_status(
|
||||||
self,
|
self, data: dict[str, Any], status_code: int = 200
|
||||||
data: dict[str, Any],
|
|
||||||
status_code: int = 200,
|
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Send a JSON response with the given status code."""
|
"""Send a JSON response with the given status code."""
|
||||||
try:
|
try:
|
||||||
@@ -84,13 +82,11 @@ class HttpBridge(ApiBridge, BaseHTTPRequestHandler):
|
|||||||
response_dict = dataclass_to_dict(response)
|
response_dict = dataclass_to_dict(response)
|
||||||
self._send_json_response_with_status(response_dict, 200)
|
self._send_json_response_with_status(response_dict, 200)
|
||||||
log.debug(
|
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(
|
def _create_success_response(
|
||||||
self,
|
self, op_key: str, data: dict[str, Any]
|
||||||
op_key: str,
|
|
||||||
data: dict[str, Any],
|
|
||||||
) -> BackendResponse:
|
) -> BackendResponse:
|
||||||
"""Create a successful API response."""
|
"""Create a successful API response."""
|
||||||
return BackendResponse(
|
return BackendResponse(
|
||||||
@@ -102,16 +98,14 @@ class HttpBridge(ApiBridge, BaseHTTPRequestHandler):
|
|||||||
def _send_info_response(self) -> None:
|
def _send_info_response(self) -> None:
|
||||||
"""Send server information response."""
|
"""Send server information response."""
|
||||||
response = self._create_success_response(
|
response = self._create_success_response(
|
||||||
"info",
|
"info", {"message": "Clan API Server", "version": "1.0.0"}
|
||||||
{"message": "Clan API Server", "version": "1.0.0"},
|
|
||||||
)
|
)
|
||||||
self.send_api_response(response)
|
self.send_api_response(response)
|
||||||
|
|
||||||
def _send_methods_response(self) -> None:
|
def _send_methods_response(self) -> None:
|
||||||
"""Send available API methods response."""
|
"""Send available API methods response."""
|
||||||
response = self._create_success_response(
|
response = self._create_success_response(
|
||||||
"methods",
|
"methods", {"methods": list(self.api.functions.keys())}
|
||||||
{"methods": list(self.api.functions.keys())},
|
|
||||||
)
|
)
|
||||||
self.send_api_response(response)
|
self.send_api_response(response)
|
||||||
|
|
||||||
@@ -148,8 +142,8 @@ class HttpBridge(ApiBridge, BaseHTTPRequestHandler):
|
|||||||
self.send_header("Content-Type", content_type)
|
self.send_header("Content-Type", content_type)
|
||||||
self.end_headers()
|
self.end_headers()
|
||||||
self.wfile.write(file_data)
|
self.wfile.write(file_data)
|
||||||
except (OSError, json.JSONDecodeError, UnicodeDecodeError):
|
except Exception as e:
|
||||||
log.exception("Error reading Swagger file")
|
log.error(f"Error reading Swagger file: {e!s}")
|
||||||
self.send_error(500, "Internal Server Error")
|
self.send_error(500, "Internal Server Error")
|
||||||
|
|
||||||
def _get_swagger_file_path(self, rel_path: str) -> Path:
|
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"))
|
json_data = json.loads(file_data.decode("utf-8"))
|
||||||
server_address = getattr(self.server, "server_address", ("localhost", 80))
|
server_address = getattr(self.server, "server_address", ("localhost", 80))
|
||||||
json_data["servers"] = [
|
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")
|
file_data = json.dumps(json_data, indent=2).encode("utf-8")
|
||||||
|
|
||||||
return file_data
|
return file_data
|
||||||
|
|
||||||
def do_OPTIONS(self) -> None:
|
def do_OPTIONS(self) -> None: # noqa: N802
|
||||||
"""Handle CORS preflight requests."""
|
"""Handle CORS preflight requests."""
|
||||||
self.send_response_only(200)
|
self.send_response_only(200)
|
||||||
self._send_cors_headers()
|
self._send_cors_headers()
|
||||||
self.end_headers()
|
self.end_headers()
|
||||||
|
|
||||||
def do_GET(self) -> None:
|
def do_GET(self) -> None: # noqa: N802
|
||||||
"""Handle GET requests."""
|
"""Handle GET requests."""
|
||||||
parsed_url = urlparse(self.path)
|
parsed_url = urlparse(self.path)
|
||||||
path = parsed_url.path
|
path = parsed_url.path
|
||||||
@@ -211,7 +205,7 @@ class HttpBridge(ApiBridge, BaseHTTPRequestHandler):
|
|||||||
else:
|
else:
|
||||||
self.send_api_error_response("info", "Not Found", ["http_bridge", "GET"])
|
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."""
|
"""Handle POST requests."""
|
||||||
parsed_url = urlparse(self.path)
|
parsed_url = urlparse(self.path)
|
||||||
path = parsed_url.path
|
path = parsed_url.path
|
||||||
@@ -219,9 +213,7 @@ class HttpBridge(ApiBridge, BaseHTTPRequestHandler):
|
|||||||
# Validate API path
|
# Validate API path
|
||||||
if not path.startswith("/api/v1/"):
|
if not path.startswith("/api/v1/"):
|
||||||
self.send_api_error_response(
|
self.send_api_error_response(
|
||||||
"post",
|
"post", f"Path not found: {path}", ["http_bridge", "POST"]
|
||||||
f"Path not found: {path}",
|
|
||||||
["http_bridge", "POST"],
|
|
||||||
)
|
)
|
||||||
return
|
return
|
||||||
|
|
||||||
@@ -229,9 +221,7 @@ class HttpBridge(ApiBridge, BaseHTTPRequestHandler):
|
|||||||
method_name = path[len("/api/v1/") :]
|
method_name = path[len("/api/v1/") :]
|
||||||
if not method_name:
|
if not method_name:
|
||||||
self.send_api_error_response(
|
self.send_api_error_response(
|
||||||
"post",
|
"post", "Method name required", ["http_bridge", "POST"]
|
||||||
"Method name required",
|
|
||||||
["http_bridge", "POST"],
|
|
||||||
)
|
)
|
||||||
return
|
return
|
||||||
|
|
||||||
@@ -252,7 +242,7 @@ class HttpBridge(ApiBridge, BaseHTTPRequestHandler):
|
|||||||
gen_op_key = str(uuid.uuid4())
|
gen_op_key = str(uuid.uuid4())
|
||||||
try:
|
try:
|
||||||
self._handle_api_request(method_name, request_data, gen_op_key)
|
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}")
|
log.exception(f"Error processing API request {method_name}")
|
||||||
self.send_api_error_response(
|
self.send_api_error_response(
|
||||||
gen_op_key,
|
gen_op_key,
|
||||||
@@ -264,10 +254,10 @@ class HttpBridge(ApiBridge, BaseHTTPRequestHandler):
|
|||||||
"""Read and parse the request body. Returns None if there was an error."""
|
"""Read and parse the request body. Returns None if there was an error."""
|
||||||
try:
|
try:
|
||||||
content_length = int(self.headers.get("Content-Length", 0))
|
content_length = int(self.headers.get("Content-Length", 0))
|
||||||
if content_length == 0:
|
if content_length > 0:
|
||||||
return {}
|
body = self.rfile.read(content_length)
|
||||||
body = self.rfile.read(content_length)
|
return json.loads(body.decode("utf-8"))
|
||||||
return json.loads(body.decode("utf-8"))
|
return {}
|
||||||
except json.JSONDecodeError:
|
except json.JSONDecodeError:
|
||||||
self.send_api_error_response(
|
self.send_api_error_response(
|
||||||
"post",
|
"post",
|
||||||
@@ -275,7 +265,7 @@ class HttpBridge(ApiBridge, BaseHTTPRequestHandler):
|
|||||||
["http_bridge", "POST", method_name],
|
["http_bridge", "POST", method_name],
|
||||||
)
|
)
|
||||||
return None
|
return None
|
||||||
except (OSError, ValueError, UnicodeDecodeError) as e:
|
except Exception as e:
|
||||||
self.send_api_error_response(
|
self.send_api_error_response(
|
||||||
"post",
|
"post",
|
||||||
f"Error reading request: {e!s}",
|
f"Error reading request: {e!s}",
|
||||||
@@ -299,26 +289,19 @@ class HttpBridge(ApiBridge, BaseHTTPRequestHandler):
|
|||||||
|
|
||||||
# Create API request
|
# Create API request
|
||||||
api_request = BackendRequest(
|
api_request = BackendRequest(
|
||||||
method_name=method_name,
|
method_name=method_name, args=body, header=header, op_key=op_key
|
||||||
args=body,
|
|
||||||
header=header,
|
|
||||||
op_key=op_key,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
except (KeyError, TypeError, ValueError) as e:
|
except Exception as e:
|
||||||
self.send_api_error_response(
|
self.send_api_error_response(
|
||||||
gen_op_key,
|
gen_op_key, str(e), ["http_bridge", method_name]
|
||||||
str(e),
|
|
||||||
["http_bridge", method_name],
|
|
||||||
)
|
)
|
||||||
return
|
return
|
||||||
|
|
||||||
self._process_api_request_in_thread(api_request)
|
self._process_api_request_in_thread(api_request, method_name)
|
||||||
|
|
||||||
def _parse_request_data(
|
def _parse_request_data(
|
||||||
self,
|
self, request_data: dict[str, Any], gen_op_key: str
|
||||||
request_data: dict[str, Any],
|
|
||||||
gen_op_key: str,
|
|
||||||
) -> tuple[dict[str, Any], dict[str, Any], str]:
|
) -> tuple[dict[str, Any], dict[str, Any], str]:
|
||||||
"""Parse and validate request data components."""
|
"""Parse and validate request data components."""
|
||||||
header = request_data.get("header", {})
|
header = request_data.get("header", {})
|
||||||
@@ -361,8 +344,7 @@ class HttpBridge(ApiBridge, BaseHTTPRequestHandler):
|
|||||||
pass
|
pass
|
||||||
|
|
||||||
def _process_api_request_in_thread(
|
def _process_api_request_in_thread(
|
||||||
self,
|
self, api_request: BackendRequest, method_name: str
|
||||||
api_request: BackendRequest,
|
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Process the API request in a separate thread."""
|
"""Process the API request in a separate thread."""
|
||||||
stop_event = threading.Event()
|
stop_event = threading.Event()
|
||||||
@@ -376,7 +358,7 @@ class HttpBridge(ApiBridge, BaseHTTPRequestHandler):
|
|||||||
|
|
||||||
log.debug(
|
log.debug(
|
||||||
f"Processing {request.method_name} with args {request.args} "
|
f"Processing {request.method_name} with args {request.args} "
|
||||||
f"and header {request.header}",
|
f"and header {request.header}"
|
||||||
)
|
)
|
||||||
self.process_request(request)
|
self.process_request(request)
|
||||||
|
|
||||||
|
|||||||
@@ -4,11 +4,13 @@ import json
|
|||||||
import logging
|
import logging
|
||||||
import threading
|
import threading
|
||||||
import time
|
import time
|
||||||
|
from unittest.mock import Mock
|
||||||
from urllib.request import Request, urlopen
|
from urllib.request import Request, urlopen
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
from clan_lib.api import MethodRegistry, tasks
|
from clan_lib.api import MethodRegistry, tasks
|
||||||
from clan_lib.async_run import is_async_cancelled
|
from clan_lib.async_run import is_async_cancelled
|
||||||
|
from clan_lib.log_manager import LogManager
|
||||||
|
|
||||||
from clan_app.api.middleware import (
|
from clan_app.api.middleware import (
|
||||||
ArgumentParsingMiddleware,
|
ArgumentParsingMiddleware,
|
||||||
@@ -51,20 +53,30 @@ def mock_api() -> MethodRegistry:
|
|||||||
return api
|
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
|
@pytest.fixture
|
||||||
def http_bridge(
|
def http_bridge(
|
||||||
mock_api: MethodRegistry,
|
mock_api: MethodRegistry, mock_log_manager: Mock
|
||||||
) -> tuple[MethodRegistry, tuple]:
|
) -> tuple[MethodRegistry, tuple]:
|
||||||
"""Create HTTP bridge dependencies for testing."""
|
"""Create HTTP bridge dependencies for testing."""
|
||||||
middleware_chain = (
|
middleware_chain = (
|
||||||
ArgumentParsingMiddleware(api=mock_api),
|
ArgumentParsingMiddleware(api=mock_api),
|
||||||
|
# LoggingMiddleware(log_manager=mock_log_manager),
|
||||||
MethodExecutionMiddleware(api=mock_api),
|
MethodExecutionMiddleware(api=mock_api),
|
||||||
)
|
)
|
||||||
return mock_api, middleware_chain
|
return mock_api, middleware_chain
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
@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."""
|
"""Create HTTP server with mock dependencies."""
|
||||||
server = HttpApiServer(
|
server = HttpApiServer(
|
||||||
api=mock_api,
|
api=mock_api,
|
||||||
@@ -74,6 +86,7 @@ def http_server(mock_api: MethodRegistry) -> HttpApiServer:
|
|||||||
|
|
||||||
# Add middleware
|
# Add middleware
|
||||||
server.add_middleware(ArgumentParsingMiddleware(api=mock_api))
|
server.add_middleware(ArgumentParsingMiddleware(api=mock_api))
|
||||||
|
# server.add_middleware(LoggingMiddleware(log_manager=mock_log_manager))
|
||||||
server.add_middleware(MethodExecutionMiddleware(api=mock_api))
|
server.add_middleware(MethodExecutionMiddleware(api=mock_api))
|
||||||
|
|
||||||
# Bridge will be created automatically when accessed
|
# 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
|
# The actual HTTP handling will be tested through the server integration tests
|
||||||
assert len(middleware_chain) == 2
|
assert len(middleware_chain) == 2
|
||||||
assert isinstance(middleware_chain[0], ArgumentParsingMiddleware)
|
assert isinstance(middleware_chain[0], ArgumentParsingMiddleware)
|
||||||
|
# assert isinstance(middleware_chain[1], LoggingMiddleware)
|
||||||
assert isinstance(middleware_chain[1], MethodExecutionMiddleware)
|
assert isinstance(middleware_chain[1], MethodExecutionMiddleware)
|
||||||
|
|
||||||
|
|
||||||
@@ -156,7 +170,7 @@ class TestHttpApiServer:
|
|||||||
data=json.dumps(request_data).encode(),
|
data=json.dumps(request_data).encode(),
|
||||||
headers={"Content-Type": "application/json"},
|
headers={"Content-Type": "application/json"},
|
||||||
)
|
)
|
||||||
response = urlopen(req) # noqa: S310
|
response = urlopen(req)
|
||||||
data = json.loads(response.read().decode())
|
data = json.loads(response.read().decode())
|
||||||
|
|
||||||
# Response should be BackendResponse format
|
# Response should be BackendResponse format
|
||||||
@@ -192,7 +206,7 @@ class TestHttpApiServer:
|
|||||||
headers={"Content-Type": "application/json"},
|
headers={"Content-Type": "application/json"},
|
||||||
)
|
)
|
||||||
|
|
||||||
res = urlopen(req) # noqa: S310
|
res = urlopen(req)
|
||||||
assert res.status == 200
|
assert res.status == 200
|
||||||
body = json.loads(res.read().decode())["body"]
|
body = json.loads(res.read().decode())["body"]
|
||||||
assert body["status"] == "error"
|
assert body["status"] == "error"
|
||||||
@@ -204,7 +218,7 @@ class TestHttpApiServer:
|
|||||||
headers={"Content-Type": "application/json"},
|
headers={"Content-Type": "application/json"},
|
||||||
)
|
)
|
||||||
|
|
||||||
res = urlopen(req) # noqa: S310
|
res = urlopen(req)
|
||||||
assert res.status == 200
|
assert res.status == 200
|
||||||
body = json.loads(res.read().decode())["body"]
|
body = json.loads(res.read().decode())["body"]
|
||||||
assert body["status"] == "error"
|
assert body["status"] == "error"
|
||||||
@@ -225,7 +239,7 @@ class TestHttpApiServer:
|
|||||||
return "OPTIONS"
|
return "OPTIONS"
|
||||||
|
|
||||||
req: Request = OptionsRequest("http://127.0.0.1:8081/api/call/test_method")
|
req: Request = OptionsRequest("http://127.0.0.1:8081/api/call/test_method")
|
||||||
response = urlopen(req) # noqa: S310
|
response = urlopen(req)
|
||||||
|
|
||||||
# Check CORS headers
|
# Check CORS headers
|
||||||
headers = response.info()
|
headers = response.info()
|
||||||
@@ -242,8 +256,7 @@ class TestIntegration:
|
|||||||
"""Integration tests for HTTP API components."""
|
"""Integration tests for HTTP API components."""
|
||||||
|
|
||||||
def test_full_request_flow(
|
def test_full_request_flow(
|
||||||
self,
|
self, mock_api: MethodRegistry, mock_log_manager: Mock
|
||||||
mock_api: MethodRegistry,
|
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Test complete request flow from server to bridge to middleware."""
|
"""Test complete request flow from server to bridge to middleware."""
|
||||||
server: HttpApiServer = HttpApiServer(
|
server: HttpApiServer = HttpApiServer(
|
||||||
@@ -254,6 +267,7 @@ class TestIntegration:
|
|||||||
|
|
||||||
# Add middleware
|
# Add middleware
|
||||||
server.add_middleware(ArgumentParsingMiddleware(api=mock_api))
|
server.add_middleware(ArgumentParsingMiddleware(api=mock_api))
|
||||||
|
# server.add_middleware(LoggingMiddleware(log_manager=mock_log_manager))
|
||||||
server.add_middleware(MethodExecutionMiddleware(api=mock_api))
|
server.add_middleware(MethodExecutionMiddleware(api=mock_api))
|
||||||
|
|
||||||
# Bridge will be created automatically when accessed
|
# Bridge will be created automatically when accessed
|
||||||
@@ -273,7 +287,7 @@ class TestIntegration:
|
|||||||
data=json.dumps(request_data).encode(),
|
data=json.dumps(request_data).encode(),
|
||||||
headers={"Content-Type": "application/json"},
|
headers={"Content-Type": "application/json"},
|
||||||
)
|
)
|
||||||
response = urlopen(req) # noqa: S310
|
response = urlopen(req)
|
||||||
data: dict = json.loads(response.read().decode())
|
data: dict = json.loads(response.read().decode())
|
||||||
|
|
||||||
# Verify response in BackendResponse format
|
# Verify response in BackendResponse format
|
||||||
@@ -287,8 +301,7 @@ class TestIntegration:
|
|||||||
server.stop()
|
server.stop()
|
||||||
|
|
||||||
def test_blocking_task(
|
def test_blocking_task(
|
||||||
self,
|
self, mock_api: MethodRegistry, mock_log_manager: Mock
|
||||||
mock_api: MethodRegistry,
|
|
||||||
) -> None:
|
) -> None:
|
||||||
shared_threads: dict[str, tasks.WebThread] = {}
|
shared_threads: dict[str, tasks.WebThread] = {}
|
||||||
tasks.BAKEND_THREADS = shared_threads
|
tasks.BAKEND_THREADS = shared_threads
|
||||||
@@ -303,6 +316,7 @@ class TestIntegration:
|
|||||||
|
|
||||||
# Add middleware
|
# Add middleware
|
||||||
server.add_middleware(ArgumentParsingMiddleware(api=mock_api))
|
server.add_middleware(ArgumentParsingMiddleware(api=mock_api))
|
||||||
|
# server.add_middleware(LoggingMiddleware(log_manager=mock_log_manager))
|
||||||
server.add_middleware(MethodExecutionMiddleware(api=mock_api))
|
server.add_middleware(MethodExecutionMiddleware(api=mock_api))
|
||||||
|
|
||||||
# Start server
|
# Start server
|
||||||
@@ -322,7 +336,7 @@ class TestIntegration:
|
|||||||
data=json.dumps(request_data).encode(),
|
data=json.dumps(request_data).encode(),
|
||||||
headers={"Content-Type": "application/json"},
|
headers={"Content-Type": "application/json"},
|
||||||
)
|
)
|
||||||
response = urlopen(req) # noqa: S310
|
response = urlopen(req)
|
||||||
data: dict = json.loads(response.read().decode())
|
data: dict = json.loads(response.read().decode())
|
||||||
|
|
||||||
# thread.join()
|
# thread.join()
|
||||||
@@ -346,7 +360,7 @@ class TestIntegration:
|
|||||||
data=json.dumps(request_data).encode(),
|
data=json.dumps(request_data).encode(),
|
||||||
headers={"Content-Type": "application/json"},
|
headers={"Content-Type": "application/json"},
|
||||||
)
|
)
|
||||||
response = urlopen(req) # noqa: S310
|
response = urlopen(req)
|
||||||
data: dict = json.loads(response.read().decode())
|
data: dict = json.loads(response.read().decode())
|
||||||
|
|
||||||
assert "body" in data
|
assert "body" in data
|
||||||
|
|||||||
@@ -21,7 +21,7 @@ def _get_lib_names() -> list[str]:
|
|||||||
machine = platform.machine().lower()
|
machine = platform.machine().lower()
|
||||||
|
|
||||||
if system == "windows":
|
if system == "windows":
|
||||||
if machine in {"amd64", "x86_64"}:
|
if machine == "amd64" or machine == "x86_64":
|
||||||
return ["webview.dll", "WebView2Loader.dll"]
|
return ["webview.dll", "WebView2Loader.dll"]
|
||||||
if machine == "arm64":
|
if machine == "arm64":
|
||||||
msg = "arm64 is not supported on Windows"
|
msg = "arm64 is not supported on Windows"
|
||||||
@@ -36,6 +36,7 @@ def _get_lib_names() -> list[str]:
|
|||||||
|
|
||||||
def _be_sure_libraries() -> list[Path] | None:
|
def _be_sure_libraries() -> list[Path] | None:
|
||||||
"""Ensure libraries exist and return paths."""
|
"""Ensure libraries exist and return paths."""
|
||||||
|
|
||||||
lib_dir = os.environ.get("WEBVIEW_LIB_DIR")
|
lib_dir = os.environ.get("WEBVIEW_LIB_DIR")
|
||||||
if not lib_dir:
|
if not lib_dir:
|
||||||
msg = "WEBVIEW_LIB_DIR environment variable is not set"
|
msg = "WEBVIEW_LIB_DIR environment variable is not set"
|
||||||
|
|||||||
@@ -10,13 +10,15 @@ from typing import TYPE_CHECKING, Any
|
|||||||
|
|
||||||
from clan_lib.api import MethodRegistry, message_queue
|
from clan_lib.api import MethodRegistry, message_queue
|
||||||
from clan_lib.api.tasks import WebThread
|
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_ffi import _encode_c_string, _webview_lib
|
||||||
from .webview_bridge import WebviewBridge
|
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
from clan_app.api.middleware import Middleware
|
from clan_app.api.middleware import Middleware
|
||||||
|
|
||||||
|
from .webview_bridge import WebviewBridge
|
||||||
|
|
||||||
log = logging.getLogger(__name__)
|
log = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
@@ -48,15 +50,14 @@ class Webview:
|
|||||||
shared_threads: dict[str, WebThread] | None = None
|
shared_threads: dict[str, WebThread] | None = None
|
||||||
|
|
||||||
# initialized later
|
# initialized later
|
||||||
_bridge: WebviewBridge | None = None
|
_bridge: "WebviewBridge | None" = None
|
||||||
_handle: Any | None = None
|
_handle: Any | None = None
|
||||||
_callbacks: dict[str, Callable[..., Any]] = field(default_factory=dict)
|
_callbacks: dict[str, Callable[..., Any]] = field(default_factory=dict)
|
||||||
_middleware: list["Middleware"] = field(default_factory=list)
|
_middleware: list["Middleware"] = field(default_factory=list)
|
||||||
|
|
||||||
def _create_handle(self) -> None:
|
def _create_handle(self) -> None:
|
||||||
# Initialize the webview handle
|
# Initialize the webview handle
|
||||||
with_debugger = True
|
handle = _webview_lib.webview_create(int(self.debug), self.window)
|
||||||
handle = _webview_lib.webview_create(int(with_debugger), self.window)
|
|
||||||
callbacks: dict[str, Callable[..., Any]] = {}
|
callbacks: dict[str, Callable[..., Any]] = {}
|
||||||
|
|
||||||
# Since we can't use object.__setattr__, we'll initialize differently
|
# Since we can't use object.__setattr__, we'll initialize differently
|
||||||
@@ -80,7 +81,7 @@ class Webview:
|
|||||||
msg = message_queue.get() # Blocks until available
|
msg = message_queue.get() # Blocks until available
|
||||||
js_code = f"window.notifyBus({json.dumps(msg)});"
|
js_code = f"window.notifyBus({json.dumps(msg)});"
|
||||||
self.eval(js_code)
|
self.eval(js_code)
|
||||||
except (json.JSONDecodeError, RuntimeError, AttributeError) as e:
|
except Exception as e:
|
||||||
print("Bridge notify error:", e)
|
print("Bridge notify error:", e)
|
||||||
sleep(0.01) # avoid busy loop
|
sleep(0.01) # avoid busy loop
|
||||||
|
|
||||||
@@ -98,24 +99,23 @@ class Webview:
|
|||||||
"""Get the bridge, creating it if necessary."""
|
"""Get the bridge, creating it if necessary."""
|
||||||
if self._bridge is None:
|
if self._bridge is None:
|
||||||
self.create_bridge()
|
self.create_bridge()
|
||||||
if self._bridge is None:
|
assert self._bridge is not None, "Bridge should be created"
|
||||||
msg = "Bridge should be created"
|
|
||||||
raise RuntimeError(msg)
|
|
||||||
return self._bridge
|
return self._bridge
|
||||||
|
|
||||||
def api_wrapper(
|
def api_wrapper(
|
||||||
self,
|
self,
|
||||||
method_name: str,
|
method_name: str,
|
||||||
|
wrap_method: Callable[..., Any],
|
||||||
op_key_bytes: bytes,
|
op_key_bytes: bytes,
|
||||||
request_data: bytes,
|
request_data: bytes,
|
||||||
arg: int,
|
arg: int,
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Legacy API wrapper - delegates to the bridge."""
|
"""Legacy API wrapper - delegates to the bridge."""
|
||||||
del arg # Unused but required for C callback signature
|
|
||||||
self.bridge.handle_webview_call(
|
self.bridge.handle_webview_call(
|
||||||
method_name=method_name,
|
method_name=method_name,
|
||||||
op_key_bytes=op_key_bytes,
|
op_key_bytes=op_key_bytes,
|
||||||
request_data=request_data,
|
request_data=request_data,
|
||||||
|
arg=arg,
|
||||||
)
|
)
|
||||||
|
|
||||||
@property
|
@property
|
||||||
@@ -131,8 +131,10 @@ class Webview:
|
|||||||
|
|
||||||
self._middleware.append(middleware)
|
self._middleware.append(middleware)
|
||||||
|
|
||||||
def create_bridge(self) -> WebviewBridge:
|
def create_bridge(self) -> "WebviewBridge":
|
||||||
"""Create and initialize the WebviewBridge with current middleware."""
|
"""Create and initialize the WebviewBridge with current middleware."""
|
||||||
|
from .webview_bridge import WebviewBridge
|
||||||
|
|
||||||
# Use shared_threads if provided, otherwise let WebviewBridge use its default
|
# Use shared_threads if provided, otherwise let WebviewBridge use its default
|
||||||
if self.shared_threads is not None:
|
if self.shared_threads is not None:
|
||||||
bridge = WebviewBridge(
|
bridge = WebviewBridge(
|
||||||
@@ -142,9 +144,7 @@ class Webview:
|
|||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
bridge = WebviewBridge(
|
bridge = WebviewBridge(
|
||||||
webview=self,
|
webview=self, middleware_chain=tuple(self._middleware), threads={}
|
||||||
middleware_chain=tuple(self._middleware),
|
|
||||||
threads={},
|
|
||||||
)
|
)
|
||||||
self._bridge = bridge
|
self._bridge = bridge
|
||||||
|
|
||||||
@@ -154,10 +154,7 @@ class Webview:
|
|||||||
def set_size(self, value: Size) -> None:
|
def set_size(self, value: Size) -> None:
|
||||||
"""Set the webview size (legacy compatibility)."""
|
"""Set the webview size (legacy compatibility)."""
|
||||||
_webview_lib.webview_set_size(
|
_webview_lib.webview_set_size(
|
||||||
self.handle,
|
self.handle, value.width, value.height, value.hint
|
||||||
value.width,
|
|
||||||
value.height,
|
|
||||||
value.hint,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
def set_title(self, value: str) -> None:
|
def set_title(self, value: str) -> None:
|
||||||
@@ -182,11 +179,12 @@ class Webview:
|
|||||||
log.info("Shutting down webview...")
|
log.info("Shutting down webview...")
|
||||||
self.destroy()
|
self.destroy()
|
||||||
|
|
||||||
def bind_jsonschema_api(self, api: MethodRegistry) -> None:
|
def bind_jsonschema_api(self, api: MethodRegistry, log_manager: LogManager) -> None:
|
||||||
for name in api.functions:
|
for name, method in api.functions.items():
|
||||||
wrapper = functools.partial(
|
wrapper = functools.partial(
|
||||||
self.api_wrapper,
|
self.api_wrapper,
|
||||||
name,
|
name,
|
||||||
|
method,
|
||||||
)
|
)
|
||||||
c_callback = _webview_lib.binding_callback_t(wrapper)
|
c_callback = _webview_lib.binding_callback_t(wrapper)
|
||||||
|
|
||||||
@@ -196,19 +194,16 @@ class Webview:
|
|||||||
|
|
||||||
self._callbacks[name] = c_callback
|
self._callbacks[name] = c_callback
|
||||||
_webview_lib.webview_bind(
|
_webview_lib.webview_bind(
|
||||||
self.handle,
|
self.handle, _encode_c_string(name), c_callback, None
|
||||||
_encode_c_string(name),
|
|
||||||
c_callback,
|
|
||||||
None,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
def bind(self, name: str, callback: Callable[..., Any]) -> 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())
|
args = json.loads(req.decode())
|
||||||
try:
|
try:
|
||||||
result = callback(*args)
|
result = callback(*args)
|
||||||
success = True
|
success = True
|
||||||
except Exception as e: # noqa: BLE001
|
except Exception as e:
|
||||||
result = str(e)
|
result = str(e)
|
||||||
success = False
|
success = False
|
||||||
self.return_(seq.decode(), 0 if success else 1, json.dumps(result))
|
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:
|
def return_(self, seq: str, status: int, result: str) -> None:
|
||||||
_webview_lib.webview_return(
|
_webview_lib.webview_return(
|
||||||
self.handle,
|
self.handle, _encode_c_string(seq), status, _encode_c_string(result)
|
||||||
_encode_c_string(seq),
|
|
||||||
status,
|
|
||||||
_encode_c_string(result),
|
|
||||||
)
|
)
|
||||||
|
|
||||||
def eval(self, source: str) -> None:
|
def eval(self, source: str) -> None:
|
||||||
|
|||||||
@@ -8,6 +8,8 @@ from clan_lib.api.tasks import WebThread
|
|||||||
|
|
||||||
from clan_app.api.api_bridge import ApiBridge, BackendRequest, BackendResponse
|
from clan_app.api.api_bridge import ApiBridge, BackendRequest, BackendResponse
|
||||||
|
|
||||||
|
from .webview import FuncStatus
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
from .webview import Webview
|
from .webview import Webview
|
||||||
|
|
||||||
@@ -24,15 +26,10 @@ class WebviewBridge(ApiBridge):
|
|||||||
def send_api_response(self, response: BackendResponse) -> None:
|
def send_api_response(self, response: BackendResponse) -> None:
|
||||||
"""Send response back to the webview client."""
|
"""Send response back to the webview client."""
|
||||||
serialized = json.dumps(
|
serialized = json.dumps(
|
||||||
dataclass_to_dict(response),
|
dataclass_to_dict(response), indent=4, ensure_ascii=False
|
||||||
indent=4,
|
|
||||||
ensure_ascii=False,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
log.debug(f"Sending response: {serialized}")
|
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
|
self.webview.return_(response._op_key, FuncStatus.SUCCESS, serialized) # noqa: SLF001
|
||||||
|
|
||||||
def handle_webview_call(
|
def handle_webview_call(
|
||||||
@@ -40,8 +37,10 @@ class WebviewBridge(ApiBridge):
|
|||||||
method_name: str,
|
method_name: str,
|
||||||
op_key_bytes: bytes,
|
op_key_bytes: bytes,
|
||||||
request_data: bytes,
|
request_data: bytes,
|
||||||
|
arg: int,
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Handle a call from webview's JavaScript bridge."""
|
"""Handle a call from webview's JavaScript bridge."""
|
||||||
|
|
||||||
try:
|
try:
|
||||||
op_key = op_key_bytes.decode()
|
op_key = op_key_bytes.decode()
|
||||||
raw_args = json.loads(request_data.decode())
|
raw_args = json.loads(request_data.decode())
|
||||||
@@ -69,10 +68,7 @@ class WebviewBridge(ApiBridge):
|
|||||||
|
|
||||||
# Create API request
|
# Create API request
|
||||||
api_request = BackendRequest(
|
api_request = BackendRequest(
|
||||||
method_name=method_name,
|
method_name=method_name, args=args, header=header, op_key=op_key
|
||||||
args=args,
|
|
||||||
header=header,
|
|
||||||
op_key=op_key,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
@@ -81,9 +77,7 @@ class WebviewBridge(ApiBridge):
|
|||||||
)
|
)
|
||||||
log.exception(msg)
|
log.exception(msg)
|
||||||
self.send_api_error_response(
|
self.send_api_error_response(
|
||||||
op_key,
|
op_key, str(e), ["webview_bridge", method_name]
|
||||||
str(e),
|
|
||||||
["webview_bridge", method_name],
|
|
||||||
)
|
)
|
||||||
return
|
return
|
||||||
|
|
||||||
|
|||||||
@@ -31,7 +31,7 @@ let
|
|||||||
desktop-file = makeDesktopItem {
|
desktop-file = makeDesktopItem {
|
||||||
name = "org.clan.app";
|
name = "org.clan.app";
|
||||||
exec = "clan-app %u";
|
exec = "clan-app %u";
|
||||||
icon = "clan-app";
|
icon = "clan-white";
|
||||||
desktopName = "Clan App";
|
desktopName = "Clan App";
|
||||||
startupWMClass = "clan";
|
startupWMClass = "clan";
|
||||||
mimeTypes = [ "x-scheme-handler/clan" ];
|
mimeTypes = [ "x-scheme-handler/clan" ];
|
||||||
|
|||||||
@@ -54,7 +54,8 @@ class Command:
|
|||||||
|
|
||||||
@pytest.fixture
|
@pytest.fixture
|
||||||
def command() -> Iterator[Command]:
|
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"])
|
>>> p = command.run(["some", "daemon"])
|
||||||
>>> print(p.pid)
|
>>> print(p.pid)
|
||||||
"""
|
"""
|
||||||
|
|||||||
@@ -2,15 +2,12 @@ from __future__ import annotations
|
|||||||
|
|
||||||
import logging
|
import logging
|
||||||
import subprocess
|
import subprocess
|
||||||
from typing import TYPE_CHECKING
|
from pathlib import Path
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
from clan_lib.custom_logger import setup_logging
|
from clan_lib.custom_logger import setup_logging
|
||||||
from clan_lib.nix import nix_shell
|
from clan_lib.nix import nix_shell
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
|
||||||
from pathlib import Path
|
|
||||||
|
|
||||||
pytest_plugins = [
|
pytest_plugins = [
|
||||||
"temporary_dir",
|
"temporary_dir",
|
||||||
"root",
|
"root",
|
||||||
|
|||||||
@@ -13,17 +13,23 @@ else:
|
|||||||
|
|
||||||
@pytest.fixture(scope="session")
|
@pytest.fixture(scope="session")
|
||||||
def project_root() -> Path:
|
def project_root() -> Path:
|
||||||
"""Root directory the clan-cli"""
|
"""
|
||||||
|
Root directory the clan-cli
|
||||||
|
"""
|
||||||
return PROJECT_ROOT
|
return PROJECT_ROOT
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture(scope="session")
|
@pytest.fixture(scope="session")
|
||||||
def test_root() -> Path:
|
def test_root() -> Path:
|
||||||
"""Root directory of the tests"""
|
"""
|
||||||
|
Root directory of the tests
|
||||||
|
"""
|
||||||
return TEST_ROOT
|
return TEST_ROOT
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture(scope="session")
|
@pytest.fixture(scope="session")
|
||||||
def clan_core() -> Path:
|
def clan_core() -> Path:
|
||||||
"""Directory of the clan-core flake"""
|
"""
|
||||||
|
Directory of the clan-core flake
|
||||||
|
"""
|
||||||
return CLAN_CORE
|
return CLAN_CORE
|
||||||
|
|||||||
@@ -24,11 +24,7 @@ def app() -> Generator[GtkProc]:
|
|||||||
cmd = [sys.executable, "-m", "clan_app"]
|
cmd = [sys.executable, "-m", "clan_app"]
|
||||||
print(f"Running: {cmd}")
|
print(f"Running: {cmd}")
|
||||||
rapp = Popen(
|
rapp = Popen(
|
||||||
cmd,
|
cmd, text=True, stdout=sys.stdout, stderr=sys.stderr, start_new_session=True
|
||||||
text=True,
|
|
||||||
stdout=sys.stdout,
|
|
||||||
stderr=sys.stderr,
|
|
||||||
start_new_session=True,
|
|
||||||
)
|
)
|
||||||
yield GtkProc(rapp)
|
yield GtkProc(rapp)
|
||||||
# Cleanup: Terminate your application
|
# Cleanup: Terminate your application
|
||||||
|
|||||||
@@ -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 |
@@ -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 |
@@ -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 |
@@ -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 |
@@ -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 |
@@ -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 |
@@ -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 |
@@ -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 |
@@ -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 |
@@ -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 |
@@ -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 |
@@ -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 |
@@ -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 |