Merge pull request 'API/show_block_devices: add option for remote devices' (#1903) from hsjobeki/clan-core:hsjobeki-main into main

This commit is contained in:
clan-bot
2024-08-20 10:10:48 +00:00
9 changed files with 299 additions and 13 deletions

View File

@@ -5,12 +5,12 @@ from pathlib import Path
from types import ModuleType
# These imports are unused, but necessary for @API.register to run once.
from clan_cli.api import directory, mdns_discovery, modules
from clan_cli.api import directory, disk, mdns_discovery, modules
from clan_cli.arg_actions import AppendOptionAction
from clan_cli.clan import show, update
# API endpoints that are not used in the cli.
__all__ = ["directory", "mdns_discovery", "modules", "update"]
__all__ = ["directory", "mdns_discovery", "modules", "update", "disk"]
from . import (
backups,

View File

@@ -90,6 +90,7 @@ def get_directory(current_path: str) -> Directory:
@dataclass
class BlkInfo:
name: str
id_link: str
path: str
rm: str
size: str
@@ -111,19 +112,47 @@ def blk_from_dict(data: dict) -> BlkInfo:
size=data["size"],
ro=data["ro"],
mountpoints=data["mountpoints"],
type_=data["type"], # renamed here
type_=data["type"], # renamed
id_link=data["id-link"], # renamed
)
@dataclass
class BlockDeviceOptions:
hostname: str | None = None
keyfile: str | None = None
@API.register
def show_block_devices() -> Blockdevices:
def show_block_devices(options: BlockDeviceOptions) -> Blockdevices:
"""
Abstract api method to show block devices.
It must return a list of block devices.
"""
keyfile = options.keyfile
remote = (
[
"ssh",
*(["-i", f"{keyfile}"] if keyfile else []),
# Disable strict host key checking
"-o StrictHostKeyChecking=no",
# Disable known hosts file
"-o UserKnownHostsFile=/dev/null",
f"{options.hostname}",
]
if options.hostname
else []
)
cmd = nix_shell(
["nixpkgs#util-linux"],
["lsblk", "--json", "--output", "PATH,NAME,RM,SIZE,RO,MOUNTPOINTS,TYPE"],
["nixpkgs#util-linux", *(["nixpkgs#openssh"] if options.hostname else [])],
[
*remote,
"lsblk",
"--json",
"--output",
"PATH,NAME,RM,SIZE,RO,MOUNTPOINTS,TYPE,ID-LINK",
],
)
proc = run_no_stdout(cmd)
res = proc.stdout.strip()

View File

@@ -0,0 +1,65 @@
from clan_cli.api import API
from clan_cli.inventory import (
ServiceMeta,
ServiceSingleDisk,
ServiceSingleDiskRole,
ServiceSingleDiskRoleDefault,
SingleDiskConfig,
load_inventory_eval,
load_inventory_json,
save_inventory,
)
def get_instance_name(machine_name: str) -> str:
return f"{machine_name}-single-disk"
@API.register
def set_single_disk_uuid(
base_path: str,
machine_name: str,
disk_uuid: str,
) -> None:
"""
Set the disk UUID of single disk machine
"""
inventory = load_inventory_json(base_path)
instance_name = get_instance_name(machine_name)
single_disk_config: ServiceSingleDisk = ServiceSingleDisk(
meta=ServiceMeta(name=instance_name),
roles=ServiceSingleDiskRole(
default=ServiceSingleDiskRoleDefault(
config=SingleDiskConfig(device=disk_uuid)
)
),
)
inventory.services.single_disk[instance_name] = single_disk_config
save_inventory(
inventory,
base_path,
f"Set disk UUID: '{disk_uuid}' on machine: '{machine_name}'",
)
@API.register
def get_single_disk_uuid(
base_path: str,
machine_name: str,
) -> str | None:
"""
Get the disk UUID of single disk machine
"""
inventory = load_inventory_eval(base_path)
instance_name = get_instance_name(machine_name)
single_disk_config: ServiceSingleDisk = inventory.services.single_disk[
instance_name
]
return single_disk_config.roles.default.config.device

View File

@@ -162,6 +162,11 @@ def get_inventory(base_path: str) -> Inventory:
def set_service_instance(
base_path: str, module_name: str, instance_name: str, config: dict[str, Any]
) -> None:
"""
A function that allows to set any service instance in the inventory.
Takes any untyped dict. The dict is then checked and converted into the correct type using the type hints of the service.
If any conversion error occurs, the function will raise an error.
"""
service_keys = get_type_hints(Service).keys()
if module_name not in service_keys:

View File

@@ -32,6 +32,10 @@ from .classes import (
ServiceBorgbackupRoleClient,
ServiceBorgbackupRoleServer,
ServiceMeta,
ServiceSingleDisk,
ServiceSingleDiskRole,
ServiceSingleDiskRoleDefault,
SingleDiskConfig,
)
# Re export classes here
@@ -49,6 +53,11 @@ __all__ = [
"ServiceBorgbackupRole",
"ServiceBorgbackupRoleClient",
"ServiceBorgbackupRoleServer",
# Single Disk service
"ServiceSingleDisk",
"ServiceSingleDiskRole",
"ServiceSingleDiskRoleDefault",
"SingleDiskConfig",
]
@@ -82,6 +91,7 @@ def load_inventory_eval(flake_dir: str | Path) -> Inventory:
"--json",
]
)
proc = run_no_stdout(cmd)
try:

