From 034982bff26410e2b2c7b4d7a05ef5b6597d38d6 Mon Sep 17 00:00:00 2001 From: Johannes Kirschbauer Date: Sat, 9 Aug 2025 13:49:05 +0200 Subject: [PATCH 1/6] API/modules: init create_service_instance endpoint --- pkgs/clan-cli/clan_lib/services/modules.py | 61 +++++++++++++++++++++- 1 file changed, 59 insertions(+), 2 deletions(-) diff --git a/pkgs/clan-cli/clan_lib/services/modules.py b/pkgs/clan-cli/clan_lib/services/modules.py index 692bd3748..7955fc1df 100644 --- a/pkgs/clan-cli/clan_lib/services/modules.py +++ b/pkgs/clan-cli/clan_lib/services/modules.py @@ -6,8 +6,14 @@ from typing import Any, TypedDict from clan_lib.api import API from clan_lib.errors import ClanError -from clan_lib.flake import Flake -from clan_lib.nix_models.clan import InventoryInstanceModuleType +from clan_lib.flake.flake import Flake +from clan_lib.nix_models.clan import ( + InventoryInstance, + InventoryInstanceModuleType, + InventoryInstanceRolesType, +) +from clan_lib.persist.inventory_store import InventoryStore +from clan_lib.persist.util import set_value_by_path class CategoryInfo(TypedDict): @@ -246,6 +252,57 @@ def get_service_module_schema( ) +@API.register +def create_service_instance( + flake: Flake, + module_ref: InventoryInstanceModuleType, + roles: InventoryInstanceRolesType, +) -> None: + """ + Show information about a module + """ + input_name, module_name = check_service_module_ref(flake, module_ref) + + inventory_store = InventoryStore(flake) + + inventory = inventory_store.read() + + # TODO: Multiple instances support + instance_name = module_name + curr_instances = inventory.get("instances", {}) + + if instance_name in curr_instances: + msg = f"Instance '{instance_name}' already exists in the inventory" + raise ClanError(msg) + + # TODO: Check the roles against the schema + schema = get_service_module_schema(flake, module_ref) + for role_name, _role in roles.items(): + if role_name not in schema: + msg = f"Role '{role_name}' is not defined in the module schema" + raise ClanError(msg) + + # TODO: Validate roles against the schema + + # Create a new instance with the given roles + new_instance: InventoryInstance = { + "module": { + "name": module_name, + "input": input_name, + }, + "roles": roles, + } + + set_value_by_path(inventory, f"instances.{instance_name}", new_instance) + inventory_store.write( + inventory, + message=f"Add service instance '{instance_name}' with module '{module_name} from {input_name}'", + commit=True, + ) + + return + + @dataclass class LegacyModuleInfo: description: str From d007b0f1b36c549bfb7f6cbcf6b1f88bde240ef0 Mon Sep 17 00:00:00 2001 From: Johannes Kirschbauer Date: Sat, 9 Aug 2025 13:49:38 +0200 Subject: [PATCH 2/6] API/generators: expose display attribute of prompts --- pkgs/clan-cli/clan_cli/vars/prompt.py | 22 ++++++++++++++++++++-- 1 file changed, 20 insertions(+), 2 deletions(-) diff --git a/pkgs/clan-cli/clan_cli/vars/prompt.py b/pkgs/clan-cli/clan_cli/vars/prompt.py index ffc2b9cea..d27d0ea91 100644 --- a/pkgs/clan-cli/clan_cli/vars/prompt.py +++ b/pkgs/clan-cli/clan_cli/vars/prompt.py @@ -3,9 +3,9 @@ import logging import sys import termios import tty -from dataclasses import dataclass +from dataclasses import dataclass, field from getpass import getpass -from typing import Any +from typing import Any, TypedDict from clan_lib.errors import ClanError @@ -22,6 +22,13 @@ class PromptType(enum.Enum): MULTILINE_HIDDEN = "multiline-hidden" +class Display(TypedDict): + label: str | None + group: str | None + helperText: str | None + required: bool + + @dataclass class Prompt: name: str @@ -30,6 +37,16 @@ class Prompt: persist: bool = False previous_value: str | None = None + display: Display = field( + default_factory=lambda: Display( + { + "label": None, + "group": None, + "helperText": None, + "required": False, + } + ) + ) @classmethod def from_nix(cls: type["Prompt"], data: dict[str, Any]) -> "Prompt": @@ -38,6 +55,7 @@ class Prompt: description=data.get("description", data["name"]), prompt_type=PromptType(data.get("type", "line")), persist=data.get("persist", False), + display=data.get("display", {}), ) From 966a3ee9197db2732b5023016fe64a685e901caf Mon Sep 17 00:00:00 2001 From: Johannes Kirschbauer Date: Sat, 9 Aug 2025 13:50:03 +0200 Subject: [PATCH 3/6] UI/queries: init generators query --- pkgs/clan-app/ui/src/hooks/queries.ts | 36 +++++++++++++++++++++++++++ 1 file changed, 36 insertions(+) diff --git a/pkgs/clan-app/ui/src/hooks/queries.ts b/pkgs/clan-app/ui/src/hooks/queries.ts index 22042ba33..49b9d97c1 100644 --- a/pkgs/clan-app/ui/src/hooks/queries.ts +++ b/pkgs/clan-app/ui/src/hooks/queries.ts @@ -260,3 +260,39 @@ export const useMachineDiskSchemas = ( }, })); }; + +export type MachineGenerators = SuccessData<"get_generators">; +export type MachineGeneratorsQuery = UseQueryResult; + +export const useMachineGenerators = ( + clanUri: string, + machineName: string, +): MachineGeneratorsQuery => { + const client = useApiClient(); + return useQuery(() => ({ + queryKey: [ + "clans", + encodeBase64(clanUri), + "machines", + machineName, + "generators", + ], + queryFn: async () => { + const call = client.fetch("get_generators", { + base_dir: clanUri, + machine_name: machineName, + // TODO: Make this configurable + include_previous_values: true, + }); + const result = await call.result; + + if (result.status === "error") { + // todo should we create some specific error types? + console.error("Error fetching clan details:", result.errors); + throw new Error(result.errors[0].message); + } + + return result.data; + }, + })); +}; From 121548ffb7dc366cbe17bebc6e93903f3be377a6 Mon Sep 17 00:00:00 2001 From: Johannes Kirschbauer Date: Sat, 9 Aug 2025 13:50:25 +0200 Subject: [PATCH 4/6] UI/onboarding: init admin instance --- .../ui/src/routes/Onboarding/Onboarding.tsx | 23 ++++++++++++++++++- 1 file changed, 22 insertions(+), 1 deletion(-) diff --git a/pkgs/clan-app/ui/src/routes/Onboarding/Onboarding.tsx b/pkgs/clan-app/ui/src/routes/Onboarding/Onboarding.tsx index 099b070bb..03b406ca3 100644 --- a/pkgs/clan-app/ui/src/routes/Onboarding/Onboarding.tsx +++ b/pkgs/clan-app/ui/src/routes/Onboarding/Onboarding.tsx @@ -33,6 +33,7 @@ import * as v from "valibot"; import { HostFileInput } from "@/src/components/Form/HostFileInput"; import { callApi } from "@/src/hooks/api"; import { Creating } from "./Creating"; +import { useApiClient } from "@/src/hooks/ApiClient"; type State = "welcome" | "setup" | "creating"; @@ -208,13 +209,15 @@ export const Onboarding: Component = (props) => { throw new Error("No data returned from api call"); }; + const client = useApiClient(); + const onSubmit: SubmitHandler = async ( { name, description, directory }, event, ) => { const path = `${directory}/${name}`; - const req = callApi("create_clan", { + const req = client.fetch("create_clan", { opts: { dest: path, // todo allow users to select a template @@ -230,6 +233,24 @@ export const Onboarding: Component = (props) => { const resp = await req.result; + // Set up default services + await client.fetch("create_service_instance", { + flake: { + identifier: path, + }, + module_ref: { + name: "admin", + input: "clan-core", + }, + roles: { + default: { + tags: { + all: {}, + }, + }, + }, + }).result; + if (resp.status === "error") { setWelcomeError(resp.errors[0].message); setState("welcome"); From 91646b323af2f95a389c93c4445109e868e4c748 Mon Sep 17 00:00:00 2001 From: Johannes Kirschbauer Date: Sat, 9 Aug 2025 19:58:23 +0200 Subject: [PATCH 5/6] API/generators: adjust filtering to match cli logic --- pkgs/clan-cli/clan_cli/vars/generate.py | 22 +++++++++++++--------- 1 file changed, 13 insertions(+), 9 deletions(-) diff --git a/pkgs/clan-cli/clan_cli/vars/generate.py b/pkgs/clan-cli/clan_cli/vars/generate.py index 4617df09e..fb22a809d 100644 --- a/pkgs/clan-cli/clan_cli/vars/generate.py +++ b/pkgs/clan-cli/clan_cli/vars/generate.py @@ -504,9 +504,9 @@ def _generate_vars_for_machine( @API.register def run_generators( machine_name: str, - generators: list[str], all_prompt_values: dict[str, dict[str, str]], base_dir: Path, + generators: list[str] | None = None, no_sandbox: bool = False, ) -> bool: """Run the specified generators for a machine. @@ -526,16 +526,20 @@ def run_generators( from clan_lib.machines.machines import Machine machine = Machine(name=machine_name, flake=Flake(str(base_dir))) - generators_set = set(generators) - generators_ = [ - g - for g in Generator.get_machine_generators(machine_name, machine.flake) - if g.name in generators_set - ] - + if not generators: + filtered_generators = Generator.get_machine_generators( + machine_name, machine.flake + ) + else: + generators_set = set(generators) + filtered_generators = [ + g + for g in Generator.get_machine_generators(machine_name, machine.flake) + if g.name in generators_set + ] return _generate_vars_for_machine( machine=machine, - generators=generators_, + generators=filtered_generators, all_prompt_values=all_prompt_values, no_sandbox=no_sandbox, ) From e2cb1fd83f5f2c8373576ac2a696100ac5659158 Mon Sep 17 00:00:00 2001 From: Johannes Kirschbauer Date: Sat, 9 Aug 2025 20:02:06 +0200 Subject: [PATCH 6/6] UI/install: run generators --- .../ui/src/workflows/Install/install.tsx | 4 + .../workflows/Install/steps/installSteps.tsx | 234 +++++++++++------- 2 files changed, 151 insertions(+), 87 deletions(-) diff --git a/pkgs/clan-app/ui/src/workflows/Install/install.tsx b/pkgs/clan-app/ui/src/workflows/Install/install.tsx index 7b8c5d99a..be2de668f 100644 --- a/pkgs/clan-app/ui/src/workflows/Install/install.tsx +++ b/pkgs/clan-app/ui/src/workflows/Install/install.tsx @@ -66,8 +66,12 @@ export interface InstallStoreType { mainDisk: string; // ...TODO Vars progress: ApiCall<"run_machine_install">; + + promptValues: PromptValues; }; + done: () => void; } +export type PromptValues = Record>; export const InstallModal = (props: InstallModalProps) => { const stepper = createStepper( diff --git a/pkgs/clan-app/ui/src/workflows/Install/steps/installSteps.tsx b/pkgs/clan-app/ui/src/workflows/Install/steps/installSteps.tsx index 8cd6fde8a..dfe1e5e0a 100644 --- a/pkgs/clan-app/ui/src/workflows/Install/steps/installSteps.tsx +++ b/pkgs/clan-app/ui/src/workflows/Install/steps/installSteps.tsx @@ -9,10 +9,10 @@ import { import { Fieldset } from "@/src/components/Form/Fieldset"; import * as v from "valibot"; import { getStepStore, useStepper } from "@/src/hooks/stepper"; -import { InstallSteps, InstallStoreType } from "../install"; +import { InstallSteps, InstallStoreType, PromptValues } from "../install"; import { TextInput } from "@/src/components/Form/TextInput"; import { Alert } from "@/src/components/Alert/Alert"; -import { onMount, Show } from "solid-js"; +import { For, onMount, Show } from "solid-js"; import { Divider } from "@/src/components/Divider/Divider"; import { Orienter } from "@/src/components/Form/Orienter"; import { Button } from "@/src/components/Button/Button"; @@ -20,7 +20,9 @@ import { Select } from "@/src/components/Select/Select"; import { LoadingBar } from "@/src/components/LoadingBar/LoadingBar"; import Icon from "@/src/components/Icon/Icon"; import { + MachineGenerators, useMachineDiskSchemas, + useMachineGenerators, useMachineHardwareSummary, } from "@/src/hooks/queries"; import { useClanURI } from "@/src/hooks/clan"; @@ -54,8 +56,13 @@ const ConfigureAddress = () => { }, }); + const client = useApiClient(); + const clanUri = useClanURI(); // TODO: push values to the parent form Store - const handleSubmit: SubmitHandler = (values, event) => { + const handleSubmit: SubmitHandler = async ( + values, + event, + ) => { console.log("targetHost set", values); set("install", (s) => ({ ...s, targetHost: values.targetHost })); @@ -161,8 +168,8 @@ const CheckHardware = () => { {(d) => ( { Next @@ -274,16 +281,85 @@ const ConfigureDisk = () => { ); }; -type DynamicForm = Record; - const ConfigureData = () => { - const [formStore, { Form, Field }] = createForm({ - // TODO: Dynamically validate fields - }); const stepSignal = useStepper(); + const [store, get] = getStepStore(stepSignal); - const handleSubmit: SubmitHandler = (values, event) => { + const generatorsQuery = useMachineGenerators( + useClanURI(), + store.install.machineName, + ); + + return ( + <> + + Checking credentials & data... + + + {(generators) => } + + + ); +}; + +type PromptGroup = { + name: string; + fields: { + prompt: Prompt; + generator: string; + value?: string | null; + }[]; +}; + +type Prompt = NonNullable[number]; +type PromptForm = { + promptValues: PromptValues; +}; + +interface PromptsFieldsProps { + generators: MachineGenerators; +} +const PromptsFields = (props: PromptsFieldsProps) => { + const stepSignal = useStepper(); + const [store, set] = getStepStore(stepSignal); + + const groupsObj: Record = props.generators.reduce( + (acc, generator) => { + if (!generator.prompts) { + return acc; + } + for (const prompt of generator.prompts) { + const groupName = ( + prompt.display?.group || generator.name + ).toUpperCase(); + + if (!acc[groupName]) acc[groupName] = { fields: [], name: groupName }; + + acc[groupName].fields.push({ + prompt, + generator: generator.name, + value: prompt.previous_value, + }); + } + return acc; + }, + {} as Record, + ); + const groups = Object.values(groupsObj); + + const [formStore, { Form, Field }] = createForm({ + initialValues: { + promptValues: store.install?.promptValues || {}, + }, + }); + + console.log(groups); + + const handleSubmit: SubmitHandler = (values, event) => { console.log("vars submitted", values); + + set("install", (s) => ({ ...s, promptValues: values.promptValues })); + console.log("vars preloaded"); // Here you would typically trigger the ISO creation process stepSignal.next(); }; @@ -293,68 +369,49 @@ const ConfigureData = () => { -
- - {(field, props) => ( - - )} - -
-
- - {(field, props) => ( - - )} - - - {(field, props) => ( - - )} - -
+ + {(group) => ( +
+ + {(fieldInfo) => ( + + {(f, props) => ( + + )} + + )} + +
+ )} +
} footer={ @@ -403,6 +460,7 @@ const InstallSummary = () => { placeholders: { mainDisk: store.install.mainDisk, }, + force: true, }); const diskResult = await setDisk.result; // Wait for the disk schema to be set @@ -411,6 +469,15 @@ const InstallSummary = () => { return; } + const runGenerators = client.fetch("run_generators", { + all_prompt_values: store.install.promptValues, + base_dir: clanUri, + machine_name: store.install.machineName, + }); + stepSignal.setActiveStep("install:progress"); + + await runGenerators.result; // Wait for the generators to run + const runInstall = client.fetch("run_machine_install", { opts: { machine: { @@ -429,7 +496,9 @@ const InstallSummary = () => { progress: runInstall, })); - stepSignal.setActiveStep("install:progress"); + await runInstall.result; // Wait for the installation to finish + + stepSignal.setActiveStep("install:done"); }; return ( { const stepSignal = useStepper(); const [store, get] = getStepStore(stepSignal); - onMount(async () => { - if (store.install.progress) { - const result = await store.install.progress.result; - - if (result.status === "error") { - console.error("Error during installation:", result.errors); - } - - stepSignal.setActiveStep("install:done"); - } - }); return (
@@ -501,6 +559,8 @@ const InstallProgress = () => { const FlashDone = () => { const stepSignal = useStepper(); + const [store, get] = getStepStore(stepSignal); + return (
@@ -520,7 +580,7 @@ const FlashDone = () => { hierarchy="primary" endIcon="Close" size="s" - onClick={() => stepSignal.next()} + onClick={() => store.done()} > Close