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": { "nixpkgs-dev": {
"locked": { "locked": {
"lastModified": 1756400612, "lastModified": 1756578978,
"narHash": "sha256-0xm2D8u6y1+hCT+o4LCUCm3GCmSJHLAF0jRELyIb1go=", "narHash": "sha256-dLgwMLIMyHlSeIDsoT2OcZBkuruIbjhIAv1sGANwtes=",
"owner": "NixOS", "owner": "NixOS",
"repo": "nixpkgs", "repo": "nixpkgs",
"rev": "593cac9f894d7d4894e0155bacbbc69e7ef552dd", "rev": "a85a50bef870537a9705f64ed75e54d1f4bf9c23",
"type": "github" "type": "github"
}, },
"original": { "original": {

View File

@@ -245,6 +245,8 @@ 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,6 +23,12 @@ 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 = true; clan.core.settings.state-version.enable = lib.mkDefault 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,8 +12,14 @@ 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
&& ( && (
(!isUnstable && !kernelPackages.zfs.meta.broken) let
|| (isUnstable && !kernelPackages.zfs_unstable.meta.broken) zfsPackage =
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 (
@@ -24,5 +30,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 = 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 { 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, useContext } from "solid-js"; import { For, Show, 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;
@@ -71,6 +72,15 @@ 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
@@ -100,18 +110,42 @@ export const SidebarBody = (props: SidebarProps) => {
</Accordion.Trigger> </Accordion.Trigger>
</Accordion.Header> </Accordion.Header>
<Accordion.Content class="content"> <Accordion.Content class="content">
<nav> <Show
<For each={Object.entries(ctx.machinesQuery.data || {})}> when={machines()}
{([id, machine]) => ( fallback={
<MachineRoute <div class="flex w-full flex-col items-center justify-center gap-2.5">
clanURI={clanURI} <Typography
machineID={id} hierarchy="body"
name={machine.name || id} size="s"
serviceCount={0} weight="medium"
/> inverted
)} >
</For> No machines yet
</nav> </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.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/Install/install"; import { InstallModal } from "@/src/workflows/InstallMachine/InstallMachine";
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,6 +8,7 @@ import {
on, on,
onMount, onMount,
Show, Show,
Signal,
useContext, useContext,
} from "solid-js"; } from "solid-js";
import { import {
@@ -24,16 +25,11 @@ 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";
@@ -43,6 +39,7 @@ 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;
@@ -53,6 +50,9 @@ 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,6 +66,8 @@ class DefaultClanContext implements ClanContextProps {
allQueries: UseQueryResult[]; allQueries: UseQueryResult[];
showAddMachineSignal: Signal<boolean>;
constructor( constructor(
clanURI: string, clanURI: string,
machinesQuery: MachinesQueryResult, machinesQuery: MachinesQueryResult,
@@ -80,6 +82,8 @@ 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 {
@@ -89,6 +93,16 @@ 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>();
@@ -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 ClanSceneController = (props: RouteSectionProps) => {
const ctx = useContext(ClanContext); const ctx = useContext(ClanContext);
if (!ctx) { if (!ctx) {
@@ -194,7 +158,6 @@ 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;
@@ -202,45 +165,11 @@ 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) => {
setShowModal(true); ctx.setShowAddMachine(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
>(); >();
@@ -312,9 +241,8 @@ 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(
@@ -322,7 +250,7 @@ const ClanSceneController = (props: RouteSectionProps) => {
if (mode === "service") { if (mode === "service") {
setShowService(true); setShowService(true);
} else { } else {
// todo: request close instead of force close // TODO: request soft close instead of forced close
setShowService(false); setShowService(false);
} }
}), }),
@@ -333,22 +261,19 @@ const ClanSceneController = (props: RouteSectionProps) => {
<Show when={loadingError()}> <Show when={loadingError()}>
<ListClansModal error={loadingError()} /> <ListClansModal error={loadingError()} />
</Show> </Show>
<Show when={showModal()}> <Show when={ctx.showAddMachine()}>
<MockCreateMachine <AddMachine
onClose={() => { onCreated={async (id) => {
setShowModal(false); const promise = currentPromise();
currentPromise()?.reject(new Error("User cancelled")); if (promise) {
}} await ctx.machinesQuery.refetch();
onSubmit={async (values) => { promise.resolve({ id });
try { setCurrentPromise(null);
const result = await sendCreate(values);
currentPromise()?.resolve(result);
setShowModal(false);
} catch (err) {
currentPromise()?.reject(err);
setShowModal(false);
} }
}} }}
onClose={() => {
ctx.setShowAddMachine(false);
}}
/> />
</Show> </Show>
<div <div
@@ -370,7 +295,7 @@ const ClanSceneController = (props: RouteSectionProps) => {
handleSubmit={handleSubmitService} handleSubmit={handleSubmitService}
onClose={() => { onClose={() => {
setShowService(false); setShowService(false);
setWorldMode("default"); setWorldMode("select");
currentPromise()?.resolve({ id: "0" }); currentPromise()?.resolve({ id: "0" });
}} }}
/> />

View File

@@ -20,7 +20,8 @@ export const Machine = (props: RouteSectionProps) => {
navigateToClan(navigate, clanURI); navigateToClan(navigate, clanURI);
}; };
const sidebarPane = (machineName: string) => { const sections = () => {
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
@@ -51,25 +52,35 @@ export const Machine = (props: RouteSectionProps) => {
const sectionProps = { clanURI, machineName, onSubmit, machineQuery }; const sectionProps = { clanURI, machineName, onSubmit, machineQuery };
return ( return (
<div class={styles.sidebarPaneContainer}> <>
<SidebarPane <SidebarSectionInstall
title={machineName} clanURI={clanURI}
onClose={onClose} machineName={useMachineName()}
subHeader={() => ( />
<SidebarMachineStatus clanURI={clanURI} machineName={machineName} /> <SectionGeneral {...sectionProps} />
)} <SectionTags {...sectionProps} />
> </>
<SidebarSectionInstall clanURI={clanURI} machineName={machineName} />
<SectionGeneral {...sectionProps} />
<SectionTags {...sectionProps} />
</SidebarPane>
</div>
); );
}; };
return ( return (
<Show when={useMachineName()} keyed> <Show when={useMachineName()}>
{sidebarPane(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> </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,10 +61,11 @@ 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 (!(id in positions)) { if (!Object.keys(positions).includes(id)) {
repr.dispose(scene); repr.dispose(scene);
this.machines.delete(id); this.machines.delete(id);
} }

View File

@@ -1,7 +1,6 @@
.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,6 +21,7 @@ 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) {
@@ -64,7 +65,7 @@ export function useMachineClick() {
/*Gloabl signal*/ /*Gloabl signal*/
const [worldMode, setWorldMode] = createSignal< const [worldMode, setWorldMode] = createSignal<
"default" | "select" | "service" | "create" "default" | "select" | "service" | "create"
>("default"); >("select");
export { worldMode, setWorldMode }; export { worldMode, setWorldMode };
export function CubeScene(props: { export function CubeScene(props: {
@@ -101,6 +102,8 @@ 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]>();
@@ -273,6 +276,13 @@ 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);
@@ -582,7 +592,17 @@ export function CubeScene(props: {
return ( 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="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}
@@ -592,9 +612,7 @@ export function CubeScene(props: {
description="Select machine" description="Select machine"
name="Select" name="Select"
icon="Cursor" icon="Cursor"
onClick={() => onClick={() => setWorldMode("select")}
setWorldMode((v) => (v === "select" ? "default" : "select"))
}
selected={worldMode() === "select"} selected={worldMode() === "select"}
/> />
<ToolbarButton <ToolbarButton
@@ -611,11 +629,11 @@ export function CubeScene(props: {
icon="Services" icon="Services"
selected={worldMode() === "service"} selected={worldMode() === "service"}
onClick={() => { onClick={() => {
setWorldMode((v) => (v === "service" ? "default" : "service")); setWorldMode("service");
}} }}
/> />
<ToolbarButton <ToolbarButton
icon="Reload" icon="Update"
name="Reload" name="Reload"
description="Reload machines" description="Reload machines"
onClick={() => machinesQuery.refetch()} 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 type { Meta, StoryContext, StoryObj } from "@kachurun/storybook-solid";
import { InstallModal } from "./install"; import { InstallModal } from "./InstallMachine";
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 "../install"; import { InstallSteps } from "../InstallMachine";
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 "../install"; import { InstallSteps, InstallStoreType } from "../InstallMachine";
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,7 +11,11 @@ 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 { InstallSteps, InstallStoreType, PromptValues } from "../install"; import {
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,59 +24,42 @@ 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",
module: { name: "Borgbackup", input: "clan-core" }, modules: [
info: { {
manifest: { usage_ref: { name: "Borgbackup", input: null },
name: "Borgbackup", instance_refs: [],
description: "This is module A", native: true,
}, info: {
roles: { manifest: {
client: null, name: "Borgbackup",
server: null, description: "This is module A",
},
roles: {
client: null,
server: null,
},
}, },
}, },
}, {
{ usage_ref: { name: "Zerotier", input: "fublub" },
module: { name: "Zerotier", input: "clan-core" }, instance_refs: [],
info: { native: false,
manifest: { info: {
name: "Zerotier", manifest: {
description: "This is module B", name: "Zerotier",
}, description: "This is module B",
roles: { },
peer: null, roles: {
moon: null, peer: null,
controller: 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: { 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[number]; type ModuleItem = ServiceModules["modules"][number];
interface Module { interface Module {
value: string; value: string;
input?: string; input?: string | null;
label: string; label: string;
description: string; description: string;
raw: ModuleItem; raw: ModuleItem;
@@ -68,20 +68,13 @@ const SelectService = () => {
createEffect(() => { createEffect(() => {
if (serviceModulesQuery.data && serviceInstancesQuery.data) { if (serviceModulesQuery.data && serviceInstancesQuery.data) {
setModuleOptions( setModuleOptions(
serviceModulesQuery.data.map((m) => ({ serviceModulesQuery.data.modules.map((currService) => ({
value: `${m.module.name}:${m.module.input}`, value: `${currService.usage_ref.name}:${currService.usage_ref.input}`,
label: m.module.name, label: currService.info.manifest.name,
description: m.info.manifest.description, description: currService.info.manifest.description,
input: m.module.input, input: currService.usage_ref.input,
raw: m, raw: currService,
// TODO: include the instances that use this module instances: currService.instance_refs,
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),
})), })),
); );
} }
@@ -96,8 +89,8 @@ const SelectService = () => {
if (!module) return; if (!module) return;
set("module", { set("module", {
name: module.raw.module.name, name: module.raw.usage_ref.name,
input: module.raw.module.input, input: module.raw.usage_ref.input,
raw: module.raw, raw: module.raw,
}); });
// TODO: Ideally we need to ask // TODO: Ideally we need to ask
@@ -183,11 +176,13 @@ const SelectService = () => {
inverted inverted
class="flex justify-between" 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} {item.description}
</span> </span>
<span class="inline-block max-w-8 truncate align-middle"> <span class="inline-block max-w-12 truncate align-middle">
by {item.input} <Show when={!item.raw.native} fallback="by clan-core">
by {item.input}
</Show>
</span> </span>
</Typography> </Typography>
</div> </div>
@@ -538,7 +533,7 @@ export interface InventoryInstance {
name: string; name: string;
module: { module: {
name: string; name: string;
input?: string; input?: string | null;
}; };
roles: Record<string, RoleType>; roles: Record<string, RoleType>;
} }
@@ -551,7 +546,7 @@ interface RoleType {
export interface ServiceStoreType { export interface ServiceStoreType {
module: { module: {
name: string; name: string;
input: string; input?: string | null;
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 "./Install/install"; import { InstallSteps } from "@/src/workflows/InstallMachine/InstallMachine";
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"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) 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.clan-core.url = "https://git.clan.lol/clan/clan-core/archive/main.tar.gz"; inputs.Y2xhbi1jaW9yZS1uZXZlci1kZXBlbmQtb24tbWU.url = "https://git.clan.lol/clan/clan-core/archive/main.tar.gz";
inputs.nixpkgs.follows = "clan-core/nixpkgs"; inputs.nixpkgs.follows = "Y2xhbi1jaW9yZS1uZXZlci1kZXBlbmQtb24tbWU/nixpkgs";
outputs = outputs =
{ self, clan-core, ... }: { self, Y2xhbi1jaW9yZS1uZXZlci1kZXBlbmQtb24tbWU, ... }:
let let
clan = clan-core.lib.clan ({ clan = Y2xhbi1jaW9yZS1uZXZlci1kZXBlbmQtb24tbWU.lib.clan ({
inherit self; inherit self;
imports = [ imports = [
./clan.nix ./clan.nix

View File

@@ -1,6 +1,7 @@
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
@@ -25,14 +26,13 @@ log = logging.getLogger(__name__)
BuildOn = Literal["auto", "local", "remote"] BuildOn = Literal["auto", "local", "remote"]
Step = Literal[ class Step(str, Enum):
"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("generators") notify_install_step(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("upload-secrets") notify_install_step(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,
) )
default_install_steps: dict[str, Step] = { install_steps = {
"kexec": "nixos-anywhere", "kexec": Step.NIXOS_ANYWHERE,
"disko": "formatting", "disko": Step.FORMATTING,
"install": "installing", "install": Step.INSTALLING,
"reboot": "rebooting", "reboot": Step.REBOOTING,
} }
def run_phase(phase: str) -> None: 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) 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 InventoryInstanceModuleInputType = str | None
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 ClanDirectoryType = dict[str, Any] | list[Any] | bool | float | int | str | None
ClanInventoryType = Inventory ClanInventoryType = Inventory
ClanMachinesType = dict[str, Unknown] ClanMachinesType = dict[str, Unknown]
ClanMetaType = 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, 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
@@ -60,7 +61,7 @@ class ModuleManifest:
raise ValueError(msg) raise ValueError(msg)
@classmethod @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. """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.
""" """
@@ -147,106 +148,159 @@ def extract_frontmatter[T](
@dataclass @dataclass
class ModuleInfo(TypedDict): class ModuleInfo:
manifest: ModuleManifest manifest: ModuleManifest
roles: dict[str, None] roles: dict[str, None]
class Module(TypedDict): @dataclass
module: InventoryInstanceModule class Module:
# To use this module specify: InventoryInstanceModule :: { input, name } (foreign key)
usage_ref: InventoryInstanceModule
info: ModuleInfo info: ModuleInfo
native: bool
instance_refs: list[str]
@API.register @dataclass
def list_service_modules(flake: Flake) -> list[Module]: class ClanModules:
"""Show information about a module""" modules: list[Module]
modules = flake.select("clanInternals.inventoryClass.modulesPerSource") core_input_name: str
res: list[Module] = []
for input_name, module_set in modules.items(): def find_instance_refs_for_module(
for module_name, module_info in module_set.items(): instances: InventoryInstancesType,
res.append( module_ref: InventoryInstanceModule,
Module( core_input_name: str,
module={"name": module_name, "input": input_name}, ) -> list[str]:
info=ModuleInfo( """Find all usages of a given module by its module_ref
manifest=ModuleManifest.from_dict(
module_info.get("manifest"), If the module is native:
), module_ref.input := None
roles=module_info.get("roles", {}), <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 return res
@API.register @API.register
def get_service_module( def list_service_modules(flake: Flake) -> ClanModules:
flake: Flake, """Show information about a module"""
module_ref: InventoryInstanceModuleType, # inputName.moduleName -> ModuleInfo
) -> ModuleInfo: modules: dict[str, dict[str, Any]] = flake.select(
"""Returns the module information for a given module reference "clanInternals.inventoryClass.modulesPerSource"
)
:param module_ref: The module reference to get the information for # moduleName -> ModuleInfo
:return: Dict of module information builtin_modules: dict[str, Any] = flake.select(
:raises ClanError: If the module_ref is invalid or missing required fields "clanInternals.inventoryClass.staticModules"
""" )
input_name, module_name = check_service_module_ref(flake, module_ref) inventory_store = InventoryStore(flake)
instances = inventory_store.read().get("instances", {})
avilable_modules = list_service_modules(flake) first_name, first_module = next(iter(builtin_modules.items()))
module_set: list[Module] = [ clan_input_name = None
m for m in avilable_modules if m["module"].get("input", None) == input_name 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: if clan_input_name is None:
msg = f"Module set for input '{input_name}' not found" msg = "Could not determine the clan-core input name"
raise ClanError(msg) 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: return ClanModules(res, clan_input_name)
msg = f"Module '{module_name}' not found in input '{input_name}'"
raise ClanError(msg)
return module["info"]
def check_service_module_ref( def resolve_service_module_ref(
flake: Flake, flake: Flake,
module_ref: InventoryInstanceModuleType, module_ref: InventoryInstanceModuleType,
) -> tuple[str, str]: ) -> Module:
"""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
""" """
avilable_modules = list_service_modules(flake) service_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)
module_set = [ if input_ref is None or input_ref == service_modules.core_input_name:
m for m in avilable_modules if m["module"].get("input", None) == input_ref # 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: if not module_set:
inputs = {m["module"].get("input") for m in avilable_modules} inputs = {m.usage_ref.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 (input_ref, module_name) return module
@API.register @API.register
@@ -260,7 +314,16 @@ 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 = 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( return flake.select(
f"clanInternals.inventoryClass.moduleSchemas.{input_name}.{module_name}", f"clanInternals.inventoryClass.moduleSchemas.{input_name}.{module_name}",
@@ -274,7 +337,8 @@ def create_service_instance(
roles: InventoryInstanceRolesType, roles: InventoryInstanceRolesType,
) -> None: ) -> None:
"""Show information about a module""" """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) inventory_store = InventoryStore(flake)
@@ -295,10 +359,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())
schema = get_service_module_schema(flake, module_ref) allowed_roles = module.info.roles
for role_name, role_members in roles.items(): for role_name, role_members in roles.items():
if role_name not in schema: if role_name not in allowed_roles:
msg = f"Role '{role_name}' is not defined in the module schema" msg = f"Role '{role_name}' is not defined in the module"
raise ClanError(msg) raise ClanError(msg)
machine_refs = role_members.get("machines") machine_refs = role_members.get("machines")
@@ -315,13 +379,21 @@ 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
new_instance: InventoryInstance = { if not input_name:
"module": { new_instance: InventoryInstance = {
"name": module_name, "module": {
"input": input_name, "name": module_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(
@@ -331,11 +403,31 @@ def create_service_instance(
) )
@dataclass
class InventoryInstanceInfo:
resolved: Module
module: InventoryInstanceModule
roles: InventoryInstanceRolesType
@API.register @API.register
def list_service_instances( def list_service_instances(flake: Flake) -> dict[str, InventoryInstanceInfo]:
flake: Flake, """Returns all currently present service instances including their full configuration"""
) -> 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

@@ -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) store = InventoryStore(clan_dir_flake)
inventory = store.read() 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") admin_module = next(
assert admin_module["info"]["manifest"].name == "clan-core/admin" 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) set_value_by_path(inventory, "instances", inventory_conf.instances)
store.write( store.write(

View File

@@ -218,13 +218,12 @@ 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]:
if "None" in field_types or default or default_factory: _field_types = set(field_types)
if "None" in field_types: if "None" in _field_types or default or default_factory:
field_types.remove("None") serialised_types = " | ".join(sort_types(_field_types)) + type_appendix
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,6 +14,8 @@ 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.url = "https://git.clan.lol/clan/clan-core/archive/main.tar.gz"; clan-core.url = "https://git.clan.lol/clan/clan-core/archive/main.tar.gz";
nixpkgs.follows = "clan/nixpkgs"; nixpkgs.follows = "clan-core/nixpkgs";
flake-parts.url = "github:hercules-ci/flake-parts"; 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 = outputs =