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:
@@ -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,
|
||||
|
||||
@@ -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()
|
||||
|
||||
65
pkgs/clan-cli/clan_cli/api/disk.py
Normal file
65
pkgs/clan-cli/clan_cli/api/disk.py
Normal 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
|
||||
@@ -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:
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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[]">
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user