Merge pull request 'Disk workflow improvements.' (#2692) from hsjobeki/clan-core:hsjobeki-main into main

This commit is contained in:
clan-bot
2025-01-07 09:27:18 +00:00
5 changed files with 104 additions and 64 deletions

View File

@@ -3,7 +3,7 @@ import logging
from collections.abc import Callable from collections.abc import Callable
from dataclasses import dataclass from dataclasses import dataclass
from pathlib import Path from pathlib import Path
from typing import Any from typing import Any, TypedDict
from uuid import uuid4 from uuid import uuid4
from clan_cli.api import API from clan_cli.api import API
@@ -124,6 +124,11 @@ def get_disk_schemas(
return disk_schemas return disk_schemas
class MachineDiskMatter(TypedDict):
schema_name: str
placeholders: dict[str, str]
@API.register @API.register
def set_machine_disk_schema( def set_machine_disk_schema(
base_path: Path, base_path: Path,
@@ -134,7 +139,7 @@ def set_machine_disk_schema(
placeholders: dict[str, str], placeholders: dict[str, str],
force: bool = False, force: bool = False,
) -> None: ) -> None:
""" " """
Set the disk placeholders of the template Set the disk placeholders of the template
""" """
# Assert the hw-config must exist before setting the disk # Assert the hw-config must exist before setting the disk
@@ -183,8 +188,13 @@ def set_machine_disk_schema(
) )
raise ClanError(msg, description=f"Valid options: {ph.options}") raise ClanError(msg, description=f"Valid options: {ph.options}")
placeholders_toml = "\n".join(
[f"""# {k} = "{v}""" for k, v in placeholders.items() if v is not None]
)
header = f"""# --- header = f"""# ---
# schema = "{schema_name}" # schema = "{schema_name}"
# [placeholders]
{placeholders_toml}
# --- # ---
# This file was automatically generated! # This file was automatically generated!
# CHANGING this configuration requires wiping and reinstalling the machine # CHANGING this configuration requires wiping and reinstalling the machine

View File

@@ -6,6 +6,7 @@ from pathlib import Path
from typing import Literal from typing import Literal
from clan_cli.api import API from clan_cli.api import API
from clan_cli.api.disk import MachineDiskMatter
from clan_cli.api.modules import parse_frontmatter from clan_cli.api.modules import parse_frontmatter
from clan_cli.api.serde import dataclass_to_dict from clan_cli.api.serde import dataclass_to_dict
from clan_cli.cmd import RunOpts, run_no_stdout from clan_cli.cmd import RunOpts, run_no_stdout
@@ -41,7 +42,7 @@ def list_inventory_machines(flake_url: str | Path) -> dict[str, Machine]:
class MachineDetails: class MachineDetails:
machine: Machine machine: Machine
hw_config: HardwareConfig | None = None hw_config: HardwareConfig | None = None
disk_schema: str | None = None disk_schema: MachineDiskMatter | None = None
import re import re
@@ -69,7 +70,7 @@ def get_inventory_machine_details(flake_url: Path, machine_name: str) -> Machine
hw_config = HardwareConfig.detect_type(flake_url, machine_name) hw_config = HardwareConfig.detect_type(flake_url, machine_name)
machine_dir = specific_machine_dir(flake_url, machine_name) machine_dir = specific_machine_dir(flake_url, machine_name)
disk_schema: str | None = None disk_schema: MachineDiskMatter | None = None
disk_path = machine_dir / "disko.nix" disk_path = machine_dir / "disko.nix"
if disk_path.exists(): if disk_path.exists():
with disk_path.open() as f: with disk_path.open() as f:
@@ -77,7 +78,7 @@ def get_inventory_machine_details(flake_url: Path, machine_name: str) -> Machine
header = extract_header(content) header = extract_header(content)
data, _rest = parse_frontmatter(header) data, _rest = parse_frontmatter(header)
if data: if data:
disk_schema = data.get("schema") disk_schema = data # type: ignore
return MachineDetails(machine=machine, hw_config=hw_config, disk_schema=disk_schema) return MachineDetails(machine=machine, hw_config=hw_config, disk_schema=disk_schema)

View File

@@ -153,6 +153,7 @@ export function SelectInput(props: SelectInputpProps) {
// Currently the popover only opens with onClick // Currently the popover only opens with onClick
// Options are not selectable with keyboard // Options are not selectable with keyboard
tabIndex={-1} tabIndex={-1}
disabled={props.disabled}
onClick={() => { onClick={() => {
const popover = document.getElementById(_id); const popover = document.getElementById(_id);
if (popover) { if (popover) {

View File

@@ -21,10 +21,10 @@ import { InputLabel } from "@/src/components/inputBase";
import { FieldLayout } from "@/src/Form/fields/layout"; import { FieldLayout } from "@/src/Form/fields/layout";
import { Modal } from "@/src/components/modal"; import { Modal } from "@/src/components/modal";
import { Typography } from "@/src/components/Typography"; import { Typography } from "@/src/components/Typography";
import cx from "classnames";
import { HardwareValues, HWStep } from "./install/hardware-step"; import { HardwareValues, HWStep } from "./install/hardware-step";
import { DiskStep, DiskValues } from "./install/disk-step"; import { DiskStep, DiskValues } from "./install/disk-step";
import { SummaryStep } from "./install/summary-step"; import { SummaryStep } from "./install/summary-step";
import cx from "classnames";
import { SectionHeader } from "@/src/components/group"; import { SectionHeader } from "@/src/components/group";
type MachineFormInterface = MachineData & { type MachineFormInterface = MachineData & {
@@ -48,12 +48,30 @@ export interface AllStepsValues extends FieldValues {
"3": NonNullable<unknown>; "3": NonNullable<unknown>;
} }
const LoadingBar = () => (
<div
class="h-3 w-80 overflow-hidden rounded-[3px] border-2 border-def-1"
style={{
background: `repeating-linear-gradient(
45deg,
#ccc,
#ccc 8px,
#eee 8px,
#eee 16px
)`,
animation: "slide 25s linear infinite",
"background-size": "200% 100%",
}}
></div>
);
function sleep(ms: number) { function sleep(ms: number) {
return new Promise((resolve) => setTimeout(resolve, ms)); return new Promise((resolve) => setTimeout(resolve, ms));
} }
interface InstallMachineProps { interface InstallMachineProps {
name?: string; name?: string;
targetHost?: string | null; targetHost?: string | null;
machine: MachineData;
} }
const InstallMachine = (props: InstallMachineProps) => { const InstallMachine = (props: InstallMachineProps) => {
const curr = activeURI(); const curr = activeURI();
@@ -64,9 +82,9 @@ const InstallMachine = (props: InstallMachineProps) => {
const [formStore, { Form, Field }] = createForm<AllStepsValues>(); const [formStore, { Form, Field }] = createForm<AllStepsValues>();
const [isDone, setIsDone] = createSignal<boolean>(false);
const [isInstalling, setIsInstalling] = createSignal<boolean>(false); const [isInstalling, setIsInstalling] = createSignal<boolean>(false);
const [progressText, setProgressText] = createSignal<string>(); const [progressText, setProgressText] = createSignal<string>();
const [installError, setInstallError] = createSignal<string>();
const handleInstall = async (values: AllStepsValues) => { const handleInstall = async (values: AllStepsValues) => {
console.log("Installing", values); console.log("Installing", values);
@@ -86,8 +104,14 @@ const InstallMachine = (props: InstallMachineProps) => {
"Installing machine. Grab coffee (15min)...", "Installing machine. Grab coffee (15min)...",
); );
setIsInstalling(true); setIsInstalling(true);
setProgressText("Setting up disk ... (1/5)");
// props.machine.disk_
const shouldRunDisk =
JSON.stringify(props.machine.disk_schema?.placeholders) !==
JSON.stringify(diskValues.placeholders);
if (shouldRunDisk) {
setProgressText("Setting up disk ... (1/5)");
const disk_response = await callApi("set_machine_disk_schema", { const disk_response = await callApi("set_machine_disk_schema", {
base_path: curr_uri, base_path: curr_uri,
machine_name: props.name, machine_name: props.name,
@@ -105,23 +129,11 @@ const InstallMachine = (props: InstallMachineProps) => {
); );
return; return;
} }
// Next step
if (disk_response.status === "success") {
setProgressText("Evaluate configuration ... (2/5)");
} }
// Next step
await sleep(2000);
setProgressText("Building machine ... (3/5)");
await sleep(2000);
setProgressText("Formatting remote disk ... (4/5)");
await sleep(2000);
setProgressText("Copying system ... (5/5)");
await sleep(2000);
setProgressText("Rebooting remote system ... ");
await sleep(2000);
const r = await callApi("install_machine", { setProgressText("Installing machine ... (2/5)");
const installPromise = callApi("install_machine", {
opts: { opts: {
machine: { machine: {
name: props.name, name: props.name,
@@ -133,13 +145,36 @@ const InstallMachine = (props: InstallMachineProps) => {
password: "", password: "",
}, },
}); });
// Next step
await sleep(10 * 1000);
setProgressText("Building machine ... (3/5)");
await sleep(10 * 1000);
setProgressText("Formatting remote disk ... (4/5)");
await sleep(10 * 1000);
setProgressText("Copying system ... (5/5)");
await sleep(20 * 1000);
setProgressText("Rebooting remote system ... ");
await sleep(10 * 1000);
const installResponse = await installPromise;
toast.dismiss(loading_toast); toast.dismiss(loading_toast);
if (r.status === "error") { if (installResponse.status === "error") {
toast.error("Failed to install machine"); toast.error("Failed to install machine");
setIsDone(true);
setProgressText(
"Failed to install machine. \n" + installResponse.errors[0].message,
);
} }
if (r.status === "success") {
if (installResponse.status === "success") {
toast.success("Machine installed successfully"); toast.success("Machine installed successfully");
setIsDone(true);
setProgressText(
"Machine installed successfully. Please unplug the usb stick and reboot the system.",
);
} }
}; };
@@ -271,7 +306,12 @@ const InstallMachine = (props: InstallMachineProps) => {
setValue(formStore, "2", { ...prev, ...data }); setValue(formStore, "2", { ...prev, ...data });
handleNext(); handleNext();
}} }}
initial={getValue(formStore, "2")} // @ts-expect-error: The placeholder type is to wide
initial={{
...props.machine.disk_schema,
...getValue(formStore, "2"),
initialized: !!props.machine.disk_schema,
}}
/> />
</Match> </Match>
<Match when={step() === "3"}> <Match when={step() === "3"}>
@@ -326,20 +366,7 @@ const InstallMachine = (props: InstallMachineProps) => {
</div> </div>
<div class="flex h-full flex-col items-center gap-3 px-4 py-8 bg-inv-4 fg-inv-1"> <div class="flex h-full flex-col items-center gap-3 px-4 py-8 bg-inv-4 fg-inv-1">
<Icon icon="ClanIcon" viewBox="0 0 72 89" class="size-20" /> <Icon icon="ClanIcon" viewBox="0 0 72 89" class="size-20" />
<div {isDone() && <LoadingBar />}
class="h-3 w-80 overflow-hidden rounded-[3px] border-2 border-def-1"
style={{
background: `repeating-linear-gradient(
45deg,
#ccc,
#ccc 8px,
#eee 8px,
#eee 16px
)`,
animation: "slide 25s linear infinite",
"background-size": "200% 100%",
}}
></div>
<Typography <Typography
hierarchy="label" hierarchy="label"
size="default" size="default"
@@ -500,7 +527,7 @@ const MachineForm = (props: MachineDetailsProps) => {
/> />
)} )}
</Field> </Field>
<Field name="disk_schema"> <Field name="disk_schema.schema_name">
{(field, props) => ( {(field, props) => (
<> <>
<FieldLayout <FieldLayout
@@ -576,6 +603,7 @@ const MachineForm = (props: MachineDetailsProps) => {
<InstallMachine <InstallMachine
name={machineName()} name={machineName()}
targetHost={getValue(formStore, "machine.deploy.targetHost")} targetHost={getValue(formStore, "machine.deploy.targetHost")}
machine={props.initialData}
/> />
</Modal> </Modal>

View File

@@ -17,6 +17,7 @@ export interface DiskValues extends FieldValues {
mainDisk: string; mainDisk: string;
}; };
schema: string; schema: string;
initialized: boolean;
} }
export const DiskStep = (props: StepProps<DiskValues>) => { export const DiskStep = (props: StepProps<DiskValues>) => {
const [formStore, { Form, Field }] = createForm<DiskValues>({ const [formStore, { Form, Field }] = createForm<DiskValues>({
@@ -74,9 +75,14 @@ export const DiskStep = (props: StepProps<DiskValues>) => {
</Field> </Field>
</span> </span>
<Group> <Group>
{props.initial?.initialized && "Disk has been initialized already"}
<Field <Field
name="placeholders.mainDisk" name="placeholders.mainDisk"
validate={required("Dsik must be provided")} validate={
!props.initial?.initialized
? required("Disk must be provided")
: undefined
}
> >
{(field, fieldProps) => ( {(field, fieldProps) => (
<SelectInput <SelectInput
@@ -88,18 +94,12 @@ export const DiskStep = (props: StepProps<DiskValues>) => {
{ label: "No options", value: "" }, { label: "No options", value: "" },
] ]
} }
// options={
// deviceQuery.data?.blockdevices.map((d) => ({
// value: d.path,
// label: `${d.path} -- ${d.size} bytes`,
// })) || []
// }
error={field.error} error={field.error}
label="Main Disk" label="Main Disk"
value={field.value || ""} value={field.value || ""}
placeholder="Select a disk" placeholder="Select a disk"
selectProps={fieldProps} selectProps={fieldProps}
required required={!props.initial?.initialized}
/> />
)} )}
</Field> </Field>