Merge pull request 'Inventory improvements' (#1795) from hsjobeki/clan-core:hsjobeki-main into main

This commit is contained in:
clan-bot
2024-07-24 11:23:16 +00:00
21 changed files with 304 additions and 89 deletions

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

@@ -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 ..errors import ClanError from ..errors import ClanError
@@ -29,11 +28,9 @@ 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_url: str = minimal_template_url template_url: str = minimal_template_url
initial: Inventory | None = None
def git_command(directory: Path, *args: str) -> list[str]: def git_command(directory: Path, *args: str) -> list[str]:
@@ -88,17 +85,13 @@ 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
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")
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,
git_init=git_init, git_init=git_init,
@@ -118,15 +111,6 @@ def register_create_parser(parser: argparse.ArgumentParser) -> None:
default=default_template_url, default=default_template_url,
) )
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(
"path", type=Path, help="Path to the clan directory", default=Path(".") "path", type=Path, help="Path to the clan directory", default=Path(".")
) )

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

@@ -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,3 +1,17 @@
"""
All read/write operations MUST use the inventory.
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 dataclasses import dataclasses
import json import json
from dataclasses import fields, is_dataclass from dataclasses import fields, is_dataclass
@@ -5,9 +19,12 @@ from pathlib import Path
from types import UnionType from types import UnionType
from typing import Any, get_args, get_origin from typing import Any, get_args, get_origin
from clan_cli.errors import ClanError from clan_cli.api import API
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,
@@ -165,14 +182,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 +229,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 +247,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

@@ -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

@@ -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);
};
}, },
}; };
} }

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

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