Compare commits

..

35 Commits

Author SHA1 Message Date
Johannes Kirschbauer
b9573636d8 ui/modules: simplify ui logic 2025-08-31 15:58:39 +02:00
Johannes Kirschbauer
3862ad2a06 api/modules: add foreign key to instances 2025-08-31 15:58:39 +02:00
Johannes Kirschbauer
c447aec9d3 api/modules: improve logic for builtin modules 2025-08-31 15:58:39 +02:00
Johannes Kirschbauer
5137d19b0f nix_modules: fix and update None types 2025-08-31 15:58:39 +02:00
Johannes Kirschbauer
453f2649d3 clanInternals: expose builtin modules 2025-08-31 15:58:39 +02:00
Johannes Kirschbauer
58cfcf3d25 api/modules: delete instances.py duplicate 2025-08-31 15:58:39 +02:00
clan-bot
c260a97cc1 Merge pull request 'Update nixpkgs-dev in devFlake' (#5033) from update-devFlake-nixpkgs-dev into main 2025-08-31 13:49:44 +00:00
clan-bot
3eb64870b0 Update nixpkgs-dev in devFlake 2025-08-31 13:44:23 +00:00
Mic92
7412b958c6 Merge pull request 'disable state-version in right place' (#5038) from private-flake-2 into main
Reviewed-on: https://git.clan.lol/clan/clan-core/pulls/5038
2025-08-31 13:43:07 +00:00
Jörg Thalheim
a0c27194a6 disable state-version in right place 2025-08-31 15:37:25 +02:00
Mic92
3437af29cb Merge pull request 'vars: fix var name in error message' (#5037) from private-flake-2 into main
Reviewed-on: https://git.clan.lol/clan/clan-core/pulls/5037
2025-08-31 13:33:01 +00:00
Jörg Thalheim
0b1c12d2e5 flash-installer: disable state-version
We cannot have vars in here because it breaks:

```
  clan flash write --flake https://git.clan.lol/clan/clan-core/archive/main.tar.gz   --ssh-pubkey $HOME/.ssh/id_ed25519.pub   --keymap us   --language en_US.UTF-8   --disk main /dev/sdb   flash-installer
```
2025-08-31 15:26:04 +02:00
Jörg Thalheim
8620761bbd vars: fix var name in error message 2025-08-31 15:23:24 +02:00
Mic92
d793b6ca07 Merge pull request 'vars: improve error message when storing trying to store a var in a read-only flake' (#5036) from private-flake-2 into main
Reviewed-on: https://git.clan.lol/clan/clan-core/pulls/5036
2025-08-31 13:20:13 +00:00
Jörg Thalheim
17e9231657 vars: improve error message when storing trying to store a var in a read-only flake 2025-08-31 14:14:56 +02:00
Mic92
acc2674d79 Merge pull request 'fix: check if phases are non-default when installing' (#5024) from sachk/clan-core:main into main
Reviewed-on: https://git.clan.lol/clan/clan-core/pulls/5024
2025-08-29 16:16:01 +00:00
Jörg Thalheim
c34a21a3bb install: make Step a String enum 2025-08-29 17:45:16 +02:00
Mic92
275bff23da Merge pull request 'zfs-latest: fix eval errors' (#5029) from zfs-latest into main
Reviewed-on: https://git.clan.lol/clan/clan-core/pulls/5029
2025-08-29 15:26:58 +00:00
Sacha Korban
1a766a3447 fix: check if phases are non-default when running 2025-08-29 17:26:49 +02:00
Jörg Thalheim
c22844c83b zfs-latest: fix eval errors 2025-08-29 17:20:56 +02:00
clan-bot
5472ca0e21 Merge pull request 'Update nixpkgs-dev in devFlake' (#5028) from update-devFlake-nixpkgs-dev into main 2025-08-29 15:08:13 +00:00
clan-bot
ad890b0b6b Update nixpkgs-dev in devFlake 2025-08-29 15:01:35 +00:00
DavHau
a364b5ebf3 API/list_service_instances: add module metadata (#5023)
@hsjobeki

Co-authored-by: Johannes Kirschbauer <hsjobeki@gmail.com>
Reviewed-on: https://git.clan.lol/clan/clan-core/pulls/5023
2025-08-29 13:14:19 +00:00
brianmcgee
d0134d131e Merge pull request 'feat(ui): display add machine in sidebar when machine list is empty' (#5027) from ui/refine-add-machine into main
Reviewed-on: https://git.clan.lol/clan/clan-core/pulls/5027
2025-08-29 12:27:33 +00:00
Brian McGee
ccf0dace11 feat(ui): display add machine in sidebar when machine list is empty 2025-08-29 13:23:45 +01:00
hsjobeki
9977a903ce Merge pull request 'ui/scene: cursor and mode fixes' (#5026) from ui-more-2 into main
Reviewed-on: https://git.clan.lol/clan/clan-core/pulls/5026
2025-08-29 12:01:56 +00:00
Johannes Kirschbauer
dc9bf5068e ui/scene: make 'select' the default mode 2025-08-29 13:58:35 +02:00
Johannes Kirschbauer
6b4f79c9fa ui/scene: add different cursor type 2025-08-29 13:54:32 +02:00
brianmcgee
b2985b59e9 Merge pull request 'feat(ui): stop reloading sidebar when moving between machine' (#5025) from ui/stop-sidebar-pane-re-opening into main
Reviewed-on: https://git.clan.lol/clan/clan-core/pulls/5025
2025-08-29 11:27:03 +00:00
Brian McGee
d4ac3b83ee feat(ui): stop reloading sidebar when moving between machine 2025-08-29 12:06:28 +01:00
hsjobeki
00bf55be5a Merge pull request 'ui/implement-add-machine-workflow' (#5021) from ui/implement-add-machine-workflow into main
Reviewed-on: https://git.clan.lol/clan/clan-core/pulls/5021
2025-08-29 08:42:31 +00:00
Johannes Kirschbauer
851d6aaa89 ui/machines: hook up create machine with scene workflow 2025-08-29 10:39:05 +02:00
Johannes Kirschbauer
f007279bee ui: format and debug messages 2025-08-29 10:38:39 +02:00
Brian McGee
5a3381d9ff ui/machines: add machine workflow 2025-08-29 10:34:03 +02:00
Brian McGee
54a8ec717e chore(ui): rename install workflow to InstallMachine 2025-08-28 22:44:27 +02:00
40 changed files with 1176 additions and 455 deletions

6
devFlake/flake.lock generated
View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -8,6 +8,7 @@ import {
on,
onMount,
Show,
Signal,
useContext,
} from "solid-js";
import {
@@ -24,16 +25,11 @@ import {
useClanListQuery,
useMachinesQuery,
} from "@/src/hooks/queries";
import { callApi } from "@/src/hooks/api";
import { clanURIs, setStore, store } from "@/src/stores/clan";
import { produce } from "solid-js/store";
import { Button } from "@/src/components/Button/Button";
import { Splash } from "@/src/scene/splash";
import cx from "classnames";
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 { UseQueryResult } from "@tanstack/solid-query";
import { ListClansModal } from "@/src/modals/ListClansModal/ListClansModal";
@@ -43,6 +39,7 @@ import {
} from "@/src/workflows/Service/Service";
import { useApiClient } from "@/src/hooks/ApiClient";
import toast from "solid-toast";
import { AddMachine } from "@/src/workflows/AddMachine/AddMachine";
interface ClanContextProps {
clanURI: string;
@@ -53,6 +50,9 @@ interface ClanContextProps {
isLoading(): boolean;
isError(): boolean;
showAddMachine(): boolean;
setShowAddMachine(value: boolean): void;
}
class DefaultClanContext implements ClanContextProps {
@@ -66,6 +66,8 @@ class DefaultClanContext implements ClanContextProps {
allQueries: UseQueryResult[];
showAddMachineSignal: Signal<boolean>;
constructor(
clanURI: string,
machinesQuery: MachinesQueryResult,
@@ -80,6 +82,8 @@ class DefaultClanContext implements ClanContextProps {
this.allClansQueries = [activeClanQuery, ...otherClanQueries];
this.allQueries = [machinesQuery, activeClanQuery, ...otherClanQueries];
this.showAddMachineSignal = createSignal(false);
}
isLoading(): boolean {
@@ -89,6 +93,16 @@ class DefaultClanContext implements ClanContextProps {
isError(): boolean {
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>();
@@ -134,56 +148,6 @@ 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 ctx = useContext(ClanContext);
if (!ctx) {
@@ -194,7 +158,6 @@ const ClanSceneController = (props: RouteSectionProps) => {
const [showService, setShowService] = createSignal(false);
const [showModal, setShowModal] = createSignal(false);
const [currentPromise, setCurrentPromise] = createSignal<{
resolve: ({ id }: { id: string }) => void;
reject: (err: unknown) => void;
@@ -202,45 +165,11 @@ const ClanSceneController = (props: RouteSectionProps) => {
const onCreate = async (): Promise<{ id: string }> => {
return new Promise((resolve, reject) => {
setShowModal(true);
ctx.setShowAddMachine(true);
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<
{ title: string; description: string } | undefined
>();
@@ -312,9 +241,8 @@ const ClanSceneController = (props: RouteSectionProps) => {
console.error("Error creating service instance", result.errors);
}
toast.success("Created");
//
currentPromise()?.resolve({ id: "0" });
setShowService(false);
setWorldMode("select");
};
createEffect(
@@ -322,7 +250,7 @@ const ClanSceneController = (props: RouteSectionProps) => {
if (mode === "service") {
setShowService(true);
} else {
// todo: request close instead of force close
// TODO: request soft close instead of forced close
setShowService(false);
}
}),
@@ -333,22 +261,19 @@ const ClanSceneController = (props: RouteSectionProps) => {
<Show when={loadingError()}>
<ListClansModal error={loadingError()} />
</Show>
<Show when={showModal()}>
<MockCreateMachine
onClose={() => {
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 when={ctx.showAddMachine()}>
<AddMachine
onCreated={async (id) => {
const promise = currentPromise();
if (promise) {
await ctx.machinesQuery.refetch();
promise.resolve({ id });
setCurrentPromise(null);
}
}}
onClose={() => {
ctx.setShowAddMachine(false);
}}
/>
</Show>
<div
@@ -370,7 +295,7 @@ const ClanSceneController = (props: RouteSectionProps) => {
handleSubmit={handleSubmitService}
onClose={() => {
setShowService(false);
setWorldMode("default");
setWorldMode("select");
currentPromise()?.resolve({ id: "0" });
}}
/>

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,119 @@
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

@@ -0,0 +1,136 @@
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

@@ -0,0 +1,176 @@
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

@@ -0,0 +1,76 @@
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

@@ -0,0 +1,40 @@
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

@@ -0,0 +1,102 @@
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 { InstallModal } from "./install";
import { InstallModal } from "./InstallMachine";
import {
createMemoryHistory,
MemoryRouter,

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

View File

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

View File

@@ -14,6 +14,8 @@ let
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;
nixpkgs.pkgs = self.inputs.nixpkgs.legacyPackages.x86_64-linux;

View File

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