1
0
forked from clan/clan-core

API: migrate machines delete and list to inventory

This commit is contained in:
Johannes Kirschbauer 2024-07-02 11:07:11 +02:00
parent df934334a2
commit 9f484c1d39
Signed by: hsjobeki
SSH Key Fingerprint: SHA256:vX3utDqig7Ph5L0JPv87ZTPb/w7cMzREKVZzzLFg9qU
10 changed files with 79 additions and 69 deletions

View File

@ -1,6 +1,6 @@
{
"machines": {
"minimal_inventory_machine": {
"minimal-inventory-machine": {
"name": "foo",
"system": "x86_64-linux",
"description": "A nice thing",

View File

@ -1,3 +1,4 @@
import json
import re
from dataclasses import asdict, dataclass, is_dataclass
from pathlib import Path
@ -136,3 +137,26 @@ class Inventory:
for name, services in d["services"].items()
},
)
@staticmethod
def get_path(flake_dir: str | Path) -> Path:
return Path(flake_dir) / "inventory.json"
@staticmethod
def load_file(flake_dir: str | Path) -> "Inventory":
inventory = Inventory(machines={}, services={})
inventory_file = Inventory.get_path(flake_dir)
if inventory_file.exists():
with open(inventory_file) as f:
try:
res = json.load(f)
inventory = Inventory.from_dict(res)
except json.JSONDecodeError as e:
raise ClanError(f"Error decoding inventory file: {e}")
return inventory
def persist(self, flake_dir: str | Path) -> None:
inventory_file = Inventory.get_path(flake_dir)
with open(inventory_file, "w") as f:
json.dump(dataclass_to_dict(self), f, indent=2)

View File

@ -1,5 +1,4 @@
import argparse
import json
import logging
import re
from pathlib import Path
@ -7,7 +6,7 @@ from pathlib import Path
from clan_cli.api import API
from clan_cli.errors import ClanError
from clan_cli.git import commit_file
from clan_cli.inventory import Inventory, Machine, dataclass_to_dict
from clan_cli.inventory import Inventory, Machine
log = logging.getLogger(__name__)
@ -18,26 +17,12 @@ def create_machine(flake_dir: str | Path, machine: Machine) -> None:
if not re.match(hostname_regex, machine.name):
raise ClanError("Machine name must be a valid hostname")
inventory = Inventory(machines={}, services={})
inventory_file = Path(flake_dir) / "inventory.json"
if inventory_file.exists():
with open(inventory_file) as f:
try:
res = json.load(f)
inventory = Inventory.from_dict(res)
except json.JSONDecodeError as e:
raise ClanError(f"Error decoding inventory file: {e}")
inventory = Inventory.load_file(flake_dir)
inventory.machines.update({machine.name: machine})
with open(inventory_file, "w") as g:
d = dataclass_to_dict(inventory)
json.dump(d, g, indent=2)
inventory.persist(flake_dir)
if flake_dir is not None:
commit_file(inventory_file, Path(flake_dir))
commit_file(Inventory.get_path(flake_dir), Path(flake_dir))
def create_command(args: argparse.Namespace) -> None:

View File

@ -1,21 +1,36 @@
import argparse
import shutil
from pathlib import Path
from clan_cli.api import API
from clan_cli.inventory import Inventory
from ..completions import add_dynamic_completer, complete_machines
from ..dirs import specific_machine_dir
from ..errors import ClanError
def delete_command(args: argparse.Namespace) -> None:
folder = specific_machine_dir(args.flake, args.host)
@API.register
def delete_machine(base_dir: str | Path, name: str) -> None:
inventory = Inventory.load_file(base_dir)
machine = inventory.machines.pop(name, None)
if machine is None:
raise ClanError(f"Machine {name} does not exist")
inventory.persist(base_dir)
folder = specific_machine_dir(Path(base_dir), name)
if folder.exists():
shutil.rmtree(folder)
else:
raise ClanError(f"Machine {args.host} does not exist")
def delete_command(args: argparse.Namespace) -> None:
delete_machine(args.flake, args.name)
def register_delete_parser(parser: argparse.ArgumentParser) -> None:
machines_parser = parser.add_argument("host", type=str)
machines_parser = parser.add_argument("name", type=str)
add_dynamic_completer(machines_parser, complete_machines)
parser.set_defaults(func=delete_command)

View File

@ -4,22 +4,19 @@ import logging
from pathlib import Path
from clan_cli.api import API
from clan_cli.inventory import Machine
from ..cmd import run_no_stdout
from ..nix import nix_config, nix_eval
from ..nix import nix_eval
log = logging.getLogger(__name__)
@API.register
def list_machines(flake_url: str | Path, debug: bool = False) -> list[str]:
config = nix_config()
system = config["system"]
def list_machines(flake_url: str | Path, debug: bool = False) -> dict[str, Machine]:
cmd = nix_eval(
[
f"{flake_url}#clanInternals.machines.{system}",
"--apply",
"builtins.attrNames",
f"{flake_url}#clanInternals.inventory.machines",
"--json",
]
)
@ -27,12 +24,13 @@ def list_machines(flake_url: str | Path, debug: bool = False) -> list[str]:
proc = run_no_stdout(cmd)
res = proc.stdout.strip()
return json.loads(res)
data = {name: Machine.from_dict(v) for name, v in json.loads(res).items()}
return data
def list_command(args: argparse.Namespace) -> None:
flake_path = Path(args.flake).resolve()
for name in list_machines(flake_path, args.debug):
for name in list_machines(flake_path, args.debug).keys():
print(name)

View File

@ -1,12 +1,14 @@
import { For, Match, Show, Switch, createSignal } from "solid-js";
import { Match, Show, Switch, createSignal } from "solid-js";
import { ErrorData, SuccessData, pyApi } from "../api";
import { currClanURI } from "../App";
type MachineDetails = SuccessData<"list_machines">["data"][string];
interface MachineListItemProps {
name: string;
info: MachineDetails;
}
type MachineDetails = Record<string, SuccessData<"show_machine">["data"]>;
type HWInfo = Record<string, SuccessData<"show_machine_hardware_info">["data"]>;
type DeploymentInfo = Record<
string,
@ -15,26 +17,12 @@ type DeploymentInfo = Record<
type MachineErrors = Record<string, ErrorData<"show_machine">["errors"]>;
const [details, setDetails] = createSignal<MachineDetails>({});
const [hwInfo, setHwInfo] = createSignal<HWInfo>({});
const [deploymentInfo, setDeploymentInfo] = createSignal<DeploymentInfo>({});
const [errors, setErrors] = createSignal<MachineErrors>({});
pyApi.show_machine.receive((r) => {
if (r.status === "error") {
const { op_key } = r;
if (op_key) {
setErrors((e) => ({ ...e, [op_key]: r.errors }));
}
console.error(r.errors);
}
if (r.status === "success") {
setDetails((d) => ({ ...d, [r.data.machine_name]: r.data }));
}
});
pyApi.show_machine_hardware_info.receive((r) => {
const { op_key } = r;
if (r.status === "error") {
@ -64,13 +52,7 @@ pyApi.show_machine_deployment_target.receive((r) => {
});
export const MachineListItem = (props: MachineListItemProps) => {
const { name } = props;
pyApi.show_machine.dispatch({
op_key: name,
machine_name: name,
flake_url: currClanURI(),
});
const { name, info } = props;
pyApi.show_machine_hardware_info.dispatch({
op_key: name,
@ -97,16 +79,14 @@ export const MachineListItem = (props: MachineListItemProps) => {
<h2 class="card-title">{name}</h2>
<div class="text-slate-600">
<Show
when={details()[name]}
when={info}
fallback={
<Switch fallback={<div class="skeleton h-8 w-full"></div>}>
<Match when={!details()[name]?.machine_description}>
No description
</Match>
<Match when={!info.description}>No description</Match>
</Switch>
}
>
{(d) => d()?.machine_description}
{(d) => d()?.description}
</Show>
</div>
<div class="flex flex-row flex-wrap gap-4 py-2">

View File

@ -23,6 +23,11 @@ type ServiceModel = Extract<
{ status: "success" }
>["data"]["services"];
type MachinesModel = Extract<
OperationResponse<"list_machines">,
{ status: "success" }
>["data"];
export const MachineListView: Component = () => {
const [{ machines, loading }, { getMachines }] = useMachineContext();
@ -44,11 +49,13 @@ export const MachineListView: Component = () => {
console.log(files());
});
const [data, setData] = createSignal<string[]>([]);
const [data, setData] = createSignal<MachinesModel>({});
createEffect(() => {
if (route() === "machines") getMachines();
});
const unpackedMachines = () => Object.entries(data());
createEffect(() => {
const response = machines();
if (response?.status === "success") {
@ -57,7 +64,7 @@ export const MachineListView: Component = () => {
toast.success("Machines loaded");
}
if (response?.status === "error") {
setData([]);
setData({});
console.error(response.errors);
toast.error("Error loading machines");
response.errors.forEach((error) =>
@ -162,13 +169,13 @@ export const MachineListView: Component = () => {
</div>
</div>
</Match>
<Match when={!loading() && data().length === 0}>
<Match when={!loading() && unpackedMachines().length === 0}>
No machines found
</Match>
<Match when={!loading()}>
<ul>
<For each={data()}>
{(entry) => <MachineListItem name={entry} />}
<For each={unpackedMachines()}>
{([name, info]) => <MachineListItem name={name} info={info} />}
</For>
</ul>
</Match>

View File

@ -28,12 +28,13 @@ describe.concurrent("API types work properly", () => {
>();
});
it("Machine list receives a list of names/id string", async () => {
it("Machine list receives a records of names and machine info.", async () => {
expectTypeOf(pyApi.list_machines.receive)
.parameter(0)
.parameter(0)
.toMatchTypeOf<
{ status: "success"; data: string[] } | { status: "error"; errors: any }
| { status: "success"; data: Record<string, object> }
| { status: "error"; errors: any }
>();
});