From 76ca85ac7312eb6a13a50ea5c2705fccbe5c078a Mon Sep 17 00:00:00 2001 From: Johannes Kirschbauer Date: Fri, 16 Aug 2024 12:17:52 +0200 Subject: [PATCH 1/4] API/show_block_devices: add option for remote devices --- pkgs/clan-cli/clan_cli/api/directory.py | 37 +++++++++++++++++-- pkgs/webview-ui/app/src/routes/flash/view.tsx | 2 +- 2 files changed, 34 insertions(+), 5 deletions(-) diff --git a/pkgs/clan-cli/clan_cli/api/directory.py b/pkgs/clan-cli/clan_cli/api/directory.py index d47225b3b..895749709 100644 --- a/pkgs/clan-cli/clan_cli/api/directory.py +++ b/pkgs/clan-cli/clan_cli/api/directory.py @@ -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() diff --git a/pkgs/webview-ui/app/src/routes/flash/view.tsx b/pkgs/webview-ui/app/src/routes/flash/view.tsx index 37618e419..309e00cd0 100644 --- a/pkgs/webview-ui/app/src/routes/flash/view.tsx +++ b/pkgs/webview-ui/app/src/routes/flash/view.tsx @@ -83,7 +83,7 @@ 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; }, From cabdbe5ecd367aa8cafd9daa2605dede5f7ba91d Mon Sep 17 00:00:00 2001 From: Johannes Kirschbauer Date: Fri, 16 Aug 2024 12:18:21 +0200 Subject: [PATCH 2/4] Clan-app: connection check & show remote devices --- pkgs/clan-cli/clan_cli/api/modules.py | 5 + pkgs/clan-cli/clan_cli/machines/list.py | 51 +++++++- .../app/src/routes/blockdevices/view.tsx | 2 +- pkgs/webview-ui/app/src/routes/flash/view.tsx | 8 +- .../app/src/routes/machines/[name]/view.tsx | 116 +++++++++++++++++- 5 files changed, 174 insertions(+), 8 deletions(-) diff --git a/pkgs/clan-cli/clan_cli/api/modules.py b/pkgs/clan-cli/clan_cli/api/modules.py index 0812c89c5..78b6beb63 100644 --- a/pkgs/clan-cli/clan_cli/api/modules.py +++ b/pkgs/clan-cli/clan_cli/api/modules.py @@ -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: diff --git a/pkgs/clan-cli/clan_cli/machines/list.py b/pkgs/clan-cli/clan_cli/machines/list.py index 139ab9bde..bc754d661 100644 --- a/pkgs/clan-cli/clan_cli/machines/list.py +++ b/pkgs/clan-cli/clan_cli/machines/list.py @@ -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): diff --git a/pkgs/webview-ui/app/src/routes/blockdevices/view.tsx b/pkgs/webview-ui/app/src/routes/blockdevices/view.tsx index 31b035f32..ab773ee9a 100644 --- a/pkgs/webview-ui/app/src/routes/blockdevices/view.tsx +++ b/pkgs/webview-ui/app/src/routes/blockdevices/view.tsx @@ -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; diff --git a/pkgs/webview-ui/app/src/routes/flash/view.tsx b/pkgs/webview-ui/app/src/routes/flash/view.tsx index 309e00cd0..91f7a1f06 100644 --- a/pkgs/webview-ui/app/src/routes/flash/view.tsx +++ b/pkgs/webview-ui/app/src/routes/flash/view.tsx @@ -45,7 +45,7 @@ export const Flash = () => { /* ==== WIFI NETWORK ==== */ const [wifiNetworks, setWifiNetworks] = createSignal([]); const [passwordVisibility, setPasswordVisibility] = createSignal( - [], + [] ); createEffect(() => { @@ -67,7 +67,7 @@ export const Flash = () => { const updatedNetworks = wifiNetworks().filter((_, i) => i !== index); setWifiNetworks(updatedNetworks); const updatedVisibility = passwordVisibility().filter( - (_, i) => i !== index, + (_, i) => i !== index ); setPasswordVisibility(updatedVisibility); setValue(formStore, "wifi", updatedNetworks); @@ -83,7 +83,9 @@ export const Flash = () => { const deviceQuery = createQuery(() => ({ queryKey: ["block_devices"], queryFn: async () => { - const result = await callApi("show_block_devices", { options: {} }); + const result = await callApi("show_block_devices", { + options: {}, + }); if (result.status === "error") throw new Error("Failed to fetch data"); return result.data; }, diff --git a/pkgs/webview-ui/app/src/routes/machines/[name]/view.tsx b/pkgs/webview-ui/app/src/routes/machines/[name]/view.tsx index e7a9e2fde..aa022edb5 100644 --- a/pkgs/webview-ui/app/src/routes/machines/[name]/view.tsx +++ b/pkgs/webview-ui/app/src/routes/machines/[name]/view.tsx @@ -1,14 +1,25 @@ 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"; +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 +35,66 @@ export const MachineDetails = () => { const [sshKey, setSshKey] = createSignal(); + const [formStore, { Form, Field }] = createForm({}); + const handleSubmit = async (values: InstallForm) => { + 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 (
{query.isLoading && } {(data) => (
+ }> + + + + {onlineStatusQuery.data} + + {data().machine.name} description @@ -65,6 +130,7 @@ export const MachineDetails = () => { has hw spec {data().has_hw_specs ? "Yes" : "Not yet"} +
+ +
+ + {(field, props) => ( + + {(disks) => ( + <> + + + {(dev) => ( + + )} + + + )} + + } + /> + )} + +
)}
From f55e7721374871f066f978e00133a35a96a8b65d Mon Sep 17 00:00:00 2001 From: Johannes Kirschbauer Date: Tue, 20 Aug 2024 10:45:22 +0200 Subject: [PATCH 3/4] Fix: types --- pkgs/webview-ui/app/src/routes/flash/view.tsx | 6 +++--- pkgs/webview-ui/app/src/routes/machines/[name]/view.tsx | 7 ++++--- 2 files changed, 7 insertions(+), 6 deletions(-) diff --git a/pkgs/webview-ui/app/src/routes/flash/view.tsx b/pkgs/webview-ui/app/src/routes/flash/view.tsx index 91f7a1f06..b62cfc51f 100644 --- a/pkgs/webview-ui/app/src/routes/flash/view.tsx +++ b/pkgs/webview-ui/app/src/routes/flash/view.tsx @@ -45,7 +45,7 @@ export const Flash = () => { /* ==== WIFI NETWORK ==== */ const [wifiNetworks, setWifiNetworks] = createSignal([]); const [passwordVisibility, setPasswordVisibility] = createSignal( - [] + [], ); createEffect(() => { @@ -67,7 +67,7 @@ export const Flash = () => { const updatedNetworks = wifiNetworks().filter((_, i) => i !== index); setWifiNetworks(updatedNetworks); const updatedVisibility = passwordVisibility().filter( - (_, i) => i !== index + (_, i) => i !== index, ); setPasswordVisibility(updatedVisibility); setValue(formStore, "wifi", updatedNetworks); @@ -169,7 +169,7 @@ export const Flash = () => { }; return ( -
+
diff --git a/pkgs/webview-ui/app/src/routes/machines/[name]/view.tsx b/pkgs/webview-ui/app/src/routes/machines/[name]/view.tsx index aa022edb5..7e7564c10 100644 --- a/pkgs/webview-ui/app/src/routes/machines/[name]/view.tsx +++ b/pkgs/webview-ui/app/src/routes/machines/[name]/view.tsx @@ -7,6 +7,7 @@ import { createQuery } from "@tanstack/solid-query"; 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; }; @@ -150,7 +151,7 @@ export const MachineDetails = () => { machine_name: params.id, clan_dir: curr_uri, hostname: query.data.machine.deploy.targetHost, - }, + } ); toast.dismiss(lt); @@ -159,7 +160,7 @@ export const MachineDetails = () => { } if (response.status === "error") { toast.error( - "Failed to generate. " + response.errors[0].message, + "Failed to generate. " + response.errors[0].message ); } query.refetch(); @@ -200,7 +201,7 @@ export const MachineDetails = () => { {"bytes @"} { query.data?.machine.deploy.targetHost?.split( - "@", + "@" )?.[1] } From db0ebcabf0628bd598ad55f6bb92fe2a85ea939b Mon Sep 17 00:00:00 2001 From: Johannes Kirschbauer Date: Tue, 20 Aug 2024 12:05:22 +0200 Subject: [PATCH 4/4] init: Set/get single disk --- pkgs/clan-cli/clan_cli/__init__.py | 4 +- pkgs/clan-cli/clan_cli/api/disk.py | 65 +++++++++++++++++++ pkgs/clan-cli/clan_cli/inventory/__init__.py | 10 +++ .../app/src/routes/machines/[name]/view.tsx | 21 +++++- 4 files changed, 95 insertions(+), 5 deletions(-) create mode 100644 pkgs/clan-cli/clan_cli/api/disk.py diff --git a/pkgs/clan-cli/clan_cli/__init__.py b/pkgs/clan-cli/clan_cli/__init__.py index 111a6c64a..7fac1fd93 100644 --- a/pkgs/clan-cli/clan_cli/__init__.py +++ b/pkgs/clan-cli/clan_cli/__init__.py @@ -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, diff --git a/pkgs/clan-cli/clan_cli/api/disk.py b/pkgs/clan-cli/clan_cli/api/disk.py new file mode 100644 index 000000000..63b50f846 --- /dev/null +++ b/pkgs/clan-cli/clan_cli/api/disk.py @@ -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 diff --git a/pkgs/clan-cli/clan_cli/inventory/__init__.py b/pkgs/clan-cli/clan_cli/inventory/__init__.py index acf680b75..3aabe6686 100644 --- a/pkgs/clan-cli/clan_cli/inventory/__init__.py +++ b/pkgs/clan-cli/clan_cli/inventory/__init__.py @@ -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: diff --git a/pkgs/webview-ui/app/src/routes/machines/[name]/view.tsx b/pkgs/webview-ui/app/src/routes/machines/[name]/view.tsx index 7e7564c10..320a2756f 100644 --- a/pkgs/webview-ui/app/src/routes/machines/[name]/view.tsx +++ b/pkgs/webview-ui/app/src/routes/machines/[name]/view.tsx @@ -38,6 +38,18 @@ export const MachineDetails = () => { const [formStore, { Form, Field }] = createForm({}); 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; }; @@ -151,7 +163,7 @@ export const MachineDetails = () => { machine_name: params.id, clan_dir: curr_uri, hostname: query.data.machine.deploy.targetHost, - } + }, ); toast.dismiss(lt); @@ -160,7 +172,7 @@ export const MachineDetails = () => { } if (response.status === "error") { toast.error( - "Failed to generate. " + response.errors[0].message + "Failed to generate. " + response.errors[0].message, ); } query.refetch(); @@ -201,7 +213,7 @@ export const MachineDetails = () => { {"bytes @"} { query.data?.machine.deploy.targetHost?.split( - "@" + "@", )?.[1] } @@ -214,6 +226,9 @@ export const MachineDetails = () => { /> )} +
)}