Merge remote-tracking branch 'origin/main' into rework-installation

This commit is contained in:
Jörg Thalheim
2024-07-24 21:09:23 +02:00
70 changed files with 986 additions and 667 deletions

30
flake.lock generated
View File

@@ -7,11 +7,11 @@
] ]
}, },
"locked": { "locked": {
"lastModified": 1720661479, "lastModified": 1721417620,
"narHash": "sha256-nsGgA14vVn0GGiqEfomtVgviRJCuSR3UEopfP8ixW1I=", "narHash": "sha256-6q9b1h8fI3hXg2DG6/vrKWCeG8c5Wj2Kvv22RCgedzg=",
"owner": "nix-community", "owner": "nix-community",
"repo": "disko", "repo": "disko",
"rev": "786965e1b1ed3fd2018d78399984f461e2a44689", "rev": "bec6e3cde912b8acb915fecdc509eda7c973fb42",
"type": "github" "type": "github"
}, },
"original": { "original": {
@@ -48,11 +48,11 @@
] ]
}, },
"locked": { "locked": {
"lastModified": 1720659757, "lastModified": 1721571445,
"narHash": "sha256-ltzUuCsEfPA9CYM9BAnwObBGqDyQIs2OLkbVMeOOk00=", "narHash": "sha256-2MnlPVcNJZ9Nbu90kFyo7+lng366gswErP4FExfrUbc=",
"owner": "nix-community", "owner": "nix-community",
"repo": "nixos-images", "repo": "nixos-images",
"rev": "5eddae0afbcfd4283af5d6676d08ad059ca04b70", "rev": "accee005735844d57b411d9969c5d0aabc6a55f6",
"type": "github" "type": "github"
}, },
"original": { "original": {
@@ -63,11 +63,11 @@
}, },
"nixpkgs": { "nixpkgs": {
"locked": { "locked": {
"lastModified": 1720977633, "lastModified": 1721571961,
"narHash": "sha256-if0qaFmAe8X01NsVRK5e9Asg9mEWVkHrA9WuqM5jB70=", "narHash": "sha256-jfF4gpRUpTBY2OxDB0FRySsgNGOiuDckEtu7YDQom3Y=",
"owner": "NixOS", "owner": "NixOS",
"repo": "nixpkgs", "repo": "nixpkgs",
"rev": "0af9d835c27984b3265145f8e3cbc6c153479196", "rev": "4cc8b29327bed3d52b40041f810f49734298af46",
"type": "github" "type": "github"
}, },
"original": { "original": {
@@ -95,11 +95,11 @@
"nixpkgs-stable": [] "nixpkgs-stable": []
}, },
"locked": { "locked": {
"lastModified": 1720926522, "lastModified": 1721531171,
"narHash": "sha256-eTpnrT6yu1vp8C0B5fxHXhgKxHoYMoYTEikQx///jxY=", "narHash": "sha256-AsvPw7T0tBLb53xZGcUC3YPqlIpdxoSx56u8vPCr6gU=",
"owner": "Mic92", "owner": "Mic92",
"repo": "sops-nix", "repo": "sops-nix",
"rev": "0703ba03fd9c1665f8ab68cc3487302475164617", "rev": "909e8cfb60d83321d85c8d17209d733658a21c95",
"type": "github" "type": "github"
}, },
"original": { "original": {
@@ -115,11 +115,11 @@
] ]
}, },
"locked": { "locked": {
"lastModified": 1720930114, "lastModified": 1721458737,
"narHash": "sha256-VZK73b5hG5bSeAn97TTcnPjXUXtV7j/AtS4KN8ggCS0=", "narHash": "sha256-wNXLQ/ATs1S4Opg1PmuNoJ+Wamqj93rgZYV3Di7kxkg=",
"owner": "numtide", "owner": "numtide",
"repo": "treefmt-nix", "repo": "treefmt-nix",
"rev": "b92afa1501ac73f1d745526adc4f89b527595f14", "rev": "888bfb10a9b091d9ed2f5f8064de8d488f7b7c97",
"type": "github" "type": "github"
}, },
"original": { "original": {

View File

@@ -58,11 +58,11 @@ let
{ {
# { ${name} :: meta // { name, tags } } # { ${name} :: meta // { name, tags } }
machines = lib.mapAttrs ( machines = lib.mapAttrs (
name: config: name: machineConfig:
(lib.attrByPath [ (lib.attrByPath [
"clan" "clan"
"meta" "meta"
] { } config) ] { } machineConfig)
// { // {
# meta.name default is the attribute name of the machine # meta.name default is the attribute name of the machine
name = lib.mkDefault ( name = lib.mkDefault (
@@ -70,11 +70,11 @@ let
"clan" "clan"
"meta" "meta"
"name" "name"
] name config ] name machineConfig
); );
} }
# tags # tags
// (clanToInventory config { // (clanToInventory machineConfig {
clanPath = [ clanPath = [
"clan" "clan"
"tags" "tags"
@@ -82,15 +82,15 @@ let
inventoryPath = [ "tags" ]; inventoryPath = [ "tags" ];
}) })
# system # system
// (clanToInventory config { // (clanToInventory machineConfig {
clanPath = [ clanPath = [
"nixpkgs" "nixpkgs"
"hostSystem" "hostPlatform"
]; ];
inventoryPath = [ "system" ]; inventoryPath = [ "system" ];
}) })
# deploy.targetHost # deploy.targetHost
// (clanToInventory config { // (clanToInventory machineConfig {
clanPath = [ clanPath = [
"clan" "clan"
"core" "core"

View File

@@ -17,7 +17,7 @@ in
imports = [ imports = [
./public/in_repo.nix ./public/in_repo.nix
# ./public/vm.nix # ./public/vm.nix
# ./secret/password-store.nix ./secret/password-store.nix
./secret/sops.nix ./secret/sops.nix
# ./secret/vm.nix # ./secret/vm.nix
]; ];
@@ -39,7 +39,7 @@ in
vars = { vars = {
generators = lib.flip lib.mapAttrs config.clan.core.vars.generators ( generators = lib.flip lib.mapAttrs config.clan.core.vars.generators (
_name: generator: { _name: generator: {
inherit (generator) dependencies finalScript; inherit (generator) dependencies finalScript prompts;
files = lib.flip lib.mapAttrs generator.files (_name: file: { inherit (file) secret; }); files = lib.flip lib.mapAttrs generator.files (_name: file: { inherit (file) secret; });
} }
); );

View File

@@ -108,8 +108,9 @@ in
Prompts are available to the generator script as files. Prompts are available to the generator script as files.
For example, a prompt named 'prompt1' will be available via $prompts/prompt1 For example, a prompt named 'prompt1' will be available via $prompts/prompt1
''; '';
default = { };
type = attrsOf (submodule { type = attrsOf (submodule {
options = { options = options {
description = { description = {
description = '' description = ''
The description of the prompted value The description of the prompted value

View File

@@ -0,0 +1,12 @@
{ config, lib, ... }:
{
config.clan.core.vars.settings =
lib.mkIf (config.clan.core.vars.settings.secretStore == "password-store")
{
fileModule = file: {
path = lib.mkIf file.secret "${config.clan.core.password-store.targetDirectory}/${config.clan.core.machineName}-${file.config.generatorName}-${file.config.name}";
};
secretUploadDirectory = lib.mkDefault "/etc/secrets";
secretModule = "clan_cli.vars.secret_modules.password_store";
};
}

View File

@@ -17,8 +17,13 @@ let
imports = [ imports = [
(modulesPath + "/virtualisation/qemu-vm.nix") (modulesPath + "/virtualisation/qemu-vm.nix")
./serial.nix ./serial.nix
./waypipe.nix
]; ];
clan.services.waypipe = {
inherit (config.clan.core.vm.inspect.waypipe) enable command;
};
# required for issuing shell commands via qga # required for issuing shell commands via qga
services.qemuGuest.enable = true; services.qemuGuest.enable = true;
@@ -149,12 +154,19 @@ in
''; '';
}; };
waypipe = lib.mkOption { waypipe = {
type = lib.types.bool; enable = lib.mkOption {
default = false; type = lib.types.bool;
description = '' default = false;
Whether to use waypipe for native wayland passthrough, or not. description = ''
''; Whether to use waypipe for native wayland passthrough, or not.
'';
};
command = lib.mkOption {
type = lib.types.listOf lib.types.str;
default = [ ];
description = "Commands that waypipe should run";
};
}; };
}; };
# All important VM config variables needed by the vm runner # All important VM config variables needed by the vm runner
@@ -193,13 +205,22 @@ in
whether to enable graphics for the vm whether to enable graphics for the vm
''; '';
}; };
waypipe = lib.mkOption {
type = lib.types.bool; waypipe = {
internal = true; enable = lib.mkOption {
readOnly = true; type = lib.types.bool;
description = '' internal = true;
whether to enable native wayland window passthrough with waypipe for the vm readOnly = true;
''; description = ''
Whether to use waypipe for native wayland passthrough, or not.
'';
};
command = lib.mkOption {
type = lib.types.listOf lib.types.str;
internal = true;
readOnly = true;
description = "Commands that waypipe should run";
};
}; };
machine_icon = lib.mkOption { machine_icon = lib.mkOption {
type = lib.types.nullOr lib.types.path; type = lib.types.nullOr lib.types.path;
@@ -245,7 +266,12 @@ in
initrd = "${vmConfig.config.system.build.initialRamdisk}/${vmConfig.config.system.boot.loader.initrdFile}"; initrd = "${vmConfig.config.system.build.initialRamdisk}/${vmConfig.config.system.boot.loader.initrdFile}";
toplevel = vmConfig.config.system.build.toplevel; toplevel = vmConfig.config.system.build.toplevel;
regInfo = (pkgs.closureInfo { rootPaths = vmConfig.config.virtualisation.additionalPaths; }); regInfo = (pkgs.closureInfo { rootPaths = vmConfig.config.virtualisation.additionalPaths; });
inherit (config.clan.virtualisation) memorySize cores graphics; inherit (config.clan.virtualisation)
memorySize
cores
graphics
waypipe
;
} }
); );
}; };

View File

@@ -39,8 +39,6 @@
# General default settings # General default settings
fonts.enableDefaultPackages = lib.mkDefault true; fonts.enableDefaultPackages = lib.mkDefault true;
hardware.opengl.enable = lib.mkDefault true; hardware.opengl.enable = lib.mkDefault true;
# Assume it is run inside a clan context
clan.virtualisation.waypipe = lib.mkDefault true;
# User account # User account
services.getty.autologinUser = lib.mkDefault config.clan.services.waypipe.user; services.getty.autologinUser = lib.mkDefault config.clan.services.waypipe.user;

View File

@@ -34,7 +34,7 @@ class MainApplication(Adw.Application):
def __init__(self, *args: Any, **kwargs: Any) -> None: def __init__(self, *args: Any, **kwargs: Any) -> None:
super().__init__( super().__init__(
application_id="org.clan.clan-app", application_id="org.clan.app",
flags=Gio.ApplicationFlags.HANDLES_COMMAND_LINE, flags=Gio.ApplicationFlags.HANDLES_COMMAND_LINE,
) )

View File

@@ -4,11 +4,10 @@ import logging
from typing import Any from typing import Any
import gi import gi
from clan_cli.api import MethodRegistry from clan_cli.api import MethodRegistry, dataclass_to_dict, from_dict
from clan_app.api import GObjApi, GResult, ImplFunc from clan_app.api import GObjApi, GResult, ImplFunc
from clan_app.api.file import open_file from clan_app.api.file import open_file
from clan_app.components.serializer import dataclass_to_dict, from_dict
gi.require_version("WebKit", "6.0") gi.require_version("WebKit", "6.0")
from gi.repository import GLib, GObject, WebKit from gi.repository import GLib, GObject, WebKit

View File

@@ -4,7 +4,7 @@
setuptools, setuptools,
copyDesktopItems, copyDesktopItems,
pygobject3, pygobject3,
wrapGAppsHook, wrapGAppsHook4,
gtk4, gtk4,
adwaita-icon-theme, adwaita-icon-theme,
pygobject-stubs, pygobject-stubs,
@@ -84,7 +84,7 @@ python3.pkgs.buildPythonApplication rec {
nativeBuildInputs = [ nativeBuildInputs = [
setuptools setuptools
copyDesktopItems copyDesktopItems
wrapGAppsHook wrapGAppsHook4
gobject-introspection gobject-introspection
]; ];

View File

@@ -1,5 +1,7 @@
{ {
lib, lib,
glib,
gsettings-desktop-schemas,
stdenv, stdenv,
clan-app, clan-app,
mkShell, mkShell,
@@ -33,7 +35,9 @@ mkShell {
buildInputs = buildInputs =
[ [
glib
ruff ruff
gtk4
gtk4.dev # has the demo called 'gtk4-widget-factory' gtk4.dev # has the demo called 'gtk4-widget-factory'
libadwaita.devdoc # has the demo called 'adwaita-1-demo' libadwaita.devdoc # has the demo called 'adwaita-1-demo'
] ]
@@ -60,6 +64,9 @@ mkShell {
# Add clan-cli to the python path so that we can import it without building it in nix first # Add clan-cli to the python path so that we can import it without building it in nix first
export PYTHONPATH="$GIT_ROOT/pkgs/clan-cli":"$PYTHONPATH" export PYTHONPATH="$GIT_ROOT/pkgs/clan-cli":"$PYTHONPATH"
export XDG_DATA_DIRS=${gtk4}/share/gsettings-schemas/gtk4-4.14.4:$XDG_DATA_DIRS
export XDG_DATA_DIRS=${gsettings-desktop-schemas}/share/gsettings-schemas/gsettings-desktop-schemas-46.0:$XDG_DATA_DIRS
# Add the webview-ui to the .webui directory # Add the webview-ui to the .webui directory
ln -nsf ${webview-ui}/lib/node_modules/@clan/webview-ui/dist/ ./clan_app/.webui ln -nsf ${webview-ui}/lib/node_modules/@clan/webview-ui/dist/ ./clan_app/.webui

View File

@@ -1,11 +1,141 @@
import dataclasses
import json
from collections.abc import Callable from collections.abc import Callable
from dataclasses import dataclass from dataclasses import dataclass, fields, is_dataclass
from functools import wraps from functools import wraps
from inspect import Parameter, Signature, signature from inspect import Parameter, Signature, signature
from typing import Annotated, Any, Generic, Literal, TypeVar, get_type_hints from pathlib import Path
from types import UnionType
from typing import (
Annotated,
Any,
Generic,
Literal,
TypeVar,
get_args,
get_origin,
get_type_hints,
)
from clan_cli.errors import ClanError from clan_cli.errors import ClanError
def sanitize_string(s: str) -> str:
# Using the native string sanitizer to handle all edge cases
# Remove the outer quotes '"string"'
return json.dumps(s)[1:-1]
def dataclass_to_dict(obj: Any) -> Any:
"""
Utility function to convert dataclasses to dictionaries
It converts all nested dataclasses, lists, tuples, and dictionaries to dictionaries
It does NOT convert member functions.
"""
if is_dataclass(obj):
return {
# Use either the original name or name
sanitize_string(
field.metadata.get("original_name", field.name)
): dataclass_to_dict(getattr(obj, field.name))
for field in fields(obj) # type: ignore
}
elif isinstance(obj, list | tuple):
return [dataclass_to_dict(item) for item in obj]
elif isinstance(obj, dict):
return {sanitize_string(k): dataclass_to_dict(v) for k, v in obj.items()}
elif isinstance(obj, Path):
return sanitize_string(str(obj))
elif isinstance(obj, str):
return sanitize_string(obj)
else:
return obj
def is_union_type(type_hint: type) -> bool:
return type(type_hint) is UnionType
def get_inner_type(type_hint: type) -> type:
if is_union_type(type_hint):
# Return the first non-None type
return next(t for t in get_args(type_hint) if t is not type(None))
return type_hint
def get_second_type(type_hint: type[dict]) -> type:
"""
Get the value type of a dictionary type hint
"""
args = get_args(type_hint)
if len(args) == 2:
# Return the second argument, which should be the value type (Machine)
return args[1]
raise ValueError(f"Invalid type hint for dict: {type_hint}")
def from_dict(t: type, data: dict[str, Any] | None) -> Any:
"""
Dynamically instantiate a data class from a dictionary, handling nested data classes.
"""
if data is None:
return None
try:
# Attempt to create an instance of the data_class
field_values = {}
for field in fields(t):
original_name = field.metadata.get("original_name", field.name)
field_value = data.get(original_name)
field_type = get_inner_type(field.type) # type: ignore
if original_name in data:
# If the field is another dataclass, recursively instantiate it
if is_dataclass(field_type):
field_value = from_dict(field_type, field_value)
elif isinstance(field_type, Path | str) and isinstance(
field_value, str
):
field_value = (
Path(field_value) if field_type == Path else field_value
)
elif get_origin(field_type) is dict and isinstance(field_value, dict):
# The field is a dictionary with a specific type
inner_type = get_second_type(field_type)
field_value = {
k: from_dict(inner_type, v) for k, v in field_value.items()
}
elif get_origin is list and isinstance(field_value, list):
# The field is a list with a specific type
inner_type = get_args(field_type)[0]
field_value = [from_dict(inner_type, v) for v in field_value]
# Set the value
if (
field.default is not dataclasses.MISSING
or field.default_factory is not dataclasses.MISSING
):
# Fields with default value
# a: Int = 1
# b: list = Field(default_factory=list)
if original_name in data or field_value is not None:
field_values[field.name] = field_value
else:
# Fields without default value
# a: Int
field_values[field.name] = field_value
return t(**field_values)
except (TypeError, ValueError) as e:
print(f"Failed to instantiate {t.__name__}: {e} {data}")
return None
T = TypeVar("T") T = TypeVar("T")
ResponseDataType = TypeVar("ResponseDataType") ResponseDataType = TypeVar("ResponseDataType")

View File

@@ -6,7 +6,7 @@ from pathlib import Path
from clan_cli.cmd import run_no_stdout from clan_cli.cmd import run_no_stdout
from clan_cli.errors import ClanCmdError, ClanError from clan_cli.errors import ClanCmdError, ClanError
from clan_cli.inventory import Inventory, load_inventory from clan_cli.inventory import Inventory, load_inventory_json
from clan_cli.nix import nix_eval from clan_cli.nix import nix_eval
from . import API from . import API
@@ -152,4 +152,4 @@ def get_module_info(
@API.register @API.register
def get_inventory(base_path: str) -> Inventory: def get_inventory(base_path: str) -> Inventory:
return load_inventory(base_path) return load_inventory_json(base_path)

View File

@@ -1,12 +1,11 @@
# !/usr/bin/env python3 # !/usr/bin/env python3
import argparse import argparse
import os import os
from dataclasses import dataclass, fields from dataclasses import dataclass
from pathlib import Path from pathlib import Path
from clan_cli.api import API from clan_cli.api import API
from clan_cli.arg_actions import AppendOptionAction from clan_cli.inventory import Inventory, init_inventory
from clan_cli.inventory import Meta, load_inventory, save_inventory
from ..cmd import CmdOut, run from ..cmd import CmdOut, run
from ..dirs import clan_templates from ..dirs import clan_templates
@@ -27,13 +26,10 @@ class CreateClanResponse:
@dataclass @dataclass
class CreateOptions: class CreateOptions:
directory: Path | str directory: Path | str
# Metadata for the clan
# Metadata can be shown with `clan show`
meta: Meta | None = None
# URL to the template to use. Defaults to the "minimal" template # URL to the template to use. Defaults to the "minimal" template
template: str = "minimal" template: str = "minimal"
setup_json_inventory: bool = True
setup_git: bool = True setup_git: bool = True
initial: Inventory | None = None
def git_command(directory: Path, *args: str) -> list[str]: def git_command(directory: Path, *args: str) -> list[str]:
@@ -67,10 +63,14 @@ def create_clan(options: CreateOptions) -> CreateClanResponse:
] ]
) )
flake_init = run(command, cwd=directory) flake_init = run(command, cwd=directory)
flake_update = run( flake_update = run(
nix_shell(["nixpkgs#nix"], ["nix", "flake", "update"]), cwd=directory nix_shell(["nixpkgs#nix"], ["nix", "flake", "update"]), cwd=directory
) )
if options.initial:
init_inventory(options.directory, init=options.initial)
response = CreateClanResponse( response = CreateClanResponse(
flake_init=flake_init, flake_init=flake_init,
flake_update=flake_update, flake_update=flake_update,
@@ -95,14 +95,6 @@ def create_clan(options: CreateOptions) -> CreateClanResponse:
git_command(directory, "config", "user.email", "clan@example.com") git_command(directory, "config", "user.email", "clan@example.com")
) )
# Write inventory.json file
if options.setup_json_inventory:
inventory = load_inventory(directory)
if options.meta is not None:
inventory.meta = options.meta
# Persist creates a commit message for each change
save_inventory(inventory, directory, "Init inventory")
return response return response
@@ -115,14 +107,6 @@ def register_create_parser(parser: argparse.ArgumentParser) -> None:
default="default", default="default",
) )
parser.add_argument(
"--meta",
help=f"""Metadata to set for the clan. Available options are: {", ".join([f.name for f in fields(Meta)]) }""",
nargs=2,
metavar=("name", "value"),
action=AppendOptionAction,
default=[],
)
parser.add_argument( parser.add_argument(
"--no-git", "--no-git",
help="Do not setup git", help="Do not setup git",
@@ -139,7 +123,6 @@ def register_create_parser(parser: argparse.ArgumentParser) -> None:
CreateOptions( CreateOptions(
directory=args.path, directory=args.path,
template=args.template, template=args.template,
setup_json_inventory=False,
setup_git=not args.no_git, setup_git=not args.no_git,
) )
) )

View File

@@ -1,7 +1,7 @@
from dataclasses import dataclass from dataclasses import dataclass
from clan_cli.api import API from clan_cli.api import API
from clan_cli.inventory import Meta, load_inventory, save_inventory from clan_cli.inventory import Meta, load_inventory_json, save_inventory
@dataclass @dataclass
@@ -12,7 +12,7 @@ class UpdateOptions:
@API.register @API.register
def update_clan_meta(options: UpdateOptions) -> Meta: def update_clan_meta(options: UpdateOptions) -> Meta:
inventory = load_inventory(options.directory) inventory = load_inventory_json(options.directory)
inventory.meta = options.meta inventory.meta = options.meta
save_inventory(inventory, options.directory, "Update clan metadata") save_inventory(inventory, options.directory, "Update clan metadata")

View File

@@ -9,32 +9,49 @@ from .errors import ClanError
@dataclass @dataclass
class FlakeId: class FlakeId:
# FIXME: this is such a footgun if you accidnetally pass a string loc: str | Path
_value: str | Path
def __post_init__(self) -> None: def __post_init__(self) -> None:
assert isinstance( assert isinstance(
self._value, str | Path self.loc, str | Path
), f"Flake {self._value} has an invalid type: {type(self._value)}" ), f"Flake {self.loc} has an invalid format: {type(self.loc)}"
def __str__(self) -> str: def __str__(self) -> str:
return str(self._value) return str(self.loc)
@property @property
def path(self) -> Path: def path(self) -> Path:
assert isinstance(self._value, Path), f"Flake {self._value} is not a local path" assert self.is_local(), f"Flake {self.loc} is not a local path"
return self._value return Path(self.loc)
@property @property
def url(self) -> str: def url(self) -> str:
assert isinstance(self._value, str), f"Flake {self._value} is not a remote url" assert self.is_remote(), f"Flake {self.loc} is not a remote url"
return self._value return str(self.loc)
def is_local(self) -> bool: def is_local(self) -> bool:
return isinstance(self._value, Path) """
https://nix.dev/manual/nix/2.22/language/builtins.html?highlight=urlS#source-types
Examples:
- file:///home/eelco/nix/README.md file LOCAL
- git+file://git:github.com:NixOS/nixpkgs git+file LOCAL
- https://example.com/index.html https REMOTE
- github:nixos/nixpkgs github REMOTE
- ftp://serv.file ftp REMOTE
- ./. '' LOCAL
"""
x = urllib.parse.urlparse(str(self.loc))
if x.scheme == "" or "file" in x.scheme:
# See above *file* or empty are the only local schemas
return True
return False
def is_remote(self) -> bool: def is_remote(self) -> bool:
return isinstance(self._value, str) return not self.is_local()
# Define the ClanURI class # Define the ClanURI class

View File

@@ -48,19 +48,22 @@ class SecretStore(SecretStoreBase):
def get(self, service: str, name: str) -> bytes: def get(self, service: str, name: str) -> bytes:
return decrypt_secret( return decrypt_secret(
self.machine.flake_dir, f"{self.machine.name}-{name}" self.machine.flake_dir,
sops_secrets_folder(self.machine.flake_dir) / f"{self.machine.name}-{name}",
).encode("utf-8") ).encode("utf-8")
def exists(self, service: str, name: str) -> bool: def exists(self, service: str, name: str) -> bool:
return has_secret( return has_secret(
self.machine.flake_dir, sops_secrets_folder(self.machine.flake_dir) / f"{self.machine.name}-{name}",
f"{self.machine.name}-{name}",
) )
def upload(self, output_dir: Path) -> None: def upload(self, output_dir: Path) -> None:
key_name = f"{self.machine.name}-age.key" key_name = f"{self.machine.name}-age.key"
if not has_secret(self.machine.flake_dir, key_name): if not has_secret(sops_secrets_folder(self.machine.flake_dir) / key_name):
# skip uploading the secret, not managed by us # skip uploading the secret, not managed by us
return return
key = decrypt_secret(self.machine.flake_dir, key_name) key = decrypt_secret(
self.machine.flake_dir,
sops_secrets_folder(self.machine.flake_dir) / key_name,
)
(output_dir / "key.txt").write_text(key) (output_dir / "key.txt").write_text(key)

View File

@@ -62,7 +62,7 @@ def _commit_file_to_git(
for file_path in file_paths: for file_path in file_paths:
cmd = run_cmd( cmd = run_cmd(
["git"], ["git"],
["git", "-C", str(repo_dir), "add", str(file_path)], ["git", "-C", str(repo_dir), "add", "--", str(file_path)],
) )
# add the file to the git index # add the file to the git index
@@ -75,7 +75,7 @@ def _commit_file_to_git(
# check if there is a diff # check if there is a diff
cmd = run_cmd( cmd = run_cmd(
["git"], ["git"],
["git", "-C", str(repo_dir), "diff", "--cached", "--exit-code"] ["git", "-C", str(repo_dir), "diff", "--cached", "--exit-code", "--"]
+ [str(file_path) for file_path in file_paths], + [str(file_path) for file_path in file_paths],
) )
result = run(cmd, check=False, cwd=repo_dir) result = run(cmd, check=False, cwd=repo_dir)
@@ -93,6 +93,7 @@ def _commit_file_to_git(
"commit", "commit",
"-m", "-m",
commit_message, commit_message,
"--no-verify", # dont run pre-commit hooks
] ]
+ [str(file_path) for file_path in file_paths], + [str(file_path) for file_path in file_paths],
) )

View File

@@ -1,13 +1,26 @@
import dataclasses """
import json All read/write operations MUST use the inventory.
from dataclasses import fields, is_dataclass
from pathlib import Path
from types import UnionType
from typing import Any, get_args, get_origin
from clan_cli.errors import ClanError Machine data, clan data or service data can be accessed in a performant way.
This file exports stable classnames for static & dynamic type safety.
Utilize:
- load_inventory_eval: To load the actual inventory with nix declarations merged.
Operate on the returned inventory to make changes
- save_inventory: To persist changes.
"""
import json
from pathlib import Path
from clan_cli.api import API, dataclass_to_dict, from_dict
from clan_cli.errors import ClanCmdError, ClanError
from clan_cli.git import commit_file from clan_cli.git import commit_file
from ..cmd import run_no_stdout
from ..nix import nix_eval
from .classes import ( from .classes import (
Inventory, Inventory,
Machine, Machine,
@@ -24,6 +37,8 @@ from .classes import (
# Re export classes here # Re export classes here
# This allows to rename classes in the generated code # This allows to rename classes in the generated code
__all__ = [ __all__ = [
"from_dict",
"dataclass_to_dict",
"Service", "Service",
"Machine", "Machine",
"Meta", "Meta",
@@ -37,121 +52,6 @@ __all__ = [
] ]
def sanitize_string(s: str) -> str:
return s.replace("\\", "\\\\").replace('"', '\\"').replace("\n", "\\n")
def dataclass_to_dict(obj: Any) -> Any:
"""
Utility function to convert dataclasses to dictionaries
It converts all nested dataclasses, lists, tuples, and dictionaries to dictionaries
It does NOT convert member functions.
"""
if is_dataclass(obj):
return {
# Use either the original name or name
sanitize_string(
field.metadata.get("original_name", field.name)
): dataclass_to_dict(getattr(obj, field.name))
for field in fields(obj) # type: ignore
}
elif isinstance(obj, list | tuple):
return [dataclass_to_dict(item) for item in obj]
elif isinstance(obj, dict):
return {sanitize_string(k): dataclass_to_dict(v) for k, v in obj.items()}
elif isinstance(obj, Path):
return str(obj)
elif isinstance(obj, str):
return sanitize_string(obj)
else:
return obj
def is_union_type(type_hint: type) -> bool:
return type(type_hint) is UnionType
def get_inner_type(type_hint: type) -> type:
if is_union_type(type_hint):
# Return the first non-None type
return next(t for t in get_args(type_hint) if t is not type(None))
return type_hint
def get_second_type(type_hint: type[dict]) -> type:
"""
Get the value type of a dictionary type hint
"""
args = get_args(type_hint)
if len(args) == 2:
# Return the second argument, which should be the value type (Machine)
return args[1]
raise ValueError(f"Invalid type hint for dict: {type_hint}")
def from_dict(t: type, data: dict[str, Any] | None) -> Any:
"""
Dynamically instantiate a data class from a dictionary, handling nested data classes.
"""
if data is None:
return None
try:
# Attempt to create an instance of the data_class
field_values = {}
for field in fields(t):
original_name = field.metadata.get("original_name", field.name)
field_value = data.get(original_name)
field_type = get_inner_type(field.type) # type: ignore
if original_name in data:
# If the field is another dataclass, recursively instantiate it
if is_dataclass(field_type):
field_value = from_dict(field_type, field_value)
elif isinstance(field_type, Path | str) and isinstance(
field_value, str
):
field_value = (
Path(field_value) if field_type == Path else field_value
)
elif get_origin(field_type) is dict and isinstance(field_value, dict):
# The field is a dictionary with a specific type
inner_type = get_second_type(field_type)
field_value = {
k: from_dict(inner_type, v) for k, v in field_value.items()
}
elif get_origin is list and isinstance(field_value, list):
# The field is a list with a specific type
inner_type = get_args(field_type)[0]
field_value = [from_dict(inner_type, v) for v in field_value]
# Set the value
if (
field.default is not dataclasses.MISSING
or field.default_factory is not dataclasses.MISSING
):
# Fields with default value
# a: Int = 1
# b: list = Field(default_factory=list)
if original_name in data or field_value is not None:
field_values[field.name] = field_value
else:
# Fields without default value
# a: Int
field_values[field.name] = field_value
return t(**field_values)
except (TypeError, ValueError) as e:
print(f"Failed to instantiate {t.__name__}: {e} {data}")
return None
# raise ClanError(f"Failed to instantiate {t.__name__}: {e}")
def get_path(flake_dir: str | Path) -> Path: def get_path(flake_dir: str | Path) -> Path:
""" """
Get the path to the inventory file in the flake directory Get the path to the inventory file in the flake directory
@@ -165,14 +65,42 @@ default_inventory = Inventory(
) )
def load_inventory( def load_inventory_eval(flake_dir: str | Path) -> Inventory:
"""
Loads the actual inventory.
After all merge operations with eventual nix code in buildClan.
Evaluates clanInternals.inventory with nix. Which is performant.
- Contains all clan metadata
- Contains all machines
- and more
"""
cmd = nix_eval(
[
f"{flake_dir}#clanInternals.inventory",
"--json",
]
)
proc = run_no_stdout(cmd)
try:
res = proc.stdout.strip()
data = json.loads(res)
inventory = from_dict(Inventory, data)
return inventory
except json.JSONDecodeError as e:
raise ClanError(f"Error decoding inventory from flake: {e}")
def load_inventory_json(
flake_dir: str | Path, default: Inventory = default_inventory flake_dir: str | Path, default: Inventory = default_inventory
) -> Inventory: ) -> Inventory:
""" """
Load the inventory file from the flake directory Load the inventory file from the flake directory
If not file is found, returns the default inventory If not file is found, returns the default inventory
""" """
inventory = default_inventory inventory = default
inventory_file = get_path(flake_dir) inventory_file = get_path(flake_dir)
if inventory_file.exists(): if inventory_file.exists():
@@ -184,6 +112,10 @@ def load_inventory(
# Error decoding the inventory file # Error decoding the inventory file
raise ClanError(f"Error decoding inventory file: {e}") raise ClanError(f"Error decoding inventory file: {e}")
if not inventory_file.exists():
# Copy over the meta from the flake if the inventory is not initialized
inventory.meta = load_inventory_eval(flake_dir).meta
return inventory return inventory
@@ -198,3 +130,22 @@ def save_inventory(inventory: Inventory, flake_dir: str | Path, message: str) ->
json.dump(dataclass_to_dict(inventory), f, indent=2) json.dump(dataclass_to_dict(inventory), f, indent=2)
commit_file(inventory_file, Path(flake_dir), commit_message=message) commit_file(inventory_file, Path(flake_dir), commit_message=message)
@API.register
def init_inventory(directory: str, init: Inventory | None = None) -> None:
inventory = None
# Try reading the current flake
if init is None:
try:
inventory = load_inventory_eval(directory)
except ClanCmdError:
pass
if init is not None:
inventory = init
# Write inventory.json file
if inventory is not None:
# Persist creates a commit message for each change
save_inventory(inventory, directory, "Init inventory")

View File

@@ -1,13 +1,17 @@
import argparse import argparse
import logging import logging
import re import re
from pathlib import Path
from ..api import API from ..api import API
from ..clan_uri import FlakeId from ..clan_uri import FlakeId
from ..errors import ClanError from ..errors import ClanError
from ..git import commit_file from ..inventory import (
from ..inventory import Machine, MachineDeploy, get_path, load_inventory, save_inventory Machine,
MachineDeploy,
load_inventory_eval,
load_inventory_json,
save_inventory,
)
log = logging.getLogger(__name__) log = logging.getLogger(__name__)
@@ -20,12 +24,16 @@ def create_machine(flake: FlakeId, machine: Machine) -> None:
"Machine name must be a valid hostname", location="Create Machine" "Machine name must be a valid hostname", location="Create Machine"
) )
inventory = load_inventory(flake.path) inventory = load_inventory_json(flake.path)
full_inventory = load_inventory_eval(flake.path)
if machine.name in full_inventory.machines.keys():
raise ClanError(f"Machine with the name {machine.name} already exists")
inventory.machines.update({machine.name: machine}) inventory.machines.update({machine.name: machine})
save_inventory(inventory, flake.path, f"Create machine {machine.name}") save_inventory(inventory, flake.path, f"Create machine {machine.name}")
commit_file(get_path(flake.path), Path(flake.path))
def create_command(args: argparse.Namespace) -> None: def create_command(args: argparse.Namespace) -> None:
create_machine( create_machine(

View File

@@ -6,12 +6,12 @@ from ..clan_uri import FlakeId
from ..completions import add_dynamic_completer, complete_machines from ..completions import add_dynamic_completer, complete_machines
from ..dirs import specific_machine_dir from ..dirs import specific_machine_dir
from ..errors import ClanError from ..errors import ClanError
from ..inventory import load_inventory, save_inventory from ..inventory import load_inventory_json, save_inventory
@API.register @API.register
def delete_machine(flake: FlakeId, name: str) -> None: def delete_machine(flake: FlakeId, name: str) -> None:
inventory = load_inventory(flake.path) inventory = load_inventory_json(flake.path)
machine = inventory.machines.pop(name, None) machine = inventory.machines.pop(name, None)
if machine is None: if machine is None:

View File

@@ -1,31 +1,17 @@
import argparse import argparse
import json
import logging import logging
from pathlib import Path from pathlib import Path
from clan_cli.api import API from clan_cli.api import API
from clan_cli.inventory import Machine, from_dict from clan_cli.inventory import Machine, load_inventory_eval
from ..cmd import run_no_stdout
from ..nix import nix_eval
log = logging.getLogger(__name__) log = logging.getLogger(__name__)
@API.register @API.register
def list_machines(flake_url: str | Path, debug: bool = False) -> dict[str, Machine]: def list_machines(flake_url: str | Path, debug: bool = False) -> dict[str, Machine]:
cmd = nix_eval( inventory = load_inventory_eval(flake_url)
[ return inventory.machines
f"{flake_url}#clanInternals.inventory.machines",
"--json",
]
)
proc = run_no_stdout(cmd)
res = proc.stdout.strip()
data = {name: from_dict(Machine, v) for name, v in json.loads(res).items()}
return data
def list_command(args: argparse.Namespace) -> None: def list_command(args: argparse.Namespace) -> None:

View File

@@ -49,27 +49,27 @@ class Group:
def list_groups(flake_dir: Path) -> list[Group]: def list_groups(flake_dir: Path) -> list[Group]:
groups: list[Group] = [] groups: list[Group] = []
folder = sops_groups_folder(flake_dir) groups_dir = sops_groups_folder(flake_dir)
if not folder.exists(): if not groups_dir.exists():
return groups return groups
for name in os.listdir(folder): for group in os.listdir(groups_dir):
group_folder = folder / name group_folder = groups_dir / group
if not group_folder.is_dir(): if not group_folder.is_dir():
continue continue
machines_path = machines_folder(flake_dir, name) machines_path = machines_folder(flake_dir, group)
machines = [] machines = []
if machines_path.is_dir(): if machines_path.is_dir():
for f in machines_path.iterdir(): for f in machines_path.iterdir():
if validate_hostname(f.name): if validate_hostname(f.name):
machines.append(f.name) machines.append(f.name)
users_path = users_folder(flake_dir, name) users_path = users_folder(flake_dir, group)
users = [] users = []
if users_path.is_dir(): if users_path.is_dir():
for f in users_path.iterdir(): for f in users_path.iterdir():
if VALID_USER_NAME.match(f.name): if VALID_USER_NAME.match(f.name):
users.append(f.name) users.append(f.name)
groups.append(Group(flake_dir, name, machines, users)) groups.append(Group(flake_dir, group, machines, users))
return groups return groups
@@ -204,7 +204,9 @@ def add_group_argument(parser: argparse.ArgumentParser) -> None:
def add_secret(flake_dir: Path, group: str, name: str) -> None: def add_secret(flake_dir: Path, group: str, name: str) -> None:
secrets.allow_member( secrets.allow_member(
secrets.groups_folder(flake_dir, name), sops_groups_folder(flake_dir), group secrets.groups_folder(sops_secrets_folder(flake_dir) / name),
sops_groups_folder(flake_dir),
group,
) )
@@ -214,7 +216,7 @@ def add_secret_command(args: argparse.Namespace) -> None:
def remove_secret(flake_dir: Path, group: str, name: str) -> None: def remove_secret(flake_dir: Path, group: str, name: str) -> None:
updated_paths = secrets.disallow_member( updated_paths = secrets.disallow_member(
secrets.groups_folder(flake_dir, name), group secrets.groups_folder(sops_secrets_folder(flake_dir) / name), group
) )
commit_files( commit_files(
updated_paths, updated_paths,

View File

@@ -6,7 +6,12 @@ from ..errors import ClanError
from ..git import commit_files from ..git import commit_files
from ..machines.types import machine_name_type, validate_hostname from ..machines.types import machine_name_type, validate_hostname
from . import secrets from . import secrets
from .folders import list_objects, remove_object, sops_machines_folder from .folders import (
list_objects,
remove_object,
sops_machines_folder,
sops_secrets_folder,
)
from .secrets import update_secrets from .secrets import update_secrets
from .sops import read_key, write_key from .sops import read_key, write_key
from .types import public_or_private_age_key_type, secret_name_type from .types import public_or_private_age_key_type, secret_name_type
@@ -56,7 +61,7 @@ def list_machines(flake_dir: Path) -> list[str]:
def add_secret(flake_dir: Path, machine: str, secret: str) -> None: def add_secret(flake_dir: Path, machine: str, secret: str) -> None:
paths = secrets.allow_member( paths = secrets.allow_member(
secrets.machines_folder(flake_dir, secret), secrets.machines_folder(sops_secrets_folder(flake_dir) / secret),
sops_machines_folder(flake_dir), sops_machines_folder(flake_dir),
machine, machine,
) )
@@ -69,7 +74,7 @@ def add_secret(flake_dir: Path, machine: str, secret: str) -> None:
def remove_secret(flake_dir: Path, machine: str, secret: str) -> None: def remove_secret(flake_dir: Path, machine: str, secret: str) -> None:
updated_paths = secrets.disallow_member( updated_paths = secrets.disallow_member(
secrets.machines_folder(flake_dir, secret), machine secrets.machines_folder(sops_secrets_folder(flake_dir) / secret), machine
) )
commit_files( commit_files(
updated_paths, updated_paths,

View File

@@ -82,7 +82,7 @@ def collect_keys_for_path(path: Path) -> set[str]:
def encrypt_secret( def encrypt_secret(
flake_dir: Path, flake_dir: Path,
secret: Path, secret_path: Path,
value: IO[str] | str | bytes | None, value: IO[str] | str | bytes | None,
add_users: list[str] = [], add_users: list[str] = [],
add_machines: list[str] = [], add_machines: list[str] = [],
@@ -95,7 +95,7 @@ def encrypt_secret(
for user in add_users: for user in add_users:
files_to_commit.extend( files_to_commit.extend(
allow_member( allow_member(
users_folder(flake_dir, secret.name), users_folder(secret_path),
sops_users_folder(flake_dir), sops_users_folder(flake_dir),
user, user,
False, False,
@@ -105,7 +105,7 @@ def encrypt_secret(
for machine in add_machines: for machine in add_machines:
files_to_commit.extend( files_to_commit.extend(
allow_member( allow_member(
machines_folder(flake_dir, secret.name), machines_folder(secret_path),
sops_machines_folder(flake_dir), sops_machines_folder(flake_dir),
machine, machine,
False, False,
@@ -115,33 +115,33 @@ def encrypt_secret(
for group in add_groups: for group in add_groups:
files_to_commit.extend( files_to_commit.extend(
allow_member( allow_member(
groups_folder(flake_dir, secret.name), groups_folder(secret_path),
sops_groups_folder(flake_dir), sops_groups_folder(flake_dir),
group, group,
False, False,
) )
) )
keys = collect_keys_for_path(secret) keys = collect_keys_for_path(secret_path)
if key.pubkey not in keys: if key.pubkey not in keys:
keys.add(key.pubkey) keys.add(key.pubkey)
files_to_commit.extend( files_to_commit.extend(
allow_member( allow_member(
users_folder(flake_dir, secret.name), users_folder(secret_path),
sops_users_folder(flake_dir), sops_users_folder(flake_dir),
key.username, key.username,
False, False,
) )
) )
secret_path = secret / "secret" secret_path = secret_path / "secret"
encrypt_file(secret_path, value, list(sorted(keys))) encrypt_file(secret_path, value, list(sorted(keys)))
files_to_commit.append(secret_path) files_to_commit.append(secret_path)
commit_files( commit_files(
files_to_commit, files_to_commit,
flake_dir, flake_dir,
f"Update secret {secret.name}", f"Update secret {secret_path.name}",
) )
@@ -169,16 +169,16 @@ def add_secret_argument(parser: argparse.ArgumentParser, autocomplete: bool) ->
add_dynamic_completer(secrets_parser, complete_secrets) add_dynamic_completer(secrets_parser, complete_secrets)
def machines_folder(flake_dir: Path, group: str) -> Path: def machines_folder(secret_path: Path) -> Path:
return sops_secrets_folder(flake_dir) / group / "machines" return secret_path / "machines"
def users_folder(flake_dir: Path, group: str) -> Path: def users_folder(secret_path: Path) -> Path:
return sops_secrets_folder(flake_dir) / group / "users" return secret_path / "users"
def groups_folder(flake_dir: Path, group: str) -> Path: def groups_folder(secret_path: Path) -> Path:
return sops_secrets_folder(flake_dir) / group / "groups" return secret_path / "groups"
def list_directory(directory: Path) -> str: def list_directory(directory: Path) -> str:
@@ -245,8 +245,8 @@ def disallow_member(group_folder: Path, name: str) -> list[Path]:
) )
def has_secret(flake_dir: Path, secret: str) -> bool: def has_secret(secret_path: Path) -> bool:
return (sops_secrets_folder(flake_dir) / secret / "secret").exists() return (secret_path / "secret").exists()
def list_secrets(flake_dir: Path, pattern: str | None = None) -> list[str]: def list_secrets(flake_dir: Path, pattern: str | None = None) -> list[str]:
@@ -255,7 +255,7 @@ def list_secrets(flake_dir: Path, pattern: str | None = None) -> list[str]:
def validate(name: str) -> bool: def validate(name: str) -> bool:
return ( return (
VALID_SECRET_NAME.match(name) is not None VALID_SECRET_NAME.match(name) is not None
and has_secret(flake_dir, name) and has_secret(sops_secrets_folder(flake_dir) / name)
and (pattern is None or pattern in name) and (pattern is None or pattern in name)
) )
@@ -278,16 +278,21 @@ def list_command(args: argparse.Namespace) -> None:
print("\n".join(lst)) print("\n".join(lst))
def decrypt_secret(flake_dir: Path, secret: str) -> str: def decrypt_secret(flake_dir: Path, secret_path: Path) -> str:
ensure_sops_key(flake_dir) ensure_sops_key(flake_dir)
secret_path = sops_secrets_folder(flake_dir) / secret / "secret" path = secret_path / "secret"
if not secret_path.exists(): if not path.exists():
raise ClanError(f"Secret '{secret}' does not exist") raise ClanError(f"Secret '{secret_path!s}' does not exist")
return decrypt_file(secret_path) return decrypt_file(path)
def get_command(args: argparse.Namespace) -> None: def get_command(args: argparse.Namespace) -> None:
print(decrypt_secret(args.flake.path, args.secret), end="") print(
decrypt_secret(
args.flake.path, sops_secrets_folder(args.flake.path) / args.secret
),
end="",
)
def set_command(args: argparse.Namespace) -> None: def set_command(args: argparse.Namespace) -> None:

View File

@@ -9,7 +9,7 @@ from ..completions import (
from ..errors import ClanError from ..errors import ClanError
from ..git import commit_files from ..git import commit_files
from . import secrets from . import secrets
from .folders import list_objects, remove_object, sops_users_folder from .folders import list_objects, remove_object, sops_secrets_folder, sops_users_folder
from .secrets import update_secrets from .secrets import update_secrets
from .sops import read_key, write_key from .sops import read_key, write_key
from .types import ( from .types import (
@@ -63,7 +63,9 @@ def list_users(flake_dir: Path) -> list[str]:
def add_secret(flake_dir: Path, user: str, secret: str) -> None: def add_secret(flake_dir: Path, user: str, secret: str) -> None:
updated_paths = secrets.allow_member( updated_paths = secrets.allow_member(
secrets.users_folder(flake_dir, secret), sops_users_folder(flake_dir), user secrets.users_folder(sops_secrets_folder(flake_dir) / secret),
sops_users_folder(flake_dir),
user,
) )
commit_files( commit_files(
updated_paths, updated_paths,
@@ -74,7 +76,7 @@ def add_secret(flake_dir: Path, user: str, secret: str) -> None:
def remove_secret(flake_dir: Path, user: str, secret: str) -> None: def remove_secret(flake_dir: Path, user: str, secret: str) -> None:
updated_paths = secrets.disallow_member( updated_paths = secrets.disallow_member(
secrets.users_folder(flake_dir, secret), user secrets.users_folder(sops_secrets_folder(flake_dir) / secret), user
) )
commit_files( commit_files(
updated_paths, updated_paths,

View File

@@ -2,9 +2,8 @@ import argparse
import importlib import importlib
import logging import logging
import os import os
import subprocess
import sys import sys
from collections.abc import Callable from getpass import getpass
from graphlib import TopologicalSorter from graphlib import TopologicalSorter
from pathlib import Path from pathlib import Path
from tempfile import TemporaryDirectory from tempfile import TemporaryDirectory
@@ -29,17 +28,7 @@ from .secret_modules import SecretStoreBase
log = logging.getLogger(__name__) log = logging.getLogger(__name__)
def read_multiline_input(prompt: str = "Finish with Ctrl-D") -> str: def bubblewrap_cmd(generator: str, tmpdir: Path) -> list[str]:
"""
Read multi-line input from stdin.
"""
print(prompt, flush=True)
proc = subprocess.run(["cat"], stdout=subprocess.PIPE, text=True)
log.info("Input received. Processing...")
return proc.stdout
def bubblewrap_cmd(generator: str, generator_dir: Path, dep_tmpdir: Path) -> list[str]:
# fmt: off # fmt: off
return nix_shell( return nix_shell(
[ [
@@ -51,8 +40,7 @@ def bubblewrap_cmd(generator: str, generator_dir: Path, dep_tmpdir: Path) -> lis
"--ro-bind", "/nix/store", "/nix/store", "--ro-bind", "/nix/store", "/nix/store",
"--tmpfs", "/usr/lib/systemd", "--tmpfs", "/usr/lib/systemd",
"--dev", "/dev", "--dev", "/dev",
"--bind", str(generator_dir), str(generator_dir), "--bind", str(tmpdir), str(tmpdir),
"--ro-bind", str(dep_tmpdir), str(dep_tmpdir),
"--unshare-all", "--unshare-all",
"--unshare-user", "--unshare-user",
"--uid", "1000", "--uid", "1000",
@@ -92,7 +80,7 @@ def decrypt_dependencies(
def dependencies_as_dir( def dependencies_as_dir(
decrypted_dependencies: dict[str, dict[str, bytes]], decrypted_dependencies: dict[str, dict[str, bytes]],
tmpdir: Path, tmpdir: Path,
) -> Path: ) -> None:
for dep_generator, files in decrypted_dependencies.items(): for dep_generator, files in decrypted_dependencies.items():
dep_generator_dir = tmpdir / dep_generator dep_generator_dir = tmpdir / dep_generator
dep_generator_dir.mkdir() dep_generator_dir.mkdir()
@@ -102,7 +90,6 @@ def dependencies_as_dir(
file_path.touch() file_path.touch()
file_path.chmod(0o600) file_path.chmod(0o600)
file_path.write_bytes(file) file_path.write_bytes(file)
return tmpdir
def execute_generator( def execute_generator(
@@ -111,10 +98,7 @@ def execute_generator(
regenerate: bool, regenerate: bool,
secret_vars_store: SecretStoreBase, secret_vars_store: SecretStoreBase,
public_vars_store: FactStoreBase, public_vars_store: FactStoreBase,
dep_tmpdir: Path,
prompt: Callable[[str], str],
) -> bool: ) -> bool:
generator_dir = dep_tmpdir / generator_name
# check if all secrets exist and generate them if at least one is missing # check if all secrets exist and generate them if at least one is missing
needs_regeneration = not check_secrets(machine, generator_name=generator_name) needs_regeneration = not check_secrets(machine, generator_name=generator_name)
log.debug(f"{generator_name} needs_regeneration: {needs_regeneration}") log.debug(f"{generator_name} needs_regeneration: {needs_regeneration}")
@@ -124,51 +108,65 @@ def execute_generator(
msg = f"flake is not a Path: {machine.flake}" msg = f"flake is not a Path: {machine.flake}"
msg += "fact/secret generation is only supported for local flakes" msg += "fact/secret generation is only supported for local flakes"
# compatibility for old outputs.nix users
generator = machine.vars_generators[generator_name]["finalScript"] generator = machine.vars_generators[generator_name]["finalScript"]
# if machine.vars_data[generator_name]["generator"]["prompt"]:
# prompt_value = prompt(machine.vars_data[generator_name]["generator"]["prompt"])
# env["prompt_value"] = prompt_value
# build temporary file tree of dependencies # build temporary file tree of dependencies
decrypted_dependencies = decrypt_dependencies( decrypted_dependencies = decrypt_dependencies(
machine, generator_name, secret_vars_store, public_vars_store machine, generator_name, secret_vars_store, public_vars_store
) )
env = os.environ.copy() env = os.environ.copy()
generator_dir.mkdir(parents=True)
env["out"] = str(generator_dir)
with TemporaryDirectory() as tmp: with TemporaryDirectory() as tmp:
dep_tmpdir = dependencies_as_dir(decrypted_dependencies, Path(tmp)) tmpdir = Path(tmp)
env["in"] = str(dep_tmpdir) tmpdir_in = tmpdir / "in"
tmpdir_prompts = tmpdir / "prompts"
tmpdir_out = tmpdir / "out"
tmpdir_in.mkdir()
tmpdir_out.mkdir()
env["in"] = str(tmpdir_in)
env["out"] = str(tmpdir_out)
# populate dependency inputs
dependencies_as_dir(decrypted_dependencies, tmpdir_in)
# populate prompted values
# TODO: make prompts rest API friendly
if machine.vars_generators[generator_name]["prompts"]:
tmpdir_prompts.mkdir()
env["prompts"] = str(tmpdir_prompts)
for prompt_name, prompt in machine.vars_generators[generator_name][
"prompts"
].items():
prompt_file = tmpdir_prompts / prompt_name
value = prompt_func(prompt["description"], prompt["type"])
prompt_file.write_text(value)
if sys.platform == "linux": if sys.platform == "linux":
cmd = bubblewrap_cmd(generator, generator_dir, dep_tmpdir=dep_tmpdir) cmd = bubblewrap_cmd(generator, tmpdir)
else: else:
cmd = ["bash", "-c", generator] cmd = ["bash", "-c", generator]
run( run(
cmd, cmd,
env=env, env=env,
) )
files_to_commit = [] files_to_commit = []
# store secrets # store secrets
files = machine.vars_generators[generator_name]["files"] files = machine.vars_generators[generator_name]["files"]
for file_name, file in files.items(): for file_name, file in files.items():
groups = machine.deployment["sops"]["defaultGroups"] groups = machine.deployment["sops"]["defaultGroups"]
secret_file = generator_dir / file_name secret_file = tmpdir_out / file_name
if not secret_file.is_file(): if not secret_file.is_file():
msg = f"did not generate a file for '{file_name}' when running the following command:\n" msg = f"did not generate a file for '{file_name}' when running the following command:\n"
msg += generator msg += generator
raise ClanError(msg) raise ClanError(msg)
if file["secret"]: if file["secret"]:
file_path = secret_vars_store.set( file_path = secret_vars_store.set(
generator_name, file_name, secret_file.read_bytes(), groups generator_name, file_name, secret_file.read_bytes(), groups
) )
else: else:
file_path = public_vars_store.set( file_path = public_vars_store.set(
generator_name, file_name, secret_file.read_bytes() generator_name, file_name, secret_file.read_bytes()
) )
if file_path: if file_path:
files_to_commit.append(file_path) files_to_commit.append(file_path)
commit_files( commit_files(
files_to_commit, files_to_commit,
machine.flake_dir, machine.flake_dir,
@@ -177,9 +175,18 @@ def execute_generator(
return True return True
def prompt_func(text: str) -> str: def prompt_func(description: str, input_type: str) -> str:
print(f"{text}: ") if input_type == "line":
return read_multiline_input() result = input(f"Enter the value for {description}: ")
elif input_type == "multiline":
print(f"Enter the value for {description} (Finish with Ctrl-D): ")
result = sys.stdin.read()
elif input_type == "hidden":
result = getpass(f"Enter the value for {description} (hidden): ")
else:
raise ClanError(f"Unknown input type: {input_type} for prompt {description}")
log.info("Input received. Processing...")
return result
def _get_subgraph(graph: dict[str, set], vertex: str) -> dict[str, set]: def _get_subgraph(graph: dict[str, set], vertex: str) -> dict[str, set]:
@@ -197,11 +204,7 @@ def _generate_vars_for_machine(
machine: Machine, machine: Machine,
generator_name: str | None, generator_name: str | None,
regenerate: bool, regenerate: bool,
tmpdir: Path,
prompt: Callable[[str], str] = prompt_func,
) -> bool: ) -> bool:
local_temp = tmpdir / machine.name
local_temp.mkdir()
secret_vars_module = importlib.import_module(machine.secret_vars_module) secret_vars_module = importlib.import_module(machine.secret_vars_module)
secret_vars_store = secret_vars_module.SecretStore(machine=machine) secret_vars_store = secret_vars_module.SecretStore(machine=machine)
@@ -216,13 +219,6 @@ def _generate_vars_for_machine(
f"Could not find generator with name: {generator_name}. The following generators are available: {generators}" f"Could not find generator with name: {generator_name}. The following generators are available: {generators}"
) )
# if generator_name:
# machine_generator_facts = {
# generator_name: machine.vars_generators[generator_name]
# }
# else:
# machine_generator_facts = machine.vars_generators
graph = { graph = {
gen_name: set(generator["dependencies"]) gen_name: set(generator["dependencies"])
for gen_name, generator in machine.vars_generators.items() for gen_name, generator in machine.vars_generators.items()
@@ -250,8 +246,6 @@ def _generate_vars_for_machine(
regenerate=regenerate, regenerate=regenerate,
secret_vars_store=secret_vars_store, secret_vars_store=secret_vars_store,
public_vars_store=public_vars_store, public_vars_store=public_vars_store,
dep_tmpdir=local_temp,
prompt=prompt,
) )
if machine_updated: if machine_updated:
# flush caches to make sure the new secrets are available in evaluation # flush caches to make sure the new secrets are available in evaluation
@@ -263,25 +257,21 @@ def generate_vars(
machines: list[Machine], machines: list[Machine],
generator_name: str | None, generator_name: str | None,
regenerate: bool, regenerate: bool,
prompt: Callable[[str], str] = prompt_func,
) -> bool: ) -> bool:
was_regenerated = False was_regenerated = False
with TemporaryDirectory() as tmp: for machine in machines:
tmpdir = Path(tmp) errors = 0
try:
for machine in machines: was_regenerated |= _generate_vars_for_machine(
errors = 0 machine, generator_name, regenerate
try: )
was_regenerated |= _generate_vars_for_machine( except Exception as exc:
machine, generator_name, regenerate, tmpdir, prompt log.error(f"Failed to generate facts for {machine.name}: {exc}")
) errors += 1
except Exception as exc: if errors > 0:
log.error(f"Failed to generate facts for {machine.name}: {exc}") raise ClanError(
errors += 1 f"Failed to generate facts for {errors} hosts. Check the logs above"
if errors > 0: )
raise ClanError(
f"Failed to generate facts for {errors} hosts. Check the logs above"
)
if not was_regenerated: if not was_regenerated:
print("All secrets and facts are already up to date") print("All secrets and facts are already up to date")

View File

@@ -13,33 +13,45 @@ class SecretStore(SecretStoreBase):
self.machine = machine self.machine = machine
def set( def set(
self, service: str, name: str, value: bytes, groups: list[str] self, generator_name: str, name: str, value: bytes, groups: list[str]
) -> Path | None: ) -> Path | None:
subprocess.run( subprocess.run(
nix_shell( nix_shell(
["nixpkgs#pass"], ["nixpkgs#pass"],
["pass", "insert", "-m", f"machines/{self.machine.name}/{name}"], [
"pass",
"insert",
"-m",
f"machines/{self.machine.name}/{generator_name}/{name}",
],
), ),
input=value, input=value,
check=True, check=True,
) )
return None # we manage the files outside of the git repo return None # we manage the files outside of the git repo
def get(self, service: str, name: str) -> bytes: def get(self, generator_name: str, name: str) -> bytes:
return subprocess.run( return subprocess.run(
nix_shell( nix_shell(
["nixpkgs#pass"], ["nixpkgs#pass"],
["pass", "show", f"machines/{self.machine.name}/{name}"], [
"pass",
"show",
f"machines/{self.machine.name}/{generator_name}/{name}",
],
), ),
check=True, check=True,
stdout=subprocess.PIPE, stdout=subprocess.PIPE,
).stdout ).stdout
def exists(self, service: str, name: str) -> bool: def exists(self, generator_name: str, name: str) -> bool:
password_store = os.environ.get( password_store = os.environ.get(
"PASSWORD_STORE_DIR", f"{os.environ['HOME']}/.password-store" "PASSWORD_STORE_DIR", f"{os.environ['HOME']}/.password-store"
) )
secret_path = Path(password_store) / f"machines/{self.machine.name}/{name}.gpg" secret_path = (
Path(password_store)
/ f"machines/{self.machine.name}/{generator_name}/{name}.gpg"
)
return secret_path.exists() return secret_path.exists()
def generate_hash(self) -> bytes: def generate_hash(self) -> bytes:

View File

@@ -36,13 +36,20 @@ class SecretStore(SecretStoreBase):
) )
add_machine(self.machine.flake_dir, self.machine.name, pub_key, False) add_machine(self.machine.flake_dir, self.machine.name, pub_key, False)
def secret_path(self, generator_name: str, secret_name: str) -> Path:
return (
self.machine.flake_dir
/ "sops"
/ "vars"
/ self.machine.name
/ generator_name
/ secret_name
)
def set( def set(
self, generator_name: str, name: str, value: bytes, groups: list[str] self, generator_name: str, name: str, value: bytes, groups: list[str]
) -> Path | None: ) -> Path | None:
path = ( path = self.secret_path(generator_name, name)
sops_secrets_folder(self.machine.flake_dir)
/ f"vars-{self.machine.name}-{generator_name}-{name}"
)
encrypt_secret( encrypt_secret(
self.machine.flake_dir, self.machine.flake_dir,
path, path,
@@ -54,19 +61,21 @@ class SecretStore(SecretStoreBase):
def get(self, generator_name: str, name: str) -> bytes: def get(self, generator_name: str, name: str) -> bytes:
return decrypt_secret( return decrypt_secret(
self.machine.flake_dir, f"vars-{self.machine.name}-{generator_name}-{name}" self.machine.flake_dir, self.secret_path(generator_name, name)
).encode("utf-8") ).encode("utf-8")
def exists(self, generator_name: str, name: str) -> bool: def exists(self, generator_name: str, name: str) -> bool:
return has_secret( return has_secret(
self.machine.flake_dir, self.secret_path(generator_name, name),
f"vars-{self.machine.name}-{generator_name}-{name}",
) )
def upload(self, output_dir: Path) -> None: def upload(self, output_dir: Path) -> None:
key_name = f"{self.machine.name}-age.key" key_name = f"{self.machine.name}-age.key"
if not has_secret(self.machine.flake_dir, key_name): if not has_secret(sops_secrets_folder(self.machine.flake_dir) / key_name):
# skip uploading the secret, not managed by us # skip uploading the secret, not managed by us
return return
key = decrypt_secret(self.machine.flake_dir, key_name) key = decrypt_secret(
self.machine.flake_dir,
sops_secrets_folder(self.machine.flake_dir) / key_name,
)
(output_dir / "key.txt").write_text(key) (output_dir / "key.txt").write_text(key)

View File

@@ -8,6 +8,12 @@ from ..completions import add_dynamic_completer, complete_machines
from ..machines.machines import Machine from ..machines.machines import Machine
@dataclass
class WaypipeConfig:
enable: bool
command: list[str]
@dataclass @dataclass
class VmConfig: class VmConfig:
machine_name: str machine_name: str
@@ -24,6 +30,8 @@ class VmConfig:
def __post_init__(self) -> None: def __post_init__(self) -> None:
if isinstance(self.flake_url, str): if isinstance(self.flake_url, str):
self.flake_url = FlakeId(self.flake_url) self.flake_url = FlakeId(self.flake_url)
if isinstance(self.waypipe, dict):
self.waypipe = WaypipeConfig(**self.waypipe)
def inspect_vm(machine: Machine) -> VmConfig: def inspect_vm(machine: Machine) -> VmConfig:

View File

@@ -56,6 +56,7 @@ def generate_flake(
}, },
# define the machines directly including their config # define the machines directly including their config
machine_configs: dict[str, dict] = {}, machine_configs: dict[str, dict] = {},
inventory: dict[str, dict] = {},
) -> FlakeForTest: ) -> FlakeForTest:
""" """
Creates a clan flake with the given name. Creates a clan flake with the given name.
@@ -80,6 +81,12 @@ def generate_flake(
shutil.copytree(flake_template, flake) shutil.copytree(flake_template, flake)
sp.run(["chmod", "+w", "-R", str(flake)], check=True) sp.run(["chmod", "+w", "-R", str(flake)], check=True)
# initialize inventory
if inventory:
# check if inventory valid
inventory_path = flake / "inventory.json"
inventory_path.write_text(json.dumps(inventory, indent=2))
# substitute `substitutions` in all files of the template # substitute `substitutions` in all files of the template
for file in flake.rglob("*"): for file in flake.rglob("*"):
if file.is_file(): if file.is_file():

View File

@@ -20,12 +20,12 @@ def test_create_flake(
cli.run(["flakes", "create", str(flake_dir), f"--url={url}"]) cli.run(["flakes", "create", str(flake_dir), f"--url={url}"])
assert (flake_dir / ".clan-flake").exists() assert (flake_dir / ".clan-flake").exists()
# Replace the inputs.clan.url in the template flake.nix # Replace the inputs.clan.url in the template flake.nix
substitute( substitute(
flake_dir / "flake.nix", flake_dir / "flake.nix",
clan_core, clan_core,
) )
# Dont evaluate the inventory before the substitute call
monkeypatch.chdir(flake_dir) monkeypatch.chdir(flake_dir)
cli.run(["machines", "create", "machine1"]) cli.run(["machines", "create", "machine1"])

View File

@@ -14,7 +14,7 @@ from clan_cli.inventory import (
ServiceBorgbackupRoleClient, ServiceBorgbackupRoleClient,
ServiceBorgbackupRoleServer, ServiceBorgbackupRoleServer,
ServiceMeta, ServiceMeta,
load_inventory, load_inventory_json,
save_inventory, save_inventory,
) )
from clan_cli.machines.create import create_machine from clan_cli.machines.create import create_machine
@@ -67,7 +67,7 @@ def test_add_module_to_inventory(
), ),
) )
inventory = load_inventory(base_path) inventory = load_inventory_json(base_path)
inventory.services.borgbackup = { inventory.services.borgbackup = {
"borg1": ServiceBorgbackup( "borg1": ServiceBorgbackup(

View File

@@ -1,6 +1,8 @@
import os import os
import subprocess
from collections import defaultdict from collections import defaultdict
from collections.abc import Callable from collections.abc import Callable
from io import StringIO
from pathlib import Path from pathlib import Path
from tempfile import TemporaryDirectory from tempfile import TemporaryDirectory
from typing import Any from typing import Any
@@ -13,7 +15,8 @@ from root import CLAN_CORE
from clan_cli.clan_uri import FlakeId from clan_cli.clan_uri import FlakeId
from clan_cli.machines.machines import Machine from clan_cli.machines.machines import Machine
from clan_cli.vars.secret_modules.sops import SecretStore from clan_cli.nix import nix_shell
from clan_cli.vars.secret_modules import password_store, sops
def def_value() -> defaultdict: def def_value() -> defaultdict:
@@ -55,7 +58,8 @@ def test_dependencies_as_files() -> None:
), ),
) )
with TemporaryDirectory() as tmpdir: with TemporaryDirectory() as tmpdir:
dep_tmpdir = dependencies_as_dir(decrypted_dependencies, Path(tmpdir)) dep_tmpdir = Path(tmpdir)
dependencies_as_dir(decrypted_dependencies, dep_tmpdir)
assert dep_tmpdir.is_dir() assert dep_tmpdir.is_dir()
assert (dep_tmpdir / "gen_1" / "var_1a").read_bytes() == b"var_1a" assert (dep_tmpdir / "gen_1" / "var_1a").read_bytes() == b"var_1a"
assert (dep_tmpdir / "gen_1" / "var_1b").read_bytes() == b"var_1b" assert (dep_tmpdir / "gen_1" / "var_1b").read_bytes() == b"var_1b"
@@ -93,7 +97,45 @@ def test_generate_public_var(
@pytest.mark.impure @pytest.mark.impure
def test_generate_secret_var_with_default_group( def test_generate_secret_var_sops(
monkeypatch: pytest.MonkeyPatch,
temporary_home: Path,
sops_setup: SopsSetup,
) -> None:
user = os.environ.get("USER", "user")
config = nested_dict()
my_generator = config["clan"]["core"]["vars"]["generators"]["my_generator"]
my_generator["files"]["my_secret"]["secret"] = True
my_generator["script"] = "echo hello > $out/my_secret"
flake = generate_flake(
temporary_home,
flake_template=CLAN_CORE / "templates" / "minimal",
machine_configs=dict(my_machine=config),
)
monkeypatch.chdir(flake.path)
cli.run(
[
"secrets",
"users",
"add",
"--flake",
str(flake.path),
user,
sops_setup.keys[0].pubkey,
]
)
cli.run(["vars", "generate", "--flake", str(flake.path), "my_machine"])
var_file_path = (
flake.path / "machines" / "my_machine" / "vars" / "my_generator" / "my_secret"
)
assert not var_file_path.is_file()
sops_store = sops.SecretStore(Machine(name="my_machine", flake=FlakeId(flake.path)))
assert sops_store.exists("my_generator", "my_secret")
assert sops_store.get("my_generator", "my_secret").decode() == "hello\n"
@pytest.mark.impure
def test_generate_secret_var_sops_with_default_group(
monkeypatch: pytest.MonkeyPatch, monkeypatch: pytest.MonkeyPatch,
temporary_home: Path, temporary_home: Path,
sops_setup: SopsSetup, sops_setup: SopsSetup,
@@ -126,19 +168,75 @@ def test_generate_secret_var_with_default_group(
assert not ( assert not (
flake.path / "machines" / "my_machine" / "vars" / "my_generator" / "my_secret" flake.path / "machines" / "my_machine" / "vars" / "my_generator" / "my_secret"
).is_file() ).is_file()
sops_store = SecretStore(Machine(name="my_machine", flake=FlakeId(flake.path))) sops_store = sops.SecretStore(Machine(name="my_machine", flake=FlakeId(flake.path)))
assert sops_store.exists("my_generator", "my_secret") assert sops_store.exists("my_generator", "my_secret")
assert ( assert (
flake.path flake.path
/ "sops" / "sops"
/ "secrets" / "vars"
/ "vars-my_machine-my_generator-my_secret" / "my_machine"
/ "my_generator"
/ "my_secret"
/ "groups" / "groups"
/ "my_group" / "my_group"
).exists() ).exists()
assert sops_store.get("my_generator", "my_secret").decode() == "hello\n" assert sops_store.get("my_generator", "my_secret").decode() == "hello\n"
@pytest.mark.impure
def test_generate_secret_var_password_store(
monkeypatch: pytest.MonkeyPatch,
temporary_home: Path,
) -> None:
config = nested_dict()
my_generator = config["clan"]["core"]["vars"]["settings"]["secretStore"] = (
"password-store"
)
my_generator = config["clan"]["core"]["vars"]["generators"]["my_generator"]
my_generator["files"]["my_secret"]["secret"] = True
my_generator["script"] = "echo hello > $out/my_secret"
flake = generate_flake(
temporary_home,
flake_template=CLAN_CORE / "templates" / "minimal",
machine_configs=dict(my_machine=config),
)
monkeypatch.chdir(flake.path)
gnupghome = temporary_home / "gpg"
gnupghome.mkdir(mode=0o700)
monkeypatch.setenv("GNUPGHOME", str(gnupghome))
monkeypatch.setenv("PASSWORD_STORE_DIR", str(temporary_home / "pass"))
gpg_key_spec = temporary_home / "gpg_key_spec"
gpg_key_spec.write_text(
"""
Key-Type: 1
Key-Length: 1024
Name-Real: Root Superuser
Name-Email: test@local
Expire-Date: 0
%no-protection
"""
)
subprocess.run(
nix_shell(
["nixpkgs#gnupg"], ["gpg", "--batch", "--gen-key", str(gpg_key_spec)]
),
check=True,
)
subprocess.run(
nix_shell(["nixpkgs#pass"], ["pass", "init", "test@local"]), check=True
)
cli.run(["vars", "generate", "--flake", str(flake.path), "my_machine"])
var_file_path = (
flake.path / "machines" / "my_machine" / "vars" / "my_generator" / "my_secret"
)
assert not var_file_path.is_file()
store = password_store.SecretStore(
Machine(name="my_machine", flake=FlakeId(flake.path))
)
assert store.exists("my_generator", "my_secret")
assert store.get("my_generator", "my_secret").decode() == "hello\n"
@pytest.mark.impure @pytest.mark.impure
def test_generate_secret_for_multiple_machines( def test_generate_secret_for_multiple_machines(
monkeypatch: pytest.MonkeyPatch, monkeypatch: pytest.MonkeyPatch,
@@ -194,8 +292,8 @@ def test_generate_secret_for_multiple_machines(
assert machine2_var_file_path.is_file() assert machine2_var_file_path.is_file()
assert machine2_var_file_path.read_text() == "machine2\n" assert machine2_var_file_path.read_text() == "machine2\n"
# check if secret vars have been created correctly # check if secret vars have been created correctly
sops_store1 = SecretStore(Machine(name="machine1", flake=FlakeId(flake.path))) sops_store1 = sops.SecretStore(Machine(name="machine1", flake=FlakeId(flake.path)))
sops_store2 = SecretStore(Machine(name="machine2", flake=FlakeId(flake.path))) sops_store2 = sops.SecretStore(Machine(name="machine2", flake=FlakeId(flake.path)))
assert sops_store1.exists("my_generator", "my_secret") assert sops_store1.exists("my_generator", "my_secret")
assert sops_store2.exists("my_generator", "my_secret") assert sops_store2.exists("my_generator", "my_secret")
assert sops_store1.get("my_generator", "my_secret").decode() == "machine1\n" assert sops_store1.get("my_generator", "my_secret").decode() == "machine1\n"
@@ -232,3 +330,40 @@ def test_dependant_generators(
) )
assert child_file_path.is_file() assert child_file_path.is_file()
assert child_file_path.read_text() == "hello\n" assert child_file_path.read_text() == "hello\n"
@pytest.mark.impure
@pytest.mark.parametrize(
("prompt_type", "input_value"),
[
("line", "my input"),
("multiline", "my\nmultiline\ninput\n"),
# The hidden type cannot easily be tested, as getpass() reads from /dev/tty directly
# ("hidden", "my hidden input"),
],
)
def test_prompt(
monkeypatch: pytest.MonkeyPatch,
temporary_home: Path,
prompt_type: str,
input_value: str,
) -> None:
config = nested_dict()
my_generator = config["clan"]["core"]["vars"]["generators"]["my_generator"]
my_generator["files"]["my_value"]["secret"] = False
my_generator["prompts"]["prompt1"]["description"] = "dream2nix"
my_generator["prompts"]["prompt1"]["type"] = prompt_type
my_generator["script"] = "cat $prompts/prompt1 > $out/my_value"
flake = generate_flake(
temporary_home,
flake_template=CLAN_CORE / "templates" / "minimal",
machine_configs=dict(my_machine=config),
)
monkeypatch.chdir(flake.path)
monkeypatch.setattr("sys.stdin", StringIO(input_value))
cli.run(["vars", "generate", "--flake", str(flake.path), "my_machine"])
var_file_path = (
flake.path / "machines" / "my_machine" / "vars" / "my_generator" / "my_value"
)
assert var_file_path.is_file()
assert var_file_path.read_text() == input_value

View File

@@ -14,6 +14,9 @@
}, },
{ {
"path": "../../lib/build-clan" "path": "../../lib/build-clan"
},
{
"path": "../../../democlan"
} }
], ],
"settings": { "settings": {

Binary file not shown.

Before

Width:  |  Height:  |  Size: 108 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 106 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 104 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 98 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 155 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 86 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 163 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 183 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 375 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 717 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 717 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 152 KiB

View File

@@ -158,9 +158,15 @@ class VMObject(GObject.Object):
flake=uri.flake, flake=uri.flake,
) )
assert self.machine is not None assert self.machine is not None
state_dir = vm_state_dir(
flake_url=str(self.machine.flake.url), vm_name=self.machine.name if self.machine.flake.is_local():
) state_dir = vm_state_dir(
flake_url=str(self.machine.flake.path), vm_name=self.machine.name
)
else:
state_dir = vm_state_dir(
flake_url=self.machine.flake.url, vm_name=self.machine.name
)
self.qmp_wrap = QMPWrapper(state_dir) self.qmp_wrap = QMPWrapper(state_dir)
assert self.machine is not None assert self.machine is not None
yield self.machine yield self.machine

View File

@@ -1,156 +0,0 @@
import dataclasses
import json
import logging
import sys
import threading
from collections.abc import Callable
from pathlib import Path
from threading import Lock
from typing import Any
import gi
from clan_cli.api import API
gi.require_version("WebKit", "6.0")
from gi.repository import GLib, WebKit
site_index: Path = (
Path(sys.argv[0]).absolute()
/ Path("../..")
/ Path("clan_vm_manager/.webui/index.html")
).resolve()
log = logging.getLogger(__name__)
def dataclass_to_dict(obj: Any) -> Any:
"""
Utility function to convert dataclasses to dictionaries
It converts all nested dataclasses, lists, tuples, and dictionaries to dictionaries
It does NOT convert member functions.
"""
if dataclasses.is_dataclass(obj):
return {k: dataclass_to_dict(v) for k, v in dataclasses.asdict(obj).items()}
elif isinstance(obj, list | tuple):
return [dataclass_to_dict(item) for item in obj]
elif isinstance(obj, dict):
return {k: dataclass_to_dict(v) for k, v in obj.items()}
else:
return obj
class WebView:
def __init__(self, methods: dict[str, Callable]) -> None:
self.method_registry: dict[str, Callable] = methods
self.webview = WebKit.WebView()
settings = self.webview.get_settings()
# settings.
settings.set_property("enable-developer-extras", True)
self.webview.set_settings(settings)
self.manager = self.webview.get_user_content_manager()
# Can be called with: window.webkit.messageHandlers.gtk.postMessage("...")
# Important: it seems postMessage must be given some payload, otherwise it won't trigger the event
self.manager.register_script_message_handler("gtk")
self.manager.connect("script-message-received", self.on_message_received)
self.webview.load_uri(f"file://{site_index}")
# global mutex lock to ensure functions run sequentially
self.mutex_lock = Lock()
self.queue_size = 0
def on_message_received(
self, user_content_manager: WebKit.UserContentManager, message: Any
) -> None:
payload = json.loads(message.to_json(0))
method_name = payload["method"]
handler_fn = self.method_registry[method_name]
log.debug(f"Received message: {payload}")
log.debug(f"Queue size: {self.queue_size} (Wait)")
def threaded_wrapper() -> bool:
"""
Ensures only one function is executed at a time
Wait until there is no other function acquiring the global lock.
Starts a thread with the potentially long running API function within.
"""
if not self.mutex_lock.locked():
thread = threading.Thread(
target=self.threaded_handler,
args=(
handler_fn,
payload.get("data"),
method_name,
),
)
thread.start()
return GLib.SOURCE_REMOVE
return GLib.SOURCE_CONTINUE
GLib.idle_add(
threaded_wrapper,
)
self.queue_size += 1
def threaded_handler(
self,
handler_fn: Callable[
...,
Any,
],
data: dict[str, Any] | None,
method_name: str,
) -> None:
with self.mutex_lock:
log.debug("Executing... ", method_name)
log.debug(f"{data}")
if data is None:
result = handler_fn()
else:
reconciled_arguments = {}
for k, v in data.items():
# Some functions expect to be called with dataclass instances
# But the js api returns dictionaries.
# Introspect the function and create the expected dataclass from dict dynamically
# Depending on the introspected argument_type
arg_type = API.get_method_argtype(method_name, k)
if dataclasses.is_dataclass(arg_type):
reconciled_arguments[k] = arg_type(**v)
else:
reconciled_arguments[k] = v
result = handler_fn(**reconciled_arguments)
serialized = json.dumps(dataclass_to_dict(result))
# Use idle_add to queue the response call to js on the main GTK thread
GLib.idle_add(self.return_data_to_js, method_name, serialized)
self.queue_size -= 1
log.debug(f"Done: Remaining queue size: {self.queue_size}")
def return_data_to_js(self, method_name: str, serialized: str) -> bool:
# This function must be run on the main GTK thread to interact with the webview
# result = method_fn(data) # takes very long
# serialized = result
self.webview.evaluate_javascript(
f"""
window.clan.{method_name}(`{serialized}`);
""",
-1,
None,
None,
None,
)
return GLib.SOURCE_REMOVE
def get_webview(self) -> WebKit.WebView:
return self.webview

View File

@@ -2,7 +2,6 @@ import logging
import threading import threading
import gi import gi
from clan_cli.api import API
from clan_cli.history.list import list_history from clan_cli.history.list import list_history
from clan_vm_manager.components.interfaces import ClanConfig from clan_vm_manager.components.interfaces import ClanConfig
@@ -12,7 +11,6 @@ from clan_vm_manager.singletons.use_vms import ClanStore
from clan_vm_manager.views.details import Details from clan_vm_manager.views.details import Details
from clan_vm_manager.views.list import ClanList from clan_vm_manager.views.list import ClanList
from clan_vm_manager.views.logs import Logs from clan_vm_manager.views.logs import Logs
from clan_vm_manager.views.webview import WebView
gi.require_version("Adw", "1") gi.require_version("Adw", "1")
@@ -61,9 +59,6 @@ class MainWindow(Adw.ApplicationWindow):
stack_view.add_named(Details(), "details") stack_view.add_named(Details(), "details")
stack_view.add_named(Logs(), "logs") stack_view.add_named(Logs(), "logs")
webview = WebView(methods=API._registry)
stack_view.add_named(webview.get_webview(), "webview")
stack_view.set_visible_child_name(config.initial_view) stack_view.set_visible_child_name(config.initial_view)
view.set_content(scroll) view.set_content(scroll)

View File

@@ -18,7 +18,6 @@
runCommand, runCommand,
setuptools, setuptools,
webkitgtk_6_0, webkitgtk_6_0,
webview-ui,
wrapGAppsHook, wrapGAppsHook,
}: }:
let let
@@ -26,7 +25,7 @@ let
desktop-file = makeDesktopItem { desktop-file = makeDesktopItem {
name = "org.clan.vm-manager"; name = "org.clan.vm-manager";
exec = "clan-vm-manager %u"; exec = "clan-vm-manager %u";
icon = ./clan_vm_manager/assets/clan_white.png; icon = "clan-white";
desktopName = "Clan Manager"; desktopName = "Clan Manager";
startupWMClass = "clan"; startupWMClass = "clan";
mimeTypes = [ "x-scheme-handler/clan" ]; mimeTypes = [ "x-scheme-handler/clan" ];
@@ -142,10 +141,9 @@ python3.pkgs.buildPythonApplication rec {
passthru.runtimeDependencies = runtimeDependencies; passthru.runtimeDependencies = runtimeDependencies;
passthru.testDependencies = testDependencies; passthru.testDependencies = testDependencies;
# TODO: place webui in lib/python3.11/site-packages/clan_vm_manager
postInstall = '' postInstall = ''
mkdir -p $out/clan_vm_manager/.webui mkdir -p $out/share/icons/hicolor
cp -r ${webview-ui}/lib/node_modules/@clan/webview-ui/dist/* $out/clan_vm_manager/.webui cp -r ./clan_vm_manager/assets/white-favicons/* $out/share/icons/hicolor
''; '';
# Don't leak python packages into a devshell. # Don't leak python packages into a devshell.

View File

@@ -13,10 +13,10 @@
else else
{ {
devShells.clan-vm-manager = pkgs.callPackage ./shell.nix { devShells.clan-vm-manager = pkgs.callPackage ./shell.nix {
inherit (config.packages) clan-vm-manager webview-ui; inherit (config.packages) clan-vm-manager;
}; };
packages.clan-vm-manager = pkgs.python3.pkgs.callPackage ./default.nix { packages.clan-vm-manager = pkgs.python3.pkgs.callPackage ./default.nix {
inherit (config.packages) clan-cli webview-ui; inherit (config.packages) clan-cli;
}; };
checks = config.packages.clan-vm-manager.tests; checks = config.packages.clan-vm-manager.tests;

View File

@@ -1,23 +1,22 @@
#!/usr/bin/env bash #!/usr/bin/env bash
CLAN=$(nix build .#clan-vm-manager --print-out-paths)
if ! command -v xdg-mime &> /dev/null; then if ! command -v xdg-mime &> /dev/null; then
echo "Warning: 'xdg-mime' is not available. The desktop file cannot be installed." echo "Warning: 'xdg-mime' is not available. The desktop file cannot be installed."
fi fi
ALREADY_INSTALLED=$(nix profile list --json | jq 'has("elements") and (.elements | has("clan-vm-manager"))')
if [ "$ALREADY_INSTALLED" = "true" ]; then
echo "Upgrading installed clan-vm-manager"
nix profile upgrade clan-vm-manager
else
nix profile install .#clan-vm-manager --priority 4
fi
# install desktop file # install desktop file
set -eou pipefail set -eou pipefail
DESKTOP_FILE_NAME=org.clan.vm-manager.desktop DESKTOP_FILE_NAME=org.clan.vm-manager.desktop
DESKTOP_DST=~/.local/share/applications/"$DESKTOP_FILE_NAME"
DESKTOP_SRC="$CLAN/share/applications/$DESKTOP_FILE_NAME"
UI_BIN="$CLAN/bin/clan-vm-manager"
cp -f "$DESKTOP_SRC" "$DESKTOP_DST"
sleep 2
sed -i "s|Exec=.*clan-vm-manager|Exec=$UI_BIN|" "$DESKTOP_DST"
xdg-mime default "$DESKTOP_FILE_NAME" x-scheme-handler/clan xdg-mime default "$DESKTOP_FILE_NAME" x-scheme-handler/clan
echo "==== Validating desktop file installation ===="
set -x
desktop-file-validate "$DESKTOP_DST"
set +xeou pipefail

View File

@@ -10,7 +10,7 @@
python3, python3,
gtk4, gtk4,
libadwaita, libadwaita,
webview-ui,
}: }:
let let
@@ -52,11 +52,5 @@ mkShell {
# Add clan-cli to the python path so that we can import it without building it in nix first # Add clan-cli to the python path so that we can import it without building it in nix first
export PYTHONPATH="$GIT_ROOT/pkgs/clan-cli":"$PYTHONPATH" export PYTHONPATH="$GIT_ROOT/pkgs/clan-cli":"$PYTHONPATH"
# Add the webview-ui to the .webui directory
rm -rf ./clan_vm_manager/.webui/*
mkdir -p ./clan_vm_manager/.webui
cp -a ${webview-ui}/lib/node_modules/@clan/webview-ui/dist/* ./clan_vm_manager/.webui
chmod -R +w ./clan_vm_manager/.webui
''; '';
} }

View File

@@ -32,7 +32,7 @@ let
} }
// flashDiskoConfig; // flashDiskoConfig;
# Important: The partition names need to be different to the clan install # Important: The partition names need to be different to the clan install
flashDiskoConfig = { flashDiskoConfig = {
boot.loader.grub.efiSupport = lib.mkDefault true; boot.loader.grub.efiSupport = lib.mkDefault true;
boot.loader.grub.efiInstallAsRemovable = lib.mkDefault true; boot.loader.grub.efiInstallAsRemovable = lib.mkDefault true;

View File

@@ -1 +1,3 @@
api api
.vite

View File

@@ -8,6 +8,7 @@ import { Flash } from "./routes/flash/view";
import { Settings } from "./routes/settings"; import { Settings } from "./routes/settings";
import { Welcome } from "./routes/welcome"; import { Welcome } from "./routes/welcome";
import { Deploy } from "./routes/deploy"; import { Deploy } from "./routes/deploy";
import { CreateMachine } from "./routes/machines/create";
export type Route = keyof typeof routes; export type Route = keyof typeof routes;
@@ -22,6 +23,11 @@ export const routes = {
label: "Machines", label: "Machines",
icon: "devices_other", icon: "devices_other",
}, },
"machines/add": {
child: CreateMachine,
label: "create Machine",
icon: "add",
},
hosts: { hosts: {
child: HostList, child: HostList,
label: "hosts", label: "hosts",

View File

@@ -67,6 +67,17 @@ function createFunctions<K extends OperationNames>(
dispatch: (args: OperationArgs<K>) => void; dispatch: (args: OperationArgs<K>) => void;
receive: (fn: (response: OperationResponse<K>) => void, id: string) => void; receive: (fn: (response: OperationResponse<K>) => void, id: string) => void;
} { } {
window.clan[operationName] = (s: string) => {
const f = (response: OperationResponse<K>) => {
// Get the correct receiver function for the op_key
const receiver = registry[operationName][response.op_key];
if (receiver) {
receiver(response);
}
};
deserialize(f)(s);
};
return { return {
dispatch: (args: OperationArgs<K>) => { dispatch: (args: OperationArgs<K>) => {
// Send the data to the gtk app // Send the data to the gtk app
@@ -78,15 +89,6 @@ function createFunctions<K extends OperationNames>(
receive: (fn: (response: OperationResponse<K>) => void, id: string) => { receive: (fn: (response: OperationResponse<K>) => void, id: string) => {
// @ts-expect-error: This should work although typescript doesn't let us write // @ts-expect-error: This should work although typescript doesn't let us write
registry[operationName][id] = fn; registry[operationName][id] = fn;
window.clan[operationName] = (s: string) => {
const f = (response: OperationResponse<K>) => {
if (response.op_key === id) {
registry[operationName][id](response);
}
};
deserialize(f)(s);
};
}, },
}; };
} }
@@ -141,8 +143,9 @@ const deserialize =
fn(r); fn(r);
} catch (e) { } catch (e) {
console.log("Error parsing JSON: ", e); console.log("Error parsing JSON: ", e);
console.log({ download: () => download("error.json", str) }); window.localStorage.setItem("error", str);
console.error(str); console.error(str);
console.error("See localStorage 'error'");
alert(`Error parsing JSON: ${e}`); alert(`Error parsing JSON: ${e}`);
} }
}; };

View File

@@ -4,7 +4,6 @@
@tailwind components; @tailwind components;
@tailwind utilities; @tailwind utilities;
html { html {
overflow-x: hidden; overflow-x: hidden;
overflow-y: scroll; overflow-y: scroll;

View File

@@ -42,7 +42,15 @@ export const ClanForm = () => {
await toast.promise( await toast.promise(
(async () => { (async () => {
await callApi("create_clan", { await callApi("create_clan", {
options: { directory: target_dir, meta, template_url }, options: {
directory: target_dir,
template_url,
initial: {
meta,
services: {},
machines: {},
},
},
}); });
setActiveURI(target_dir); setActiveURI(target_dir);
setRoute("machines"); setRoute("machines");

View File

@@ -0,0 +1,124 @@
import { callApi, OperationArgs, pyApi } from "@/src/api";
import { activeURI } from "@/src/App";
import { createForm, required } from "@modular-forms/solid";
import toast from "solid-toast";
type CreateMachineForm = OperationArgs<"create_machine">;
export function CreateMachine() {
const [formStore, { Form, Field }] = createForm<CreateMachineForm>({});
const handleSubmit = async (values: CreateMachineForm) => {
const active_dir = activeURI();
if (!active_dir) {
toast.error("Open a clan to create the machine in");
return;
}
callApi("create_machine", {
flake: {
loc: active_dir,
},
machine: {
name: "jon",
deploy: {
targetHost: null,
},
},
});
console.log("submit", values);
};
return (
<div class="px-1">
Create new Machine
<Form onSubmit={handleSubmit}>
<Field
name="machine.name"
validate={[required("This field is required")]}
>
{(field, props) => (
<>
<label class="input input-bordered flex items-center gap-2">
<input
type="text"
class="grow"
placeholder="name"
required
{...props}
/>
</label>
<div class="label">
{field.error && (
<span class="label-text-alt font-bold text-error">
{field.error}
</span>
)}
</div>
</>
)}
</Field>
<Field name="machine.description">
{(field, props) => (
<>
<label class="input input-bordered flex items-center gap-2">
<input
type="text"
class="grow"
placeholder="description"
required
{...props}
/>
</label>
<div class="label">
{field.error && (
<span class="label-text-alt font-bold text-error">
{field.error}
</span>
)}
</div>
</>
)}
</Field>
<Field name="machine.deploy.targetHost">
{(field, props) => (
<>
<label class="input input-bordered flex items-center gap-2">
<input
type="text"
class="grow"
placeholder="root@flash-installer.local"
required
{...props}
/>
</label>
<div class="label">
<span class="label-text-alt text-neutral">
Must be set before deployment for the following tasks:
<ul>
<li>
<span>Detect hardware config</span>
</li>
<li>
<span>Detect disk layout</span>
</li>
<li>
<span>Remote installation</span>
</li>
</ul>
</span>
{field.error && (
<span class="label-text-alt font-bold text-error">
{field.error}
</span>
)}
</div>
</>
)}
</Field>
<button class="btn btn-error float-right" type="submit">
<span class="material-icons">add</span>Create
</button>
</Form>
</div>
);
}

View File

@@ -7,7 +7,7 @@ import {
createSignal, createSignal,
type Component, type Component,
} from "solid-js"; } from "solid-js";
import { activeURI, route, setActiveURI } from "@/src/App"; import { activeURI, route, setActiveURI, setRoute } from "@/src/App";
import { OperationResponse, callApi, pyApi } from "@/src/api"; import { OperationResponse, callApi, pyApi } from "@/src/api";
import toast from "solid-toast"; import toast from "solid-toast";
import { MachineListItem } from "@/src/components/MachineListItem"; import { MachineListItem } from "@/src/components/MachineListItem";
@@ -86,6 +86,11 @@ export const MachineListView: Component = () => {
<span class="material-icons ">refresh</span> <span class="material-icons ">refresh</span>
</button> </button>
</div> </div>
<div class="tooltip tooltip-bottom" data-tip="Create machine">
<button class="btn btn-ghost" onClick={() => setRoute("machines/add")}>
<span class="material-icons ">add</span>
</button>
</div>
{/* <Show when={services()}> {/* <Show when={services()}>
{(services) => ( {(services) => (
<For each={Object.values(services())}> <For each={Object.values(services())}>

View File

@@ -12,7 +12,8 @@ import {
setRoute, setRoute,
clanList, clanList,
} from "@/src/App"; } from "@/src/App";
import { For } from "solid-js"; import { For, Show } from "solid-js";
import { createQuery } from "@tanstack/solid-query";
export const registerClan = async () => { export const registerClan = async () => {
try { try {
@@ -26,6 +27,7 @@ export const registerClan = async () => {
const res = new Set([...s, loc.data]); const res = new Set([...s, loc.data]);
return Array.from(res); return Array.from(res);
}); });
setActiveURI(loc.data);
setRoute((r) => { setRoute((r) => {
if (r === "welcome") return "machines"; if (r === "welcome") return "machines";
return r; return r;
@@ -37,6 +39,87 @@ export const registerClan = async () => {
} }
}; };
interface ClanDetailsProps {
clan_dir: string;
}
const ClanDetails = (props: ClanDetailsProps) => {
const { clan_dir } = props;
const details = createQuery(() => ({
queryKey: [clan_dir, "meta"],
queryFn: async () => {
const result = await callApi("show_clan_meta", { uri: clan_dir });
if (result.status === "error") throw new Error("Failed to fetch data");
return result.data;
},
}));
return (
<div class="stat">
<div class="stat-figure text-primary">
<div class="join">
<button
class=" join-item btn-sm"
classList={{
"btn btn-ghost btn-outline": activeURI() !== clan_dir,
"badge badge-primary": activeURI() === clan_dir,
}}
disabled={activeURI() === clan_dir}
onClick={() => {
setActiveURI(clan_dir);
}}
>
{activeURI() === clan_dir ? "active" : "select"}
</button>
<button
class="btn btn-ghost btn-outline join-item btn-sm"
onClick={() => {
setClanList((s) =>
s.filter((v, idx) => {
if (v == clan_dir) {
setActiveURI(
clanList()[idx - 1] || clanList()[idx + 1] || null
);
return false;
}
return true;
})
);
}}
>
Remove
</button>
</div>
</div>
<div class="stat-title">Clan URI</div>
<Show when={details.isSuccess}>
<div
class="stat-value"
// classList={{
// "text-primary": activeURI() === clan_dir,
// }}
>
{details.data?.name}
</div>
</Show>
<Show
when={details.isSuccess && details.data?.description}
fallback={<div class="stat-desc text-lg">{clan_dir}</div>}
>
<div
class="stat-desc text-lg"
// classList={{
// "text-primary": activeURI() === clan_dir,
// }}
>
{details.data?.description}
</div>
</Show>
</div>
);
};
export const Settings = () => { export const Settings = () => {
return ( return (
<div class="card card-normal"> <div class="card card-normal">
@@ -54,60 +137,7 @@ export const Settings = () => {
</div> </div>
<div class="stats stats-vertical shadow"> <div class="stats stats-vertical shadow">
<For each={clanList()}> <For each={clanList()}>
{(value) => ( {(value) => <ClanDetails clan_dir={value} />}
<div class="stat">
<div class="stat-figure text-primary">
<div class="join">
<button
class=" join-item btn-sm"
classList={{
"btn btn-ghost btn-outline": activeURI() !== value,
"badge badge-primary": activeURI() === value,
}}
disabled={activeURI() === value}
onClick={() => {
setActiveURI(value);
}}
>
{activeURI() === value ? "active" : "select"}
</button>
<button
class="btn btn-ghost btn-outline join-item btn-sm"
onClick={() => {
setClanList((s) =>
s.filter((v, idx) => {
if (v == value) {
setActiveURI(
clanList()[idx - 1] ||
clanList()[idx + 1] ||
null
);
return false;
}
return true;
})
);
// if (activeURI() === value) {
// setActiveURI();
// }
}}
>
Remove URI
</button>
</div>
</div>
<div class="stat-title">Clan URI</div>
<div
class="stat-desc text-lg"
classList={{
"text-primary": activeURI() === value,
}}
>
{value}
</div>
</div>
)}
</For> </For>
</div> </div>
</div> </div>

View File

@@ -17,7 +17,6 @@
systems = [ systems = [
"x86_64-linux" "x86_64-linux"
"aarch64-linux" "aarch64-linux"
"x86_64-darwin" "x86_64-darwin"
"aarch64-darwin" "aarch64-darwin"
]; ];

View File

@@ -1,16 +1,14 @@
{ {
outputs = outputs = { ... }: {
{ ... }: templates = {
{ default = {
templates = { description = "Initialize a new clan flake";
default = { path = ./new-clan;
description = "Initialize a new clan flake"; };
path = ./new-clan; minimal = {
}; description = "for clans managed via (G)UI";
minimal = { path = ./minimal;
description = "for clans managed via (G)UI";
path = ./minimal;
};
}; };
}; };
};
} }

View File

@@ -0,0 +1,5 @@
{
"meta": { "name": "__CHANGE_ME__" },
"machines": {},
"services": {}
}

View File

@@ -24,9 +24,11 @@
# IMPORTANT! Add your SSH key here # IMPORTANT! Add your SSH key here
# e.g. > cat ~/.ssh/id_ed25519.pub # e.g. > cat ~/.ssh/id_ed25519.pub
users.users.root.openssh.authorizedKeys.keys = ['' users.users.root.openssh.authorizedKeys.keys = [
__YOUR_SSH_KEY__ ''
'']; __YOUR_SSH_KEY__
''
];
# Zerotier needs one controller to accept new nodes. Once accepted # Zerotier needs one controller to accept new nodes. Once accepted
# the controller can be offline and routing still works. # the controller can be offline and routing still works.

View File

@@ -21,9 +21,11 @@
# IMPORTANT! Add your SSH key here # IMPORTANT! Add your SSH key here
# e.g. > cat ~/.ssh/id_ed25519.pub # e.g. > cat ~/.ssh/id_ed25519.pub
users.users.root.openssh.authorizedKeys.keys = ['' users.users.root.openssh.authorizedKeys.keys = [
__YOUR_SSH_KEY__ ''
'']; __YOUR_SSH_KEY__
''
];
/* /*
After jon is deployed, uncomment the following line After jon is deployed, uncomment the following line
This will allow sara to share the VPN overlay network with jon This will allow sara to share the VPN overlay network with jon