Compare commits

..

2 Commits

Author SHA1 Message Date
Johannes Kirschbauer
8584f3247a machine/install: fix type issues 2025-08-29 11:03:24 +02:00
Sacha Korban
d3534a2b72 fix: check if phases are non-default when running 2025-08-29 18:27:03 +10:00
40 changed files with 463 additions and 1184 deletions

6
devFlake/flake.lock generated
View File

@@ -84,11 +84,11 @@
}, },
"nixpkgs-dev": { "nixpkgs-dev": {
"locked": { "locked": {
"lastModified": 1756578978, "lastModified": 1756400612,
"narHash": "sha256-dLgwMLIMyHlSeIDsoT2OcZBkuruIbjhIAv1sGANwtes=", "narHash": "sha256-0xm2D8u6y1+hCT+o4LCUCm3GCmSJHLAF0jRELyIb1go=",
"owner": "NixOS", "owner": "NixOS",
"repo": "nixpkgs", "repo": "nixpkgs",
"rev": "a85a50bef870537a9705f64ed75e54d1f4bf9c23", "rev": "593cac9f894d7d4894e0155bacbbc69e7ef552dd",
"type": "github" "type": "github"
}, },
"original": { "original": {

View File

@@ -245,8 +245,6 @@ in
in in
{ config, ... }: { config, ... }:
{ {
staticModules = clan-core.clan.modules;
distributedServices = clanLib.inventory.mapInstances { distributedServices = clanLib.inventory.mapInstances {
inherit (clanConfig) inventory exportsModule; inherit (clanConfig) inventory exportsModule;
inherit flakeInputs directory; inherit flakeInputs directory;

View File

@@ -23,12 +23,6 @@ let
}; };
in in
{ {
options.staticModules = lib.mkOption {
readOnly = true;
type = lib.types.raw;
apply = moduleSet: lib.mapAttrs (inspectModule "<clan-core>") moduleSet;
};
options.modulesPerSource = lib.mkOption { options.modulesPerSource = lib.mkOption {
# { sourceName :: { moduleName :: {} }} # { sourceName :: { moduleName :: {} }}
readOnly = true; readOnly = true;

View File

@@ -10,7 +10,7 @@
lib.mkIf config.clan.core.enableRecommendedDefaults { lib.mkIf config.clan.core.enableRecommendedDefaults {
# Enable automatic state-version generation. # Enable automatic state-version generation.
clan.core.settings.state-version.enable = lib.mkDefault true; clan.core.settings.state-version.enable = true;
# Use systemd during boot as well except: # Use systemd during boot as well except:
# - systems with raids as this currently require manual configuration: https://github.com/NixOS/nixpkgs/issues/210210 # - systems with raids as this currently require manual configuration: https://github.com/NixOS/nixpkgs/issues/210210

View File

@@ -12,14 +12,8 @@ let
(builtins.match "linux_[0-9]+_[0-9]+" name) != null (builtins.match "linux_[0-9]+_[0-9]+" name) != null
&& (builtins.tryEval kernelPackages).success && (builtins.tryEval kernelPackages).success
&& ( && (
let (!isUnstable && !kernelPackages.zfs.meta.broken)
zfsPackage = || (isUnstable && !kernelPackages.zfs_unstable.meta.broken)
if isUnstable then
kernelPackages.zfs_unstable
else
kernelPackages.${pkgs.zfs.kernelModuleAttribute};
in
!(zfsPackage.meta.broken or false)
) )
) pkgs.linuxKernel.packages; ) pkgs.linuxKernel.packages;
latestKernelPackage = lib.last ( latestKernelPackage = lib.last (
@@ -30,5 +24,5 @@ let
in in
{ {
# Note this might jump back and worth as kernel get added or removed. # Note this might jump back and worth as kernel get added or removed.
boot.kernelPackages = lib.mkIf (lib.meta.availableOn pkgs.hostPlatform pkgs.zfs) latestKernelPackage; boot.kernelPackages = latestKernelPackage;
} }

View File

@@ -3,13 +3,12 @@ import { A } from "@solidjs/router";
import { Accordion } from "@kobalte/core/accordion"; import { Accordion } from "@kobalte/core/accordion";
import Icon from "../Icon/Icon"; import Icon from "../Icon/Icon";
import { Typography } from "@/src/components/Typography/Typography"; import { Typography } from "@/src/components/Typography/Typography";
import { For, Show, useContext } from "solid-js"; import { For, useContext } from "solid-js";
import { MachineStatus } from "@/src/components/MachineStatus/MachineStatus"; import { MachineStatus } from "@/src/components/MachineStatus/MachineStatus";
import { buildMachinePath, useClanURI } from "@/src/hooks/clan"; import { buildMachinePath, useClanURI } from "@/src/hooks/clan";
import { useMachineStateQuery } from "@/src/hooks/queries"; import { useMachineStateQuery } from "@/src/hooks/queries";
import { SidebarProps } from "./Sidebar"; import { SidebarProps } from "./Sidebar";
import { ClanContext } from "@/src/routes/Clan/Clan"; import { ClanContext } from "@/src/routes/Clan/Clan";
import { Button } from "../Button/Button";
interface MachineProps { interface MachineProps {
clanURI: string; clanURI: string;
@@ -72,15 +71,6 @@ export const SidebarBody = (props: SidebarProps) => {
// we want them all to be open by default // we want them all to be open by default
const defaultAccordionValues = ["your-machines", ...sectionLabels]; const defaultAccordionValues = ["your-machines", ...sectionLabels];
const machines = () => {
if (!ctx.machinesQuery.isSuccess) {
return {};
}
const result = ctx.machinesQuery.data;
return Object.keys(result).length > 0 ? result : undefined;
};
return ( return (
<div class="sidebar-body"> <div class="sidebar-body">
<Accordion <Accordion
@@ -110,42 +100,18 @@ export const SidebarBody = (props: SidebarProps) => {
</Accordion.Trigger> </Accordion.Trigger>
</Accordion.Header> </Accordion.Header>
<Accordion.Content class="content"> <Accordion.Content class="content">
<Show <nav>
when={machines()} <For each={Object.entries(ctx.machinesQuery.data || {})}>
fallback={ {([id, machine]) => (
<div class="flex w-full flex-col items-center justify-center gap-2.5"> <MachineRoute
<Typography clanURI={clanURI}
hierarchy="body" machineID={id}
size="s" name={machine.name || id}
weight="medium" serviceCount={0}
inverted />
> )}
No machines yet </For>
</Typography> </nav>
<Button
hierarchy="primary"
size="s"
startIcon="Machine"
onClick={() => ctx.setShowAddMachine(true)}
>
Add machine
</Button>
</div>
}
>
<nav>
<For each={Object.entries(machines()!)}>
{([id, machine]) => (
<MachineRoute
clanURI={clanURI}
machineID={id}
name={machine.name || id}
serviceCount={0}
/>
)}
</For>
</nav>
</Show>
</Accordion.Content> </Accordion.Content>
</Accordion.Item> </Accordion.Item>

View File

@@ -9,7 +9,7 @@ export interface SidebarPaneProps {
class?: string; class?: string;
title: string; title: string;
onClose: () => void; onClose: () => void;
subHeader?: JSX.Element; subHeader?: () => JSX.Element;
children: JSX.Element; children: JSX.Element;
} }
@@ -43,7 +43,7 @@ export const SidebarPane = (props: SidebarPaneProps) => {
</KButton> </KButton>
</div> </div>
<Show when={props.subHeader}> <Show when={props.subHeader}>
<div class="sub-header">{props.subHeader}</div> <div class="sub-header">{props.subHeader!()}</div>
</Show> </Show>
<div class="body">{props.children}</div> <div class="body">{props.children}</div>
</div> </div>

View File

@@ -1,6 +1,6 @@
import { createSignal, Show } from "solid-js"; import { createSignal, Show } from "solid-js";
import { Button } from "@/src/components/Button/Button"; import { Button } from "@/src/components/Button/Button";
import { InstallModal } from "@/src/workflows/InstallMachine/InstallMachine"; import { InstallModal } from "@/src/workflows/Install/install";
import { useMachineName } from "@/src/hooks/clan"; import { useMachineName } from "@/src/hooks/clan";
import { useMachineStateQuery } from "@/src/hooks/queries"; import { useMachineStateQuery } from "@/src/hooks/queries";
import styles from "./SidebarSectionInstall.module.css"; import styles from "./SidebarSectionInstall.module.css";

View File

@@ -8,7 +8,6 @@ import {
on, on,
onMount, onMount,
Show, Show,
Signal,
useContext, useContext,
} from "solid-js"; } from "solid-js";
import { import {
@@ -25,11 +24,16 @@ import {
useClanListQuery, useClanListQuery,
useMachinesQuery, useMachinesQuery,
} from "@/src/hooks/queries"; } from "@/src/hooks/queries";
import { callApi } from "@/src/hooks/api";
import { clanURIs, setStore, store } from "@/src/stores/clan"; import { clanURIs, setStore, store } from "@/src/stores/clan";
import { produce } from "solid-js/store"; import { produce } from "solid-js/store";
import { Button } from "@/src/components/Button/Button";
import { Splash } from "@/src/scene/splash"; import { Splash } from "@/src/scene/splash";
import cx from "classnames"; import cx from "classnames";
import styles from "./Clan.module.css"; import styles from "./Clan.module.css";
import { Modal } from "@/src/components/Modal/Modal";
import { TextInput } from "@/src/components/Form/TextInput";
import { createForm, FieldValues, reset } from "@modular-forms/solid";
import { Sidebar } from "@/src/components/Sidebar/Sidebar"; import { Sidebar } from "@/src/components/Sidebar/Sidebar";
import { UseQueryResult } from "@tanstack/solid-query"; import { UseQueryResult } from "@tanstack/solid-query";
import { ListClansModal } from "@/src/modals/ListClansModal/ListClansModal"; import { ListClansModal } from "@/src/modals/ListClansModal/ListClansModal";
@@ -39,7 +43,6 @@ import {
} from "@/src/workflows/Service/Service"; } from "@/src/workflows/Service/Service";
import { useApiClient } from "@/src/hooks/ApiClient"; import { useApiClient } from "@/src/hooks/ApiClient";
import toast from "solid-toast"; import toast from "solid-toast";
import { AddMachine } from "@/src/workflows/AddMachine/AddMachine";
interface ClanContextProps { interface ClanContextProps {
clanURI: string; clanURI: string;
@@ -50,9 +53,6 @@ interface ClanContextProps {
isLoading(): boolean; isLoading(): boolean;
isError(): boolean; isError(): boolean;
showAddMachine(): boolean;
setShowAddMachine(value: boolean): void;
} }
class DefaultClanContext implements ClanContextProps { class DefaultClanContext implements ClanContextProps {
@@ -66,8 +66,6 @@ class DefaultClanContext implements ClanContextProps {
allQueries: UseQueryResult[]; allQueries: UseQueryResult[];
showAddMachineSignal: Signal<boolean>;
constructor( constructor(
clanURI: string, clanURI: string,
machinesQuery: MachinesQueryResult, machinesQuery: MachinesQueryResult,
@@ -82,8 +80,6 @@ class DefaultClanContext implements ClanContextProps {
this.allClansQueries = [activeClanQuery, ...otherClanQueries]; this.allClansQueries = [activeClanQuery, ...otherClanQueries];
this.allQueries = [machinesQuery, activeClanQuery, ...otherClanQueries]; this.allQueries = [machinesQuery, activeClanQuery, ...otherClanQueries];
this.showAddMachineSignal = createSignal(false);
} }
isLoading(): boolean { isLoading(): boolean {
@@ -93,16 +89,6 @@ class DefaultClanContext implements ClanContextProps {
isError(): boolean { isError(): boolean {
return this.activeClanQuery.isError; return this.activeClanQuery.isError;
} }
setShowAddMachine(value: boolean) {
const [_, setShow] = this.showAddMachineSignal;
setShow(value);
}
showAddMachine(): boolean {
const [show, _] = this.showAddMachineSignal;
return show();
}
} }
export const ClanContext = createContext<ClanContextProps>(); export const ClanContext = createContext<ClanContextProps>();
@@ -148,6 +134,56 @@ export const Clan: Component<RouteSectionProps> = (props) => {
); );
}; };
interface CreateFormValues extends FieldValues {
name: string;
}
interface MockProps {
onClose: () => void;
onSubmit: (formValues: CreateFormValues) => void;
}
const MockCreateMachine = (props: MockProps) => {
const [form, { Form, Field, FieldArray }] = createForm<CreateFormValues>();
return (
<Modal
open={true}
onClose={() => {
reset(form);
props.onClose();
}}
class={cx(styles.createModal)}
title="Create Machine"
>
<Form class="flex flex-col" onSubmit={props.onSubmit}>
<Field name="name">
{(field, props) => (
<>
<TextInput
{...field}
label="Name"
size="s"
required={true}
input={{ ...props, placeholder: "name", autofocus: true }}
/>
</>
)}
</Field>
<div class="mt-4 flex w-full items-center justify-end gap-4">
<Button size="s" hierarchy="secondary" onClick={props.onClose}>
Cancel
</Button>
<Button size="s" type="submit" hierarchy="primary" onClick={close}>
Create
</Button>
</div>
</Form>
</Modal>
);
};
const ClanSceneController = (props: RouteSectionProps) => { const ClanSceneController = (props: RouteSectionProps) => {
const ctx = useContext(ClanContext); const ctx = useContext(ClanContext);
if (!ctx) { if (!ctx) {
@@ -158,6 +194,7 @@ const ClanSceneController = (props: RouteSectionProps) => {
const [showService, setShowService] = createSignal(false); const [showService, setShowService] = createSignal(false);
const [showModal, setShowModal] = createSignal(false);
const [currentPromise, setCurrentPromise] = createSignal<{ const [currentPromise, setCurrentPromise] = createSignal<{
resolve: ({ id }: { id: string }) => void; resolve: ({ id }: { id: string }) => void;
reject: (err: unknown) => void; reject: (err: unknown) => void;
@@ -165,11 +202,45 @@ const ClanSceneController = (props: RouteSectionProps) => {
const onCreate = async (): Promise<{ id: string }> => { const onCreate = async (): Promise<{ id: string }> => {
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
ctx.setShowAddMachine(true); setShowModal(true);
setCurrentPromise({ resolve, reject }); setCurrentPromise({ resolve, reject });
}); });
}; };
const onAddService = async (): Promise<{ id: string }> => {
return new Promise((resolve, reject) => {
setShowService((v) => !v);
console.log("setting current promise");
setCurrentPromise({ resolve, reject });
});
};
const sendCreate = async (values: CreateFormValues) => {
const api = callApi("create_machine", {
opts: {
clan_dir: {
identifier: ctx.clanURI,
},
machine: {
name: values.name,
},
},
});
const res = await api.result;
if (res.status === "error") {
// TODO: Handle displaying errors
console.error("Error creating machine:");
// Important: rejects the promise
throw new Error(res.errors[0].message);
}
// trigger a refetch of the machines query
ctx.machinesQuery.refetch();
return { id: values.name };
};
const [loadingError, setLoadingError] = createSignal< const [loadingError, setLoadingError] = createSignal<
{ title: string; description: string } | undefined { title: string; description: string } | undefined
>(); >();
@@ -241,8 +312,9 @@ const ClanSceneController = (props: RouteSectionProps) => {
console.error("Error creating service instance", result.errors); console.error("Error creating service instance", result.errors);
} }
toast.success("Created"); toast.success("Created");
//
currentPromise()?.resolve({ id: "0" });
setShowService(false); setShowService(false);
setWorldMode("select");
}; };
createEffect( createEffect(
@@ -250,7 +322,7 @@ const ClanSceneController = (props: RouteSectionProps) => {
if (mode === "service") { if (mode === "service") {
setShowService(true); setShowService(true);
} else { } else {
// TODO: request soft close instead of forced close // todo: request close instead of force close
setShowService(false); setShowService(false);
} }
}), }),
@@ -261,18 +333,21 @@ const ClanSceneController = (props: RouteSectionProps) => {
<Show when={loadingError()}> <Show when={loadingError()}>
<ListClansModal error={loadingError()} /> <ListClansModal error={loadingError()} />
</Show> </Show>
<Show when={ctx.showAddMachine()}> <Show when={showModal()}>
<AddMachine <MockCreateMachine
onCreated={async (id) => {
const promise = currentPromise();
if (promise) {
await ctx.machinesQuery.refetch();
promise.resolve({ id });
setCurrentPromise(null);
}
}}
onClose={() => { onClose={() => {
ctx.setShowAddMachine(false); setShowModal(false);
currentPromise()?.reject(new Error("User cancelled"));
}}
onSubmit={async (values) => {
try {
const result = await sendCreate(values);
currentPromise()?.resolve(result);
setShowModal(false);
} catch (err) {
currentPromise()?.reject(err);
setShowModal(false);
}
}} }}
/> />
</Show> </Show>
@@ -295,7 +370,7 @@ const ClanSceneController = (props: RouteSectionProps) => {
handleSubmit={handleSubmitService} handleSubmit={handleSubmitService}
onClose={() => { onClose={() => {
setShowService(false); setShowService(false);
setWorldMode("select"); setWorldMode("default");
currentPromise()?.resolve({ id: "0" }); currentPromise()?.resolve({ id: "0" });
}} }}
/> />

View File

@@ -20,8 +20,7 @@ export const Machine = (props: RouteSectionProps) => {
navigateToClan(navigate, clanURI); navigateToClan(navigate, clanURI);
}; };
const sections = () => { const sidebarPane = (machineName: string) => {
const machineName = useMachineName();
const machineQuery = useMachineQuery(clanURI, machineName); const machineQuery = useMachineQuery(clanURI, machineName);
// we have to update the whole machine model rather than just the sub fields that were changed // we have to update the whole machine model rather than just the sub fields that were changed
@@ -52,35 +51,25 @@ export const Machine = (props: RouteSectionProps) => {
const sectionProps = { clanURI, machineName, onSubmit, machineQuery }; const sectionProps = { clanURI, machineName, onSubmit, machineQuery };
return ( return (
<> <div class={styles.sidebarPaneContainer}>
<SidebarSectionInstall <SidebarPane
clanURI={clanURI} title={machineName}
machineName={useMachineName()} onClose={onClose}
/> subHeader={() => (
<SectionGeneral {...sectionProps} /> <SidebarMachineStatus clanURI={clanURI} machineName={machineName} />
<SectionTags {...sectionProps} /> )}
</> >
<SidebarSectionInstall clanURI={clanURI} machineName={machineName} />
<SectionGeneral {...sectionProps} />
<SectionTags {...sectionProps} />
</SidebarPane>
</div>
); );
}; };
return ( return (
<Show when={useMachineName()}> <Show when={useMachineName()} keyed>
<div class={styles.sidebarPaneContainer}> {sidebarPane(useMachineName())}
<SidebarPane
title={useMachineName()}
onClose={onClose}
subHeader={
<Show when={useMachineName()} keyed>
<SidebarMachineStatus
clanURI={clanURI}
machineName={useMachineName()}
/>
</Show>
}
>
{sections()}
</SidebarPane>
</div>
</Show> </Show>
); );
}; };

View File

@@ -39,10 +39,10 @@ export class MachineManager {
const actualIds = Object.keys(machinesQueryResult.data); const actualIds = Object.keys(machinesQueryResult.data);
const machinePositions = machinePositionsSignal(); const machinePositions = machinePositionsSignal();
// Remove stale // Remove stale
for (const id of Object.keys(machinePositions)) { for (const id of Object.keys(machinePositions)) {
if (!actualIds.includes(id)) { if (!actualIds.includes(id)) {
console.log("Removing stale machine", id);
setMachinePos(id, null); setMachinePos(id, null);
} }
} }
@@ -61,11 +61,10 @@ export class MachineManager {
// //
createEffect(() => { createEffect(() => {
const positions = machinePositionsSignal(); const positions = machinePositionsSignal();
if (!positions) return;
// Remove machines from scene // Remove machines from scene
for (const [id, repr] of this.machines) { for (const [id, repr] of this.machines) {
if (!Object.keys(positions).includes(id)) { if (!(id in positions)) {
repr.dispose(scene); repr.dispose(scene);
this.machines.delete(id); this.machines.delete(id);
} }

View File

@@ -1,6 +1,7 @@
.cubes-scene-container { .cubes-scene-container {
width: 100%; width: 100%;
height: 100vh; height: 100vh;
cursor: pointer;
} }
/* <div class="absolute bottom-4 left-1/2 flex -translate-x-1/2 flex-col items-center"> /* <div class="absolute bottom-4 left-1/2 flex -translate-x-1/2 flex-col items-center">

View File

@@ -21,7 +21,6 @@ import { Accessor } from "solid-js";
import { renderLoop } from "./RenderLoop"; import { renderLoop } from "./RenderLoop";
import { ObjectRegistry } from "./ObjectRegistry"; import { ObjectRegistry } from "./ObjectRegistry";
import { MachineManager } from "./MachineManager"; import { MachineManager } from "./MachineManager";
import cx from "classnames";
function garbageCollectGroup(group: THREE.Group) { function garbageCollectGroup(group: THREE.Group) {
for (const child of group.children) { for (const child of group.children) {
@@ -65,7 +64,7 @@ export function useMachineClick() {
/*Gloabl signal*/ /*Gloabl signal*/
const [worldMode, setWorldMode] = createSignal< const [worldMode, setWorldMode] = createSignal<
"default" | "select" | "service" | "create" "default" | "select" | "service" | "create"
>("select"); >("default");
export { worldMode, setWorldMode }; export { worldMode, setWorldMode };
export function CubeScene(props: { export function CubeScene(props: {
@@ -102,8 +101,6 @@ export function CubeScene(props: {
const [positionMode, setPositionMode] = createSignal<"grid" | "circle">( const [positionMode, setPositionMode] = createSignal<"grid" | "circle">(
"grid", "grid",
); );
// Managed by controls
const [isDragging, setIsDragging] = createSignal(false);
const [cursorPosition, setCursorPosition] = createSignal<[number, number]>(); const [cursorPosition, setCursorPosition] = createSignal<[number, number]>();
@@ -276,13 +273,6 @@ export function CubeScene(props: {
bgCamera, bgCamera,
); );
controls.addEventListener("start", (e) => {
setIsDragging(true);
});
controls.addEventListener("end", (e) => {
setIsDragging(false);
});
// Lighting // Lighting
const ambientLight = new THREE.AmbientLight(0xd9f2f7, 0.72); const ambientLight = new THREE.AmbientLight(0xd9f2f7, 0.72);
scene.add(ambientLight); scene.add(ambientLight);
@@ -592,17 +582,7 @@ export function CubeScene(props: {
return ( return (
<> <>
<div <div class="cubes-scene-container" ref={(el) => (container = el)} />
class={cx(
"cubes-scene-container",
worldMode() === "default" && "cursor-no-drop",
worldMode() === "select" && "cursor-pointer",
worldMode() === "service" && "cursor-pointer",
worldMode() === "create" && "cursor-cell",
isDragging() && "!cursor-grabbing",
)}
ref={(el) => (container = el)}
/>
<div class="toolbar-container"> <div class="toolbar-container">
<div class="absolute bottom-full left-1/2 mb-2 -translate-x-1/2"> <div class="absolute bottom-full left-1/2 mb-2 -translate-x-1/2">
{props.toolbarPopup} {props.toolbarPopup}
@@ -612,7 +592,9 @@ export function CubeScene(props: {
description="Select machine" description="Select machine"
name="Select" name="Select"
icon="Cursor" icon="Cursor"
onClick={() => setWorldMode("select")} onClick={() =>
setWorldMode((v) => (v === "select" ? "default" : "select"))
}
selected={worldMode() === "select"} selected={worldMode() === "select"}
/> />
<ToolbarButton <ToolbarButton
@@ -629,11 +611,11 @@ export function CubeScene(props: {
icon="Services" icon="Services"
selected={worldMode() === "service"} selected={worldMode() === "service"}
onClick={() => { onClick={() => {
setWorldMode("service"); setWorldMode((v) => (v === "service" ? "default" : "service"));
}} }}
/> />
<ToolbarButton <ToolbarButton
icon="Update" icon="Reload"
name="Reload" name="Reload"
description="Reload machines" description="Reload machines"
onClick={() => machinesQuery.refetch()} onClick={() => machinesQuery.refetch()}

View File

@@ -1,119 +0,0 @@
import type { Meta, StoryContext, StoryObj } from "@kachurun/storybook-solid";
import { AddMachine } from "@/src/workflows/AddMachine/AddMachine";
import {
createMemoryHistory,
MemoryRouter,
RouteDefinition,
} from "@solidjs/router";
import { QueryClient, QueryClientProvider } from "@tanstack/solid-query";
import { ApiClientProvider, Fetcher } from "@/src/hooks/ApiClient";
import {
ApiCall,
OperationNames,
OperationResponse,
SuccessQuery,
} from "@/src/hooks/api";
type ResultDataMap = {
[K in OperationNames]: SuccessQuery<K>["data"];
};
const mockFetcher: Fetcher = <K extends OperationNames>(
name: K,
_args: unknown,
): ApiCall<K> => {
// TODO: Make this configurable for every story
const resultData: Partial<ResultDataMap> = {
list_machines: {
pandora: {
name: "pandora",
},
enceladus: {
name: "enceladus",
},
dione: {
name: "dione",
},
},
};
return {
uuid: "mock",
cancel: () => Promise.resolve(),
result: new Promise((resolve) => {
setTimeout(() => {
resolve({
op_key: "1",
status: "success",
data: resultData[name],
} as OperationResponse<K>);
}, 1500);
}),
};
};
const meta: Meta<typeof AddMachine> = {
title: "workflows/add-machine",
component: AddMachine,
decorators: [
(Story: StoryObj, context: StoryContext) => {
const Routes: RouteDefinition[] = [
{
path: "/clans/:clanURI",
component: () => (
<div class="w-[600px]">
<Story />
</div>
),
},
];
const history = createMemoryHistory();
history.set({ value: "/clans/dGVzdA==", replace: true });
const queryClient = new QueryClient();
return (
<ApiClientProvider client={{ fetch: mockFetcher }}>
<QueryClientProvider client={queryClient}>
<MemoryRouter
root={(props) => {
console.debug("Rendering MemoryRouter root with props:", props);
return props.children;
}}
history={history}
>
{Routes}
</MemoryRouter>
</QueryClientProvider>
</ApiClientProvider>
);
},
],
};
export default meta;
type Story = StoryObj<typeof AddMachine>;
export const General: Story = {
args: {},
};
export const Host: Story = {
args: {
initialStep: "host",
},
};
export const Tags: Story = {
args: {
initialStep: "tags",
},
};
export const Progress: Story = {
args: {
initialStep: "progress",
},
};

View File

@@ -1,136 +0,0 @@
import {
createStepper,
defineSteps,
StepperProvider,
useStepper,
} from "@/src/hooks/stepper";
import {
GeneralForm,
StepGeneral,
} from "@/src/workflows/AddMachine/StepGeneral";
import { Modal } from "@/src/components/Modal/Modal";
import cx from "classnames";
import { Dynamic } from "solid-js/web";
import { Show } from "solid-js";
import { Typography } from "@/src/components/Typography/Typography";
import { StepHost } from "@/src/workflows/AddMachine/StepHost";
import { StepTags } from "@/src/workflows/AddMachine/StepTags";
import { StepProgress } from "./StepProgress";
interface AddMachineStepperProps {
onDone: () => void;
}
const AddMachineStepper = (props: AddMachineStepperProps) => {
const stepSignal = useStepper<AddMachineSteps>();
return (
<Dynamic
component={stepSignal.currentStep().content}
onDone={props.onDone}
/>
);
};
export interface AddMachineProps {
onClose: () => void;
onCreated: (id: string) => void;
initialStep?: AddMachineSteps[number]["id"];
}
export interface AddMachineStoreType {
general: GeneralForm;
deploy: {
targetHost: string;
};
tags: {
tags: string[];
};
onCreated: (id: string) => void;
error?: string;
}
const steps = defineSteps([
{
id: "general",
title: "General",
content: StepGeneral,
},
{
id: "host",
title: "Host",
content: StepHost,
},
{
id: "tags",
title: "Tags",
content: StepTags,
},
{
id: "progress",
title: "Creating...",
content: StepProgress,
isSplash: true,
},
] as const);
export type AddMachineSteps = typeof steps;
export const AddMachine = (props: AddMachineProps) => {
const stepper = createStepper(
{
steps,
},
{
initialStep: props.initialStep || "general",
initialStoreData: { onCreated: props.onCreated },
},
);
const MetaHeader = () => {
const title = stepper.currentStep().title;
return (
<Show when={title}>
<Typography
hierarchy="label"
family="mono"
size="default"
weight="medium"
>
{title}
</Typography>
</Show>
);
};
const sizeClasses = () => {
const defaultClass = "max-w-3xl h-fit";
const currentStep = stepper.currentStep();
if (!currentStep) {
return defaultClass;
}
switch (currentStep.id) {
default:
return defaultClass;
}
};
return (
<StepperProvider stepper={stepper}>
<Modal
class={cx("w-screen", sizeClasses())}
title="Add Machine"
onClose={props.onClose}
open={true}
// @ts-expect-error some steps might not have
metaHeader={stepper.currentStep()?.title ? <MetaHeader /> : undefined}
// @ts-expect-error some steps might not have
disablePadding={stepper.currentStep()?.isSplash}
>
<AddMachineStepper onDone={() => props.onClose()} />
</Modal>
</StepperProvider>
);
};

View File

@@ -1,176 +0,0 @@
import { NextButton, StepLayout } from "@/src/workflows/Steps";
import * as v from "valibot";
import { getStepStore, useStepper } from "@/src/hooks/stepper";
import {
clearError,
createForm,
FieldValues,
getError,
getErrors,
setError,
SubmitHandler,
valiForm,
} from "@modular-forms/solid";
import {
AddMachineSteps,
AddMachineStoreType,
} from "@/src/workflows/AddMachine/AddMachine";
import { Fieldset } from "@/src/components/Form/Fieldset";
import { TextInput } from "@/src/components/Form/TextInput";
import { Divider } from "@/src/components/Divider/Divider";
import { TextArea } from "@/src/components/Form/TextArea";
import { Select } from "@/src/components/Select/Select";
import { Show } from "solid-js";
import { Alert } from "@/src/components/Alert/Alert";
import { useMachinesQuery } from "@/src/hooks/queries";
import { useClanURI } from "@/src/hooks/clan";
const PlatformOptions = [
{ label: "NixOS", value: "nixos" },
{ label: "Darwin", value: "darwin" },
];
const GeneralSchema = v.object({
name: v.pipe(
v.string("Name must be a string"),
v.nonEmpty("Please enter a machine name"),
v.regex(
new RegExp(/^(?!-)[A-Za-z0-9-]{1,63}(?<!-)$/),
"Name must be a valid hostname e.g. alphanumeric characters and - only",
),
),
description: v.optional(v.string("Description must be a string")),
machineClass: v.pipe(v.string(), v.nonEmpty()),
});
export interface GeneralForm extends FieldValues {
machineClass: "nixos" | "darwin";
name: string;
description?: string;
}
export const StepGeneral = () => {
const stepSignal = useStepper<AddMachineSteps>();
const [store, set] = getStepStore<AddMachineStoreType>(stepSignal);
const clanURI = useClanURI();
const machines = useMachinesQuery(clanURI);
const machineNames = () => {
if (!machines.isSuccess) {
return [];
}
return Object.keys(machines.data || {});
};
const [formStore, { Form, Field }] = createForm<GeneralForm>({
validate: valiForm(GeneralSchema),
initialValues: { ...store.general, machineClass: "nixos" },
});
const handleSubmit: SubmitHandler<GeneralForm> = (values, event) => {
if (machineNames().includes(values.name)) {
setError(
formStore,
"name",
`A machine named '${values.name}' already exists. Please choose a different one.`,
);
return;
}
clearError(formStore, "name");
set("general", (s) => ({
...s,
...values,
}));
stepSignal.next();
};
const formError = () => {
const errors = getErrors(formStore);
return errors.name || errors.description || errors.machineClass;
};
return (
<Form onSubmit={handleSubmit} class="h-full">
<StepLayout
body={
<div class="flex flex-col gap-2">
<Show when={formError()}>
<Alert
type="error"
icon="WarningFilled"
title="Error"
description={formError()}
/>
</Show>
<Fieldset>
<Field name="name">
{(field, input) => (
<TextInput
{...field}
value={field.value}
label="Name"
required
orientation="horizontal"
input={{
...input,
placeholder: "A unique machine name.",
}}
validationState={
getError(formStore, "name") ? "invalid" : "valid"
}
/>
)}
</Field>
<Divider />
<Field name="description">
{(field, input) => (
<TextArea
{...field}
value={field.value}
label="Description"
orientation="horizontal"
input={{
...input,
placeholder: "A short description of the machine.",
}}
validationState={
getError(formStore, "description") ? "invalid" : "valid"
}
/>
)}
</Field>
</Fieldset>
<Fieldset>
<Field name="machineClass">
{(field, props) => (
<Select
zIndex={100}
{...props}
value={field.value}
error={field.error}
required
label={{
label: "Platform",
}}
options={PlatformOptions}
name={field.name}
/>
)}
</Field>
</Fieldset>
</div>
}
footer={
<div class="flex justify-end">
<NextButton type="submit" />
</div>
}
/>
</Form>
);
};

View File

@@ -1,76 +0,0 @@
import { BackButton, NextButton, StepLayout } from "@/src/workflows/Steps";
import * as v from "valibot";
import { getStepStore, useStepper } from "@/src/hooks/stepper";
import {
createForm,
getError,
SubmitHandler,
valiForm,
} from "@modular-forms/solid";
import {
AddMachineSteps,
AddMachineStoreType,
} from "@/src/workflows/AddMachine/AddMachine";
import { Fieldset } from "@/src/components/Form/Fieldset";
import { TextInput } from "@/src/components/Form/TextInput";
const HostSchema = v.object({
targetHost: v.pipe(v.string("Name must be a string")),
});
type HostForm = v.InferInput<typeof HostSchema>;
export const StepHost = () => {
const stepSignal = useStepper<AddMachineSteps>();
const [store, set] = getStepStore<AddMachineStoreType>(stepSignal);
const [formStore, { Form, Field }] = createForm<HostForm>({
validate: valiForm(HostSchema),
initialValues: store.deploy,
});
const handleSubmit: SubmitHandler<HostForm> = (values, event) => {
set("deploy", (s) => ({
...s,
...values,
}));
stepSignal.next();
};
return (
<Form onSubmit={handleSubmit} class="h-full">
<StepLayout
body={
<div class="flex flex-col gap-2">
<Fieldset>
<Field name="targetHost">
{(field, input) => (
<TextInput
{...field}
value={field.value}
label="Target"
orientation="horizontal"
input={{
...input,
placeholder: "root@flashinstaller.local",
}}
validationState={
getError(formStore, "targetHost") ? "invalid" : "valid"
}
/>
)}
</Field>
</Fieldset>
</div>
}
footer={
<div class="flex justify-between">
<BackButton />
<NextButton type="submit" />
</div>
}
/>
</Form>
);
};

View File

@@ -1,40 +0,0 @@
import { getStepStore, useStepper } from "@/src/hooks/stepper";
import {
AddMachineSteps,
AddMachineStoreType,
} from "@/src/workflows/AddMachine/AddMachine";
import { Loader } from "@/src/components/Loader/Loader";
import { Typography } from "@/src/components/Typography/Typography";
import { Show } from "solid-js";
import { Alert } from "@/src/components/Alert/Alert";
export interface StepProgressProps {
onDone: () => void;
}
export const StepProgress = (props: StepProgressProps) => {
const stepSignal = useStepper<AddMachineSteps>();
const [store, set] = getStepStore<AddMachineStoreType>(stepSignal);
return (
<div class="flex flex-col items-center justify-center gap-2.5 px-6 pb-7 pt-4">
<Show
when={store.error}
fallback={
<>
<Loader class="size-8" />
<Typography hierarchy="body" size="s" weight="medium" family="mono">
{store.general?.name} is being created
</Typography>
</>
}
>
<Alert
type="error"
title="There was an error"
description={store.error}
/>
</Show>
</div>
);
};

View File

@@ -1,102 +0,0 @@
import { BackButton, StepLayout } from "@/src/workflows/Steps";
import * as v from "valibot";
import { getStepStore, useStepper } from "@/src/hooks/stepper";
import { createForm, SubmitHandler, valiForm } from "@modular-forms/solid";
import {
AddMachineSteps,
AddMachineStoreType,
} from "@/src/workflows/AddMachine/AddMachine";
import { Fieldset } from "@/src/components/Form/Fieldset";
import { MachineTags } from "@/src/components/Form/MachineTags";
import { Button } from "@/src/components/Button/Button";
import { useApiClient } from "@/src/hooks/ApiClient";
import { useClanURI } from "@/src/hooks/clan";
const TagsSchema = v.object({
tags: v.array(v.string()),
});
type TagsForm = v.InferInput<typeof TagsSchema>;
export const StepTags = (props: { onDone: () => void }) => {
const stepSignal = useStepper<AddMachineSteps>();
const [store, set] = getStepStore<AddMachineStoreType>(stepSignal);
const [formStore, { Form, Field }] = createForm<TagsForm>({
validate: valiForm(TagsSchema),
initialValues: store.tags,
});
const apiClient = useApiClient();
const clanURI = useClanURI();
const handleSubmit: SubmitHandler<TagsForm> = async (values, event) => {
set("tags", (s) => ({
...s,
...values,
}));
const call = apiClient.fetch("create_machine", {
opts: {
clan_dir: {
identifier: clanURI,
},
machine: {
...store.general,
...store.tags,
deploy: store.deploy,
},
},
});
stepSignal.next();
const result = await call.result;
if (result.status == "error") {
// setError(result.errors[0].message);
}
if (result.status == "success") {
console.log("Machine creation was successful");
if (store.general) {
store.onCreated(store.general.name);
}
}
console.log("Done creating machine");
props.onDone();
};
return (
<Form onSubmit={handleSubmit} class="h-full">
<StepLayout
body={
<div class="flex flex-col gap-2">
<Fieldset>
<Field name="tags" type="string[]">
{(field, input) => (
<MachineTags
{...field}
required
orientation="horizontal"
defaultValue={field.value}
defaultOptions={[]}
input={input}
/>
)}
</Field>
</Fieldset>
</div>
}
footer={
<div class="flex justify-between">
<BackButton />
<Button hierarchy="primary" type="submit" endIcon="Flash">
Create Machine
</Button>
</div>
}
/>
</Form>
);
};

View File

@@ -1,5 +1,5 @@
import type { Meta, StoryContext, StoryObj } from "@kachurun/storybook-solid"; import type { Meta, StoryContext, StoryObj } from "@kachurun/storybook-solid";
import { InstallModal } from "./InstallMachine"; import { InstallModal } from "./install";
import { import {
createMemoryHistory, createMemoryHistory,
MemoryRouter, MemoryRouter,

View File

@@ -1,5 +1,5 @@
import { defineSteps, useStepper } from "@/src/hooks/stepper"; import { defineSteps, useStepper } from "@/src/hooks/stepper";
import { InstallSteps } from "../InstallMachine"; import { InstallSteps } from "../install";
import { Button } from "@/src/components/Button/Button"; import { Button } from "@/src/components/Button/Button";
import { StepLayout } from "../../Steps"; import { StepLayout } from "../../Steps";
import { NavSection } from "@/src/components/NavSection/NavSection"; import { NavSection } from "@/src/components/NavSection/NavSection";

View File

@@ -6,7 +6,7 @@ import {
valiForm, valiForm,
} from "@modular-forms/solid"; } from "@modular-forms/solid";
import * as v from "valibot"; import * as v from "valibot";
import { InstallSteps, InstallStoreType } from "../InstallMachine"; import { InstallSteps, InstallStoreType } from "../install";
import { Fieldset } from "@/src/components/Form/Fieldset"; import { Fieldset } from "@/src/components/Form/Fieldset";
import { HostFileInput } from "@/src/components/Form/HostFileInput"; import { HostFileInput } from "@/src/components/Form/HostFileInput";
import { Select } from "@/src/components/Select/Select"; import { Select } from "@/src/components/Select/Select";

View File

@@ -11,11 +11,7 @@ import {
import { Fieldset } from "@/src/components/Form/Fieldset"; import { Fieldset } from "@/src/components/Form/Fieldset";
import * as v from "valibot"; import * as v from "valibot";
import { getStepStore, useStepper } from "@/src/hooks/stepper"; import { getStepStore, useStepper } from "@/src/hooks/stepper";
import { import { InstallSteps, InstallStoreType, PromptValues } from "../install";
InstallSteps,
InstallStoreType,
PromptValues,
} from "../InstallMachine";
import { TextInput } from "@/src/components/Form/TextInput"; import { TextInput } from "@/src/components/Form/TextInput";
import { Alert } from "@/src/components/Alert/Alert"; import { Alert } from "@/src/components/Alert/Alert";
import { createSignal, For, Match, Show, Switch } from "solid-js"; import { createSignal, For, Match, Show, Switch } from "solid-js";

View File

@@ -24,42 +24,59 @@ const mockFetcher: Fetcher = <K extends OperationNames>(
): ApiCall<K> => { ): ApiCall<K> => {
// TODO: Make this configurable for every story // TODO: Make this configurable for every story
const resultData: Partial<ResultDataMap> = { const resultData: Partial<ResultDataMap> = {
list_service_modules: { list_service_modules: [
core_input_name: "clan-core", {
modules: [ module: { name: "Borgbackup", input: "clan-core" },
{ info: {
usage_ref: { name: "Borgbackup", input: null }, manifest: {
instance_refs: [], name: "Borgbackup",
native: true, description: "This is module A",
info: { },
manifest: { roles: {
name: "Borgbackup", client: null,
description: "This is module A", server: null,
},
roles: {
client: null,
server: null,
},
}, },
}, },
{ },
usage_ref: { name: "Zerotier", input: "fublub" }, {
instance_refs: [], module: { name: "Zerotier", input: "clan-core" },
native: false, info: {
info: { manifest: {
manifest: { name: "Zerotier",
name: "Zerotier", description: "This is module B",
description: "This is module B", },
}, roles: {
roles: { peer: null,
peer: null, moon: null,
moon: null, controller: null,
controller: null,
},
}, },
}, },
], },
}, {
module: { name: "Admin", input: "clan-core" },
info: {
manifest: {
name: "Admin",
description: "This is module B",
},
roles: {
default: null,
},
},
},
{
module: { name: "Garage", input: "lo-l" },
info: {
manifest: {
name: "Garage",
description: "This is module B",
},
roles: {
default: null,
},
},
},
],
list_machines: { list_machines: {
jon: { jon: {
name: "jon", name: "jon",

View File

@@ -45,11 +45,11 @@ import {
} from "@/src/scene/highlightStore"; } from "@/src/scene/highlightStore";
import { useClickOutside } from "@/src/hooks/useClickOutside"; import { useClickOutside } from "@/src/hooks/useClickOutside";
type ModuleItem = ServiceModules["modules"][number]; type ModuleItem = ServiceModules[number];
interface Module { interface Module {
value: string; value: string;
input?: string | null; input?: string;
label: string; label: string;
description: string; description: string;
raw: ModuleItem; raw: ModuleItem;
@@ -68,13 +68,20 @@ const SelectService = () => {
createEffect(() => { createEffect(() => {
if (serviceModulesQuery.data && serviceInstancesQuery.data) { if (serviceModulesQuery.data && serviceInstancesQuery.data) {
setModuleOptions( setModuleOptions(
serviceModulesQuery.data.modules.map((currService) => ({ serviceModulesQuery.data.map((m) => ({
value: `${currService.usage_ref.name}:${currService.usage_ref.input}`, value: `${m.module.name}:${m.module.input}`,
label: currService.info.manifest.name, label: m.module.name,
description: currService.info.manifest.description, description: m.info.manifest.description,
input: currService.usage_ref.input, input: m.module.input,
raw: currService, raw: m,
instances: currService.instance_refs, // TODO: include the instances that use this module
instances: Object.entries(serviceInstancesQuery.data)
.filter(
([name, i]) =>
i.module?.name === m.module.name &&
(!i.module?.input || i.module?.input === m.module.input),
)
.map(([name, _]) => name),
})), })),
); );
} }
@@ -89,8 +96,8 @@ const SelectService = () => {
if (!module) return; if (!module) return;
set("module", { set("module", {
name: module.raw.usage_ref.name, name: module.raw.module.name,
input: module.raw.usage_ref.input, input: module.raw.module.input,
raw: module.raw, raw: module.raw,
}); });
// TODO: Ideally we need to ask // TODO: Ideally we need to ask
@@ -176,13 +183,11 @@ const SelectService = () => {
inverted inverted
class="flex justify-between" class="flex justify-between"
> >
<span class="inline-block max-w-48 truncate align-middle"> <span class="inline-block max-w-32 truncate align-middle">
{item.description} {item.description}
</span> </span>
<span class="inline-block max-w-12 truncate align-middle"> <span class="inline-block max-w-8 truncate align-middle">
<Show when={!item.raw.native} fallback="by clan-core"> by {item.input}
by {item.input}
</Show>
</span> </span>
</Typography> </Typography>
</div> </div>
@@ -533,7 +538,7 @@ export interface InventoryInstance {
name: string; name: string;
module: { module: {
name: string; name: string;
input?: string | null; input?: string;
}; };
roles: Record<string, RoleType>; roles: Record<string, RoleType>;
} }
@@ -546,7 +551,7 @@ interface RoleType {
export interface ServiceStoreType { export interface ServiceStoreType {
module: { module: {
name: string; name: string;
input?: string | null; input: string;
raw?: ModuleItem; raw?: ModuleItem;
}; };
roles: Record<string, TagType[]>; roles: Record<string, TagType[]>;

View File

@@ -1,7 +1,7 @@
import { JSX } from "solid-js"; import { JSX } from "solid-js";
import { useStepper } from "../hooks/stepper"; import { useStepper } from "../hooks/stepper";
import { Button, ButtonProps } from "../components/Button/Button"; import { Button, ButtonProps } from "../components/Button/Button";
import { InstallSteps } from "@/src/workflows/InstallMachine/InstallMachine"; import { InstallSteps } from "./Install/install";
import styles from "./Steps.module.css"; import styles from "./Steps.module.css";
interface StepLayoutProps { interface StepLayoutProps {

View File

@@ -29,7 +29,7 @@ class FactStore(StoreBase):
value: bytes, value: bytes,
) -> Path | None: ) -> Path | None:
if not self.flake.is_local: if not self.flake.is_local:
msg = f"Storing var '{var.id}' in a flake is only supported for local flakes: {self.flake}" msg = f"in_flake fact storage is only supported for local flakes: {self.flake}"
raise ClanError(msg) raise ClanError(msg)
folder = self.directory(generator, var.name) folder = self.directory(generator, var.name)
file_path = folder / "value" file_path = folder / "value"

View File

@@ -1,11 +1,11 @@
{ {
inputs.Y2xhbi1jaW9yZS1uZXZlci1kZXBlbmQtb24tbWU.url = "https://git.clan.lol/clan/clan-core/archive/main.tar.gz"; inputs.clan-core.url = "https://git.clan.lol/clan/clan-core/archive/main.tar.gz";
inputs.nixpkgs.follows = "Y2xhbi1jaW9yZS1uZXZlci1kZXBlbmQtb24tbWU/nixpkgs"; inputs.nixpkgs.follows = "clan-core/nixpkgs";
outputs = outputs =
{ self, Y2xhbi1jaW9yZS1uZXZlci1kZXBlbmQtb24tbWU, ... }: { self, clan-core, ... }:
let let
clan = Y2xhbi1jaW9yZS1uZXZlci1kZXBlbmQtb24tbWU.lib.clan ({ clan = clan-core.lib.clan ({
inherit self; inherit self;
imports = [ imports = [
./clan.nix ./clan.nix

View File

@@ -1,7 +1,6 @@
import logging import logging
import os import os
from dataclasses import dataclass from dataclasses import dataclass
from enum import Enum
from pathlib import Path from pathlib import Path
from tempfile import TemporaryDirectory from tempfile import TemporaryDirectory
from time import time from time import time
@@ -26,13 +25,14 @@ log = logging.getLogger(__name__)
BuildOn = Literal["auto", "local", "remote"] BuildOn = Literal["auto", "local", "remote"]
class Step(str, Enum): Step = Literal[
GENERATORS = "generators" "generators",
UPLOAD_SECRETS = "upload-secrets" "upload-secrets",
NIXOS_ANYWHERE = "nixos-anywhere" "nixos-anywhere",
FORMATTING = "formatting" "formatting",
REBOOTING = "rebooting" "rebooting",
INSTALLING = "installing" "installing",
]
def notify_install_step(current: Step) -> None: def notify_install_step(current: Step) -> None:
@@ -93,7 +93,7 @@ def run_machine_install(opts: InstallOptions, target_host: Remote) -> None:
) )
# Notify the UI about what we are doing # Notify the UI about what we are doing
notify_install_step(Step.GENERATORS) notify_install_step("generators")
generate_facts([machine]) generate_facts([machine])
run_generators([machine], generators=None, full_closure=False) run_generators([machine], generators=None, full_closure=False)
@@ -106,7 +106,7 @@ def run_machine_install(opts: InstallOptions, target_host: Remote) -> None:
upload_dir.mkdir(parents=True) upload_dir.mkdir(parents=True)
# Notify the UI about what we are doing # Notify the UI about what we are doing
notify_install_step(Step.UPLOAD_SECRETS) notify_install_step("upload-secrets")
machine.secret_facts_store.upload(upload_dir) machine.secret_facts_store.upload(upload_dir)
machine.secret_vars_store.populate_dir( machine.secret_vars_store.populate_dir(
machine.name, machine.name,
@@ -214,15 +214,15 @@ def run_machine_install(opts: InstallOptions, target_host: Remote) -> None:
cmd, cmd,
) )
install_steps = { default_install_steps: dict[str, Step] = {
"kexec": Step.NIXOS_ANYWHERE, "kexec": "nixos-anywhere",
"disko": Step.FORMATTING, "disko": "formatting",
"install": Step.INSTALLING, "install": "installing",
"reboot": Step.REBOOTING, "reboot": "rebooting",
} }
def run_phase(phase: str) -> None: def run_phase(phase: str) -> None:
notification = install_steps.get(phase, Step.NIXOS_ANYWHERE) notification = default_install_steps.get(phase, "nixos-anywhere")
notify_install_step(notification) notify_install_step(notification)
run( run(
[*cmd, "--phases", phase], [*cmd, "--phases", phase],

View File

@@ -13,7 +13,7 @@ class Unknown:
InventoryInstanceModuleNameType = str InventoryInstanceModuleNameType = str
InventoryInstanceModuleInputType = str | None InventoryInstanceModuleInputType = str
class InventoryInstanceModule(TypedDict): class InventoryInstanceModule(TypedDict):
name: str name: str
@@ -163,7 +163,7 @@ class Template(TypedDict):
ClanDirectoryType = dict[str, Any] | list[Any] | bool | float | int | str | None ClanDirectoryType = dict[str, Any] | list[Any] | bool | float | int | str
ClanInventoryType = Inventory ClanInventoryType = Inventory
ClanMachinesType = dict[str, Unknown] ClanMachinesType = dict[str, Unknown]
ClanMetaType = Unknown ClanMetaType = Unknown

View File

@@ -0,0 +1,112 @@
from typing import TypedDict
from clan_lib.api import API
from clan_lib.errors import ClanError
from clan_lib.flake.flake import Flake
from clan_lib.nix_models.clan import (
InventoryInstanceModule,
InventoryInstanceRolesType,
InventoryInstancesType,
InventoryMachinesType,
)
from clan_lib.persist.inventory_store import InventoryStore
from clan_lib.persist.util import set_value_by_path
from clan_lib.services.modules import (
get_service_module,
)
# TODO: move imports out of cli/__init__.py causing import cycles
# from clan_lib.machines.actions import list_machines
@API.register
def list_service_instances(flake: Flake) -> InventoryInstancesType:
"""Returns all currently present service instances including their full configuration"""
inventory_store = InventoryStore(flake)
inventory = inventory_store.read()
return inventory.get("instances", {})
def collect_tags(machines: InventoryMachinesType) -> set[str]:
res = set()
for machine in machines.values():
res |= set(machine.get("tags", []))
return res
# Removed 'module' ref - Needs to be passed seperately
class InstanceConfig(TypedDict):
roles: InventoryInstanceRolesType
@API.register
def create_service_instance(
flake: Flake,
module_ref: InventoryInstanceModule,
instance_name: str,
instance_config: InstanceConfig,
) -> None:
module = get_service_module(flake, module_ref)
inventory_store = InventoryStore(flake)
inventory = inventory_store.read()
instances = inventory.get("instances", {})
if instance_name in instances:
msg = f"service instance '{instance_name}' already exists."
raise ClanError(msg)
target_roles = instance_config.get("roles")
if not target_roles:
msg = "Creating a service instance requires adding roles"
raise ClanError(msg)
available_roles = set(module.get("roles", {}).keys())
unavailable_roles = list(filter(lambda r: r not in available_roles, target_roles))
if unavailable_roles:
msg = f"Unknown roles: {unavailable_roles}. Use one of {available_roles}"
raise ClanError(msg)
role_configs = instance_config.get("roles")
if not role_configs:
return
## Validate machine references
all_machines = inventory.get("machines", {})
available_machine_refs = set(all_machines.keys())
available_tag_refs = collect_tags(all_machines)
for role_name, role_members in role_configs.items():
machine_refs = role_members.get("machines")
msg = f"Role: '{role_name}' - "
if machine_refs:
unavailable_machines = list(
filter(lambda m: m not in available_machine_refs, machine_refs),
)
if unavailable_machines:
msg += f"Unknown machine reference: {unavailable_machines}. Use one of {available_machine_refs}"
raise ClanError(msg)
tag_refs = role_members.get("tags")
if tag_refs:
unavailable_tags = list(
filter(lambda m: m not in available_tag_refs, tag_refs),
)
if unavailable_tags:
msg += (
f"Unknown tags: {unavailable_tags}. Use one of {available_tag_refs}"
)
raise ClanError(msg)
# TODO:
# Validate instance_config roles settings against role schema
set_value_by_path(inventory, f"instances.{instance_name}", instance_config)
set_value_by_path(inventory, f"instances.{instance_name}.module", module_ref)
inventory_store.write(
inventory,
message=f"services: instance '{instance_name}' init",
)

View File

@@ -11,7 +11,6 @@ from clan_lib.nix_models.clan import (
InventoryInstanceModule, InventoryInstanceModule,
InventoryInstanceModuleType, InventoryInstanceModuleType,
InventoryInstanceRolesType, InventoryInstanceRolesType,
InventoryInstancesType,
) )
from clan_lib.persist.inventory_store import InventoryStore from clan_lib.persist.inventory_store import InventoryStore
from clan_lib.persist.util import set_value_by_path from clan_lib.persist.util import set_value_by_path
@@ -61,7 +60,7 @@ class ModuleManifest:
raise ValueError(msg) raise ValueError(msg)
@classmethod @classmethod
def from_dict(cls, data: dict[str, Any]) -> "ModuleManifest": def from_dict(cls, data: dict) -> "ModuleManifest":
"""Create an instance of this class from a dictionary. """Create an instance of this class from a dictionary.
Drops any keys that are not defined in the dataclass. Drops any keys that are not defined in the dataclass.
""" """
@@ -148,159 +147,106 @@ def extract_frontmatter[T](
@dataclass @dataclass
class ModuleInfo: class ModuleInfo(TypedDict):
manifest: ModuleManifest manifest: ModuleManifest
roles: dict[str, None] roles: dict[str, None]
@dataclass class Module(TypedDict):
class Module: module: InventoryInstanceModule
# To use this module specify: InventoryInstanceModule :: { input, name } (foreign key)
usage_ref: InventoryInstanceModule
info: ModuleInfo info: ModuleInfo
native: bool
instance_refs: list[str]
@dataclass @API.register
class ClanModules: def list_service_modules(flake: Flake) -> list[Module]:
modules: list[Module] """Show information about a module"""
core_input_name: str modules = flake.select("clanInternals.inventoryClass.modulesPerSource")
res: list[Module] = []
def find_instance_refs_for_module( for input_name, module_set in modules.items():
instances: InventoryInstancesType, for module_name, module_info in module_set.items():
module_ref: InventoryInstanceModule, res.append(
core_input_name: str, Module(
) -> list[str]: module={"name": module_name, "input": input_name},
"""Find all usages of a given module by its module_ref info=ModuleInfo(
manifest=ModuleManifest.from_dict(
If the module is native: module_info.get("manifest"),
module_ref.input := None ),
<instance>.module.name := None roles=module_info.get("roles", {}),
),
If module is from explicit input )
<instance>.module.name != None )
module_ref.input could be None, if explicit input refers to a native module
"""
res: list[str] = []
for instance_name, instance in instances.items():
local_ref = instance.get("module")
if not local_ref:
continue
local_name: str = local_ref.get("name", instance_name)
local_input: str | None = local_ref.get("input")
# Normal match
if (
local_name == module_ref.get("name")
and local_input == module_ref.get("input")
) or (local_input == core_input_name and local_name == module_ref.get("name")):
res.append(instance_name)
return res return res
@API.register @API.register
def list_service_modules(flake: Flake) -> ClanModules: def get_service_module(
"""Show information about a module"""
# inputName.moduleName -> ModuleInfo
modules: dict[str, dict[str, Any]] = flake.select(
"clanInternals.inventoryClass.modulesPerSource"
)
# moduleName -> ModuleInfo
builtin_modules: dict[str, Any] = flake.select(
"clanInternals.inventoryClass.staticModules"
)
inventory_store = InventoryStore(flake)
instances = inventory_store.read().get("instances", {})
first_name, first_module = next(iter(builtin_modules.items()))
clan_input_name = None
for input_name, module_set in modules.items():
if first_name in module_set:
# Compare the manifest name
module_set[first_name]["manifest"]["name"] = first_module["manifest"][
"name"
]
clan_input_name = input_name
break
if clan_input_name is None:
msg = "Could not determine the clan-core input name"
raise ClanError(msg)
res: list[Module] = []
for input_name, module_set in modules.items():
for module_name, module_info in module_set.items():
module_ref = InventoryInstanceModule(
{
"name": module_name,
"input": None if input_name == clan_input_name else input_name,
}
)
res.append(
Module(
instance_refs=find_instance_refs_for_module(
instances, module_ref, clan_input_name
),
usage_ref=module_ref,
info=ModuleInfo(
roles=module_info.get("roles", {}),
manifest=ModuleManifest.from_dict(module_info["manifest"]),
),
native=(input_name == clan_input_name),
)
)
return ClanModules(res, clan_input_name)
def resolve_service_module_ref(
flake: Flake, flake: Flake,
module_ref: InventoryInstanceModuleType, module_ref: InventoryInstanceModuleType,
) -> Module: ) -> ModuleInfo:
"""Returns the module information for a given module reference
:param module_ref: The module reference to get the information for
:return: Dict of module information
:raises ClanError: If the module_ref is invalid or missing required fields
"""
input_name, module_name = check_service_module_ref(flake, module_ref)
avilable_modules = list_service_modules(flake)
module_set: list[Module] = [
m for m in avilable_modules if m["module"].get("input", None) == input_name
]
if not module_set:
msg = f"Module set for input '{input_name}' not found"
raise ClanError(msg)
module = next((m for m in module_set if m["module"]["name"] == module_name), None)
if module is None:
msg = f"Module '{module_name}' not found in input '{input_name}'"
raise ClanError(msg)
return module["info"]
def check_service_module_ref(
flake: Flake,
module_ref: InventoryInstanceModuleType,
) -> tuple[str, str]:
"""Checks if the module reference is valid """Checks if the module reference is valid
:param module_ref: The module reference to check :param module_ref: The module reference to check
:raises ClanError: If the module_ref is invalid or missing required fields :raises ClanError: If the module_ref is invalid or missing required fields
""" """
service_modules = list_service_modules(flake) avilable_modules = list_service_modules(flake)
avilable_modules = service_modules.modules
input_ref = module_ref.get("input", None) input_ref = module_ref.get("input", None)
if input_ref is None:
msg = "Setting module_ref.input is currently required"
raise ClanError(msg)
if input_ref is None or input_ref == service_modules.core_input_name: module_set = [
# Take only the native modules m for m in avilable_modules if m["module"].get("input", None) == input_ref
module_set = [m for m in avilable_modules if m.native] ]
else:
# Match the input ref
module_set = [
m for m in avilable_modules if m.usage_ref.get("input", None) == input_ref
]
if not module_set: if module_set is None:
inputs = {m.usage_ref.get("input") for m in avilable_modules} inputs = {m["module"].get("input") for m in avilable_modules}
msg = f"module set for input '{input_ref}' not found" msg = f"module set for input '{input_ref}' not found"
msg += f"\nAvilable input_refs: {inputs}" msg += f"\nAvilable input_refs: {inputs}"
msg += "\nOmit the input field to use the built-in modules\n"
msg += "\n".join([m.usage_ref["name"] for m in avilable_modules if m.native])
raise ClanError(msg) raise ClanError(msg)
module_name = module_ref.get("name") module_name = module_ref.get("name")
if not module_name: if not module_name:
msg = "Module name is required in module_ref" msg = "Module name is required in module_ref"
raise ClanError(msg) raise ClanError(msg)
module = next((m for m in module_set if m["module"]["name"] == module_name), None)
module = next((m for m in module_set if m.usage_ref["name"] == module_name), None)
if module is None: if module is None:
msg = f"module with name '{module_name}' not found" msg = f"module with name '{module_name}' not found"
raise ClanError(msg) raise ClanError(msg)
return module return (input_ref, module_name)
@API.register @API.register
@@ -314,16 +260,7 @@ def get_service_module_schema(
:return: Dict of schemas for the service module roles :return: Dict of schemas for the service module roles
:raises ClanError: If the module_ref is invalid or missing required fields :raises ClanError: If the module_ref is invalid or missing required fields
""" """
input_name, module_name = module_ref.get("input"), module_ref["name"] input_name, module_name = check_service_module_ref(flake, module_ref)
module = resolve_service_module_ref(flake, module_ref)
if module is None:
msg = f"Module '{module_name}' not found in input '{input_name}'"
raise ClanError(msg)
if input_name is None:
msg = "Not implemented for: input_name is None"
raise ClanError(msg)
return flake.select( return flake.select(
f"clanInternals.inventoryClass.moduleSchemas.{input_name}.{module_name}", f"clanInternals.inventoryClass.moduleSchemas.{input_name}.{module_name}",
@@ -337,8 +274,7 @@ def create_service_instance(
roles: InventoryInstanceRolesType, roles: InventoryInstanceRolesType,
) -> None: ) -> None:
"""Show information about a module""" """Show information about a module"""
input_name, module_name = module_ref.get("input"), module_ref["name"] input_name, module_name = check_service_module_ref(flake, module_ref)
module = resolve_service_module_ref(flake, module_ref)
inventory_store = InventoryStore(flake) inventory_store = InventoryStore(flake)
@@ -359,10 +295,10 @@ def create_service_instance(
all_machines = inventory.get("machines", {}) all_machines = inventory.get("machines", {})
available_machine_refs = set(all_machines.keys()) available_machine_refs = set(all_machines.keys())
allowed_roles = module.info.roles schema = get_service_module_schema(flake, module_ref)
for role_name, role_members in roles.items(): for role_name, role_members in roles.items():
if role_name not in allowed_roles: if role_name not in schema:
msg = f"Role '{role_name}' is not defined in the module" msg = f"Role '{role_name}' is not defined in the module schema"
raise ClanError(msg) raise ClanError(msg)
machine_refs = role_members.get("machines") machine_refs = role_members.get("machines")
@@ -379,21 +315,13 @@ def create_service_instance(
# settings = role_members.get("settings", {}) # settings = role_members.get("settings", {})
# Create a new instance with the given roles # Create a new instance with the given roles
if not input_name: new_instance: InventoryInstance = {
new_instance: InventoryInstance = { "module": {
"module": { "name": module_name,
"name": module_name, "input": input_name,
}, },
"roles": roles, "roles": roles,
} }
else:
new_instance = {
"module": {
"name": module_name,
"input": input_name,
},
"roles": roles,
}
set_value_by_path(inventory, f"instances.{instance_name}", new_instance) set_value_by_path(inventory, f"instances.{instance_name}", new_instance)
inventory_store.write( inventory_store.write(
@@ -403,31 +331,11 @@ def create_service_instance(
) )
@dataclass
class InventoryInstanceInfo:
resolved: Module
module: InventoryInstanceModule
roles: InventoryInstanceRolesType
@API.register @API.register
def list_service_instances(flake: Flake) -> dict[str, InventoryInstanceInfo]: def list_service_instances(
"""Returns all currently present service instances including their full configuration""" flake: Flake,
) -> dict[str, InventoryInstance]:
"""Show information about a module"""
inventory_store = InventoryStore(flake) inventory_store = InventoryStore(flake)
inventory = inventory_store.read() inventory = inventory_store.read()
return inventory.get("instances", {})
instances = inventory.get("instances", {})
res: dict[str, InventoryInstanceInfo] = {}
for instance_name, instance in instances.items():
persisted_ref = instance.get("module", {"name": instance_name})
module = resolve_service_module_ref(flake, persisted_ref)
if module is None:
msg = f"Module for instance '{instance_name}' not found"
raise ClanError(msg)
res[instance_name] = InventoryInstanceInfo(
resolved=module,
module=persisted_ref,
roles=instance.get("roles", {}),
)
return res

View File

@@ -1,105 +0,0 @@
from collections.abc import Callable
from typing import TYPE_CHECKING
import pytest
from clan_cli.tests.fixtures_flakes import nested_dict
from clan_lib.flake.flake import Flake
from clan_lib.services.modules import list_service_instances, list_service_modules
if TYPE_CHECKING:
from clan_lib.nix_models.clan import Clan
@pytest.mark.with_core
def test_list_service_instances(
clan_flake: Callable[..., Flake],
) -> None:
# ATTENTION! This method lacks Typechecking
config = nested_dict()
# explicit module selection
# We use this random string in test to avoid code dependencies on the input name
config["inventory"]["instances"]["foo"]["module"]["input"] = (
"Y2xhbi1jaW9yZS1uZXZlci1kZXBlbmQtb24tbWU"
)
config["inventory"]["instances"]["foo"]["module"]["name"] = "sshd"
# input = null
config["inventory"]["instances"]["bar"]["module"]["input"] = None
config["inventory"]["instances"]["bar"]["module"]["name"] = "sshd"
# Omit input
config["inventory"]["instances"]["baz"]["module"]["name"] = "sshd"
# external input
flake = clan_flake(config)
service_modules = list_service_modules(flake)
assert len(service_modules.modules)
assert any(m.usage_ref["name"] == "sshd" for m in service_modules.modules)
instances = list_service_instances(flake)
assert set(instances.keys()) == {"foo", "bar", "baz"}
# Reference to a built-in module
assert instances["foo"].resolved.usage_ref.get("input") is None
assert instances["foo"].resolved.usage_ref.get("name") == "sshd"
assert instances["foo"].resolved.info.manifest.name == "clan-core/sshd"
# Actual module
assert (
instances["foo"].module.get("input")
== "Y2xhbi1jaW9yZS1uZXZlci1kZXBlbmQtb24tbWU"
)
# Module exposes the input name?
assert instances["bar"].resolved.usage_ref.get("input") is None
assert instances["bar"].resolved.usage_ref.get("name") == "sshd"
assert instances["baz"].resolved.usage_ref.get("input") is None
assert instances["baz"].resolved.usage_ref.get("name") == "sshd"
@pytest.mark.with_core
def test_list_service_modules(
clan_flake: Callable[..., Flake],
) -> None:
# Nice! This is typechecked :)
clan_config: Clan = {
"inventory": {
"instances": {
# No module spec -> resolves to clan-core/admin
"admin": {},
# Partial module spec
"admin2": {"module": {"name": "admin"}},
# Full explicit module spec
"admin3": {
"module": {
"name": "admin",
"input": "Y2xhbi1jaW9yZS1uZXZlci1kZXBlbmQtb24tbWU",
}
},
}
}
}
flake = clan_flake(clan_config)
service_modules = list_service_modules(flake)
# Detects the input name right
assert service_modules.core_input_name == "Y2xhbi1jaW9yZS1uZXZlci1kZXBlbmQtb24tbWU"
assert len(service_modules.modules)
admin_service = next(
m for m in service_modules.modules if m.usage_ref.get("name") == "admin"
)
assert admin_service
assert admin_service.usage_ref == {"name": "admin", "input": None}
assert set(admin_service.instance_refs) == {"admin", "admin2", "admin3"}
# Negative test: Assert not used
sshd_service = next(
m for m in service_modules.modules if m.usage_ref.get("name") == "sshd"
)
assert sshd_service
assert sshd_service.usage_ref == {"name": "sshd", "input": None}
assert set(sshd_service.instance_refs) == set({})

View File

@@ -214,12 +214,10 @@ def test_clan_create_api(
store = InventoryStore(clan_dir_flake) store = InventoryStore(clan_dir_flake)
inventory = store.read() inventory = store.read()
service_modules = list_service_modules(clan_dir_flake) modules = list_service_modules(clan_dir_flake)
admin_module = next( admin_module = next(m for m in modules if m["module"]["name"] == "admin")
m for m in service_modules.modules if m.usage_ref.get("name") == "admin" assert admin_module["info"]["manifest"].name == "clan-core/admin"
)
assert admin_module.info.manifest.name == "clan-core/admin"
set_value_by_path(inventory, "instances", inventory_conf.instances) set_value_by_path(inventory, "instances", inventory_conf.instances)
store.write( store.write(

View File

@@ -218,12 +218,13 @@ def get_field_def(
default_factory: str | None = None, default_factory: str | None = None,
type_appendix: str = "", type_appendix: str = "",
) -> tuple[str, str]: ) -> tuple[str, str]:
_field_types = set(field_types) if "None" in field_types or default or default_factory:
if "None" in _field_types or default or default_factory: if "None" in field_types:
serialised_types = " | ".join(sort_types(_field_types)) + type_appendix field_types.remove("None")
serialised_types = " | ".join(sort_types(field_types)) + type_appendix
serialised_types = f"{serialised_types}" serialised_types = f"{serialised_types}"
else: else:
serialised_types = " | ".join(sort_types(_field_types)) + type_appendix serialised_types = " | ".join(sort_types(field_types)) + type_appendix
return (field_name, serialised_types) return (field_name, serialised_types)

View File

@@ -14,8 +14,6 @@ let
self.nixosModules.installer self.nixosModules.installer
]; ];
# We don't need state-version in a live installer, we can just set nixos.release directly
clan.core.settings.state-version.enable = false;
system.stateVersion = config.system.nixos.release; system.stateVersion = config.system.nixos.release;
nixpkgs.pkgs = self.inputs.nixpkgs.legacyPackages.x86_64-linux; nixpkgs.pkgs = self.inputs.nixpkgs.legacyPackages.x86_64-linux;

View File

@@ -1,11 +1,11 @@
{ {
inputs = { inputs = {
clan-core.url = "https://git.clan.lol/clan/clan-core/archive/main.tar.gz"; clan.url = "https://git.clan.lol/clan/clan-core/archive/main.tar.gz";
nixpkgs.follows = "clan-core/nixpkgs"; nixpkgs.follows = "clan/nixpkgs";
flake-parts.url = "github:hercules-ci/flake-parts"; flake-parts.url = "github:hercules-ci/flake-parts";
flake-parts.inputs.nixpkgs-lib.follows = "clan-core/nixpkgs"; flake-parts.inputs.nixpkgs-lib.follows = "clan/nixpkgs";
}; };
outputs = outputs =