View File

@@ -3,12 +3,13 @@ import json
import logging
from dataclasses import dataclass
from pathlib import Path
from typing import Literal
from clan_cli.api import API
from clan_cli.cmd import run_no_stdout
from clan_cli.errors import ClanError
from clan_cli.errors import ClanCmdError, ClanError
from clan_cli.inventory import Machine, load_inventory_eval
from clan_cli.nix import nix_eval
from clan_cli.nix import nix_eval, nix_shell
log = logging.getLogger(__name__)
@@ -66,6 +67,52 @@ def list_nixos_machines(flake_url: str | Path) -> list[str]:
raise ClanError(f"Error decoding machines from flake: {e}")
@dataclass
class ConnectionOptions:
keyfile: str | None = None
timeout: int = 2
@API.register
def check_machine_online(
flake_url: str | Path, machine_name: str, opts: ConnectionOptions | None
) -> Literal["Online", "Offline"]:
machine = load_inventory_eval(flake_url).machines.get(machine_name)
if not machine:
raise ClanError(f"Machine {machine_name} not found in inventory")
hostname = machine.deploy.targetHost
if not hostname:
raise ClanError(f"Machine {machine_name} does not specify a targetHost")
timeout = opts.timeout if opts and opts.timeout else 2
cmd = nix_shell(
["nixpkgs#util-linux", *(["nixpkgs#openssh"] if hostname else [])],
[
"ssh",
*(["-i", f"{opts.keyfile}"] if opts and opts.keyfile else []),
# Disable strict host key checking
"-o StrictHostKeyChecking=no",
# Disable known hosts file
"-o UserKnownHostsFile=/dev/null",
f"-o ConnectTimeout={timeout}",
f"{hostname}",
"true",
"&> /dev/null",
],
)
try:
proc = run_no_stdout(cmd)
if proc.returncode != 0:
return "Offline"
return "Online"
except ClanCmdError:
return "Offline"
def list_command(args: argparse.Namespace) -> None:
flake_path = args.flake.path
for name in list_nixos_machines(flake_path):

View File

@@ -11,7 +11,7 @@ export const BlockDevicesView: Component = () => {
} = createQuery(() => ({
queryKey: ["block_devices"],
queryFn: async () => {
const result = await callApi("show_block_devices", {});
const result = await callApi("show_block_devices", { options: {} });
if (result.status === "error") throw new Error("Failed to fetch data");
return result.data;

View File

@@ -83,7 +83,9 @@ export const Flash = () => {
const deviceQuery = createQuery(() => ({
queryKey: ["block_devices"],
queryFn: async () => {
const result = await callApi("show_block_devices", {});
const result = await callApi("show_block_devices", {
options: {},
});
if (result.status === "error") throw new Error("Failed to fetch data");
return result.data;
},
@@ -167,7 +169,7 @@ export const Flash = () => {
};
return (
<div class="m-4 bg-slate-50 p-4 pt-8 shadow-sm shadow-slate-400 rounded-lg">
<div class="m-4 rounded-lg bg-slate-50 p-4 pt-8 shadow-sm shadow-slate-400">
<Form onSubmit={handleSubmit}>
<div class="my-4">
<Field name="sshKeys" type="File[]">

View File

@@ -1,14 +1,26 @@
import { callApi } from "@/src/api";
import { activeURI } from "@/src/App";
import { SelectInput } from "@/src/components/SelectInput";
import { createForm } from "@modular-forms/solid";
import { useParams } from "@solidjs/router";
import { createQuery } from "@tanstack/solid-query";
import { createSignal, Show } from "solid-js";
import { createSignal, For, Show } from "solid-js";
import toast from "solid-toast";
// eslint-disable-next-line @typescript-eslint/consistent-type-definitions
type InstallForm = {
disk: string;
};
export const MachineDetails = () => {
const params = useParams();
const query = createQuery(() => ({
queryKey: [activeURI(), "machine", params.id],
queryKey: [
activeURI(),
"machine",
params.id,
"get_inventory_machine_details",
],
queryFn: async () => {
const curr = activeURI();
if (curr) {
@@ -24,12 +36,78 @@ export const MachineDetails = () => {
const [sshKey, setSshKey] = createSignal<string>();
const [formStore, { Form, Field }] = createForm<InstallForm>({});
const handleSubmit = async (values: InstallForm) => {
const curr_uri = activeURI();
if (!curr_uri) {
return;
}
console.log("Setting disk", values.disk);
const r = await callApi("set_single_disk_uuid", {
base_path: curr_uri,
machine_name: params.id,
disk_uuid: values.disk,
});
return null;
};
const targetHost = () => query?.data?.machine.deploy.targetHost;
const remoteDiskQuery = createQuery(() => ({
queryKey: [activeURI(), "machine", targetHost(), "show_block_devices"],
queryFn: async () => {
const curr = activeURI();
if (curr) {
const result = await callApi("show_block_devices", {
options: {
hostname: targetHost(),
keyfile: sshKey(),
},
});
if (result.status === "error") throw new Error("Failed to fetch data");
return result.data;
}
},
}));
const onlineStatusQuery = createQuery(() => ({
queryKey: [activeURI(), "machine", targetHost(), "check_machine_online"],
queryFn: async () => {
const curr = activeURI();
if (curr) {
const result = await callApi("check_machine_online", {
flake_url: curr,
machine_name: params.id,
opts: {
keyfile: sshKey(),
},
});
if (result.status === "error") throw new Error("Failed to fetch data");
return result.data;
}
},
refetchInterval: 5000,
}));
return (
<div>
{query.isLoading && <span class="loading loading-bars" />}
<Show when={!query.isLoading && query.data}>
{(data) => (
<div class="grid grid-cols-2 gap-2 text-lg">
<Show when={onlineStatusQuery.isFetching} fallback={<span></span>}>
<span class="loading loading-bars loading-sm justify-self-end"></span>
</Show>
<span
class="badge badge-outline text-lg"
classList={{
"badge-primary": onlineStatusQuery.data === "Online",
}}
>
{onlineStatusQuery.data}
</span>
<label class="justify-self-end font-light">Name</label>
<span>{data().machine.name}</span>
<span class="justify-self-end font-light">description</span>
@@ -65,6 +143,7 @@ export const MachineDetails = () => {
<span class="justify-self-end font-light">has hw spec</span>
<span>{data().has_hw_specs ? "Yes" : "Not yet"}</span>
<div class="col-span-2 justify-self-center">
<button
class="btn btn-primary join-item btn-sm"
@@ -102,6 +181,55 @@ export const MachineDetails = () => {
Generate HW Spec
</button>
</div>
<button
class="btn self-end"
onClick={() => remoteDiskQuery.refetch()}
>
Refresh remote disks
</button>
<Form onSubmit={handleSubmit} class="w-full">
<Field name="disk">
{(field, props) => (
<SelectInput
formStore={formStore}
selectProps={props}
label="Remote Disk to use"
value={String(field.value)}
error={field.error}
required
options={
<Show when={remoteDiskQuery.data}>
{(disks) => (
<>
<option disabled>
Select the boot disk of the remote machine
</option>
<For each={disks().blockdevices}>
{(dev) => (
<option value={dev.name}>
{dev.name}
{" -- "}
{dev.size}
{"bytes @"}
{
query.data?.machine.deploy.targetHost?.split(
"@",
)?.[1]
}
</option>
)}
</For>
</>
)}
</Show>
}
/>
)}
</Field>
<button class="btn btn-primary" type="submit">
Set disk
</button>
</Form>
</div>
)}
</Show>