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
|
from types import ModuleType
|
||||||
|
|
||||||
# These imports are unused, but necessary for @API.register to run once.
|
# 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.arg_actions import AppendOptionAction
|
||||||
from clan_cli.clan import show, update
|
from clan_cli.clan import show, update
|
||||||
|
|
||||||
# API endpoints that are not used in the cli.
|
# API endpoints that are not used in the cli.
|
||||||
__all__ = ["directory", "mdns_discovery", "modules", "update"]
|
__all__ = ["directory", "mdns_discovery", "modules", "update", "disk"]
|
||||||
|
|
||||||
from . import (
|
from . import (
|
||||||
backups,
|
backups,
|
||||||
|
|||||||
@@ -90,6 +90,7 @@ def get_directory(current_path: str) -> Directory:
|
|||||||
@dataclass
|
@dataclass
|
||||||
class BlkInfo:
|
class BlkInfo:
|
||||||
name: str
|
name: str
|
||||||
|
id_link: str
|
||||||
path: str
|
path: str
|
||||||
rm: str
|
rm: str
|
||||||
size: str
|
size: str
|
||||||
@@ -111,19 +112,47 @@ def blk_from_dict(data: dict) -> BlkInfo:
|
|||||||
size=data["size"],
|
size=data["size"],
|
||||||
ro=data["ro"],
|
ro=data["ro"],
|
||||||
mountpoints=data["mountpoints"],
|
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
|
@API.register
|
||||||
def show_block_devices() -> Blockdevices:
|
def show_block_devices(options: BlockDeviceOptions) -> Blockdevices:
|
||||||
"""
|
"""
|
||||||
Abstract api method to show block devices.
|
Abstract api method to show block devices.
|
||||||
It must return a list of 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(
|
cmd = nix_shell(
|
||||||
["nixpkgs#util-linux"],
|
["nixpkgs#util-linux", *(["nixpkgs#openssh"] if options.hostname else [])],
|
||||||
["lsblk", "--json", "--output", "PATH,NAME,RM,SIZE,RO,MOUNTPOINTS,TYPE"],
|
[
|
||||||
|
*remote,
|
||||||
|
"lsblk",
|
||||||
|
"--json",
|
||||||
|
"--output",
|
||||||
|
"PATH,NAME,RM,SIZE,RO,MOUNTPOINTS,TYPE,ID-LINK",
|
||||||
|
],
|
||||||
)
|
)
|
||||||
proc = run_no_stdout(cmd)
|
proc = run_no_stdout(cmd)
|
||||||
res = proc.stdout.strip()
|
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(
|
def set_service_instance(
|
||||||
base_path: str, module_name: str, instance_name: str, config: dict[str, Any]
|
base_path: str, module_name: str, instance_name: str, config: dict[str, Any]
|
||||||
) -> None:
|
) -> 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()
|
service_keys = get_type_hints(Service).keys()
|
||||||
|
|
||||||
if module_name not in service_keys:
|
if module_name not in service_keys:
|
||||||
|
|||||||
@@ -32,6 +32,10 @@ from .classes import (
|
|||||||
ServiceBorgbackupRoleClient,
|
ServiceBorgbackupRoleClient,
|
||||||
ServiceBorgbackupRoleServer,
|
ServiceBorgbackupRoleServer,
|
||||||
ServiceMeta,
|
ServiceMeta,
|
||||||
|
ServiceSingleDisk,
|
||||||
|
ServiceSingleDiskRole,
|
||||||
|
ServiceSingleDiskRoleDefault,
|
||||||
|
SingleDiskConfig,
|
||||||
)
|
)
|
||||||
|
|
||||||
# Re export classes here
|
# Re export classes here
|
||||||
@@ -49,6 +53,11 @@ __all__ = [
|
|||||||
"ServiceBorgbackupRole",
|
"ServiceBorgbackupRole",
|
||||||
"ServiceBorgbackupRoleClient",
|
"ServiceBorgbackupRoleClient",
|
||||||
"ServiceBorgbackupRoleServer",
|
"ServiceBorgbackupRoleServer",
|
||||||
|
# Single Disk service
|
||||||
|
"ServiceSingleDisk",
|
||||||
|
"ServiceSingleDiskRole",
|
||||||
|
"ServiceSingleDiskRoleDefault",
|
||||||
|
"SingleDiskConfig",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
@@ -82,6 +91,7 @@ def load_inventory_eval(flake_dir: str | Path) -> Inventory:
|
|||||||
"--json",
|
"--json",
|
||||||
]
|
]
|
||||||
)
|
)
|
||||||
|
|
||||||
proc = run_no_stdout(cmd)
|
proc = run_no_stdout(cmd)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
|
|||||||
@@ -3,12 +3,13 @@ import json
|
|||||||
import logging
|
import logging
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
from typing import Literal
|
||||||
|
|
||||||
from clan_cli.api import API
|
from clan_cli.api import API
|
||||||
from clan_cli.cmd import run_no_stdout
|
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.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__)
|
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}")
|
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:
|
def list_command(args: argparse.Namespace) -> None:
|
||||||
flake_path = args.flake.path
|
flake_path = args.flake.path
|
||||||
for name in list_nixos_machines(flake_path):
|
for name in list_nixos_machines(flake_path):
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ export const BlockDevicesView: Component = () => {
|
|||||||
} = createQuery(() => ({
|
} = createQuery(() => ({
|
||||||
queryKey: ["block_devices"],
|
queryKey: ["block_devices"],
|
||||||
queryFn: async () => {
|
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");
|
if (result.status === "error") throw new Error("Failed to fetch data");
|
||||||
|
|
||||||
return result.data;
|
return result.data;
|
||||||
|
|||||||
@@ -83,7 +83,9 @@ export const Flash = () => {
|
|||||||
const deviceQuery = createQuery(() => ({
|
const deviceQuery = createQuery(() => ({
|
||||||
queryKey: ["block_devices"],
|
queryKey: ["block_devices"],
|
||||||
queryFn: async () => {
|
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");
|
if (result.status === "error") throw new Error("Failed to fetch data");
|
||||||
return result.data;
|
return result.data;
|
||||||
},
|
},
|
||||||
@@ -167,7 +169,7 @@ export const Flash = () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
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}>
|
<Form onSubmit={handleSubmit}>
|
||||||
<div class="my-4">
|
<div class="my-4">
|
||||||
<Field name="sshKeys" type="File[]">
|
<Field name="sshKeys" type="File[]">
|
||||||
|
|||||||
@@ -1,14 +1,26 @@
|
|||||||
import { callApi } from "@/src/api";
|
import { callApi } from "@/src/api";
|
||||||
import { activeURI } from "@/src/App";
|
import { activeURI } from "@/src/App";
|
||||||
|
import { SelectInput } from "@/src/components/SelectInput";
|
||||||
|
import { createForm } from "@modular-forms/solid";
|
||||||
import { useParams } from "@solidjs/router";
|
import { useParams } from "@solidjs/router";
|
||||||
import { createQuery } from "@tanstack/solid-query";
|
import { createQuery } from "@tanstack/solid-query";
|
||||||
import { createSignal, Show } from "solid-js";
|
import { createSignal, For, Show } from "solid-js";
|
||||||
import toast from "solid-toast";
|
import toast from "solid-toast";
|
||||||
|
|
||||||
|
// eslint-disable-next-line @typescript-eslint/consistent-type-definitions
|
||||||
|
type InstallForm = {
|
||||||
|
disk: string;
|
||||||
|
};
|
||||||
|
|
||||||
export const MachineDetails = () => {
|
export const MachineDetails = () => {
|
||||||
const params = useParams();
|
const params = useParams();
|
||||||
const query = createQuery(() => ({
|
const query = createQuery(() => ({
|
||||||
queryKey: [activeURI(), "machine", params.id],
|
queryKey: [
|
||||||
|
activeURI(),
|
||||||
|
"machine",
|
||||||
|
params.id,
|
||||||
|
"get_inventory_machine_details",
|
||||||
|
],
|
||||||
queryFn: async () => {
|
queryFn: async () => {
|
||||||
const curr = activeURI();
|
const curr = activeURI();
|
||||||
if (curr) {
|
if (curr) {
|
||||||
@@ -24,12 +36,78 @@ export const MachineDetails = () => {
|
|||||||
|
|
||||||
const [sshKey, setSshKey] = createSignal<string>();
|
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 (
|
return (
|
||||||
<div>
|
<div>
|
||||||
{query.isLoading && <span class="loading loading-bars" />}
|
{query.isLoading && <span class="loading loading-bars" />}
|
||||||
<Show when={!query.isLoading && query.data}>
|
<Show when={!query.isLoading && query.data}>
|
||||||
{(data) => (
|
{(data) => (
|
||||||
<div class="grid grid-cols-2 gap-2 text-lg">
|
<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>
|
<label class="justify-self-end font-light">Name</label>
|
||||||
<span>{data().machine.name}</span>
|
<span>{data().machine.name}</span>
|
||||||
<span class="justify-self-end font-light">description</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 class="justify-self-end font-light">has hw spec</span>
|
||||||
<span>{data().has_hw_specs ? "Yes" : "Not yet"}</span>
|
<span>{data().has_hw_specs ? "Yes" : "Not yet"}</span>
|
||||||
|
|
||||||
<div class="col-span-2 justify-self-center">
|
<div class="col-span-2 justify-self-center">
|
||||||
<button
|
<button
|
||||||
class="btn btn-primary join-item btn-sm"
|
class="btn btn-primary join-item btn-sm"
|
||||||
@@ -102,6 +181,55 @@ export const MachineDetails = () => {
|
|||||||
Generate HW Spec
|
Generate HW Spec
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</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>
|
</div>
|
||||||
)}
|
)}
|
||||||
</Show>
|
</Show>
|
||||||
|
|||||||
Reference in New Issue
Block a user