Merge pull request 'api/modules: improve logic for builtin modules' (#5040) from fix-modules-spagetti-other into main
Reviewed-on: https://git.clan.lol/clan/clan-core/pulls/5040
This commit is contained in:
@@ -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;
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -143,6 +143,7 @@ export const useMachineStateQuery = (clanURI: string, machineName: string) => {
|
|||||||
const client = useApiClient();
|
const client = useApiClient();
|
||||||
return useQuery<MachineState>(() => ({
|
return useQuery<MachineState>(() => ({
|
||||||
queryKey: ["clans", encodeBase64(clanURI), "machine", machineName, "state"],
|
queryKey: ["clans", encodeBase64(clanURI), "machine", machineName, "state"],
|
||||||
|
staleTime: 60_000, // 1 minute stale time
|
||||||
queryFn: async () => {
|
queryFn: async () => {
|
||||||
const apiCall = client.fetch("get_machine_state", {
|
const apiCall = client.fetch("get_machine_state", {
|
||||||
machine: {
|
machine: {
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -45,15 +45,12 @@ 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;
|
|
||||||
label: string;
|
label: string;
|
||||||
description: string;
|
|
||||||
raw: ModuleItem;
|
raw: ModuleItem;
|
||||||
instances: string[];
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const SelectService = () => {
|
const SelectService = () => {
|
||||||
@@ -68,21 +65,10 @@ 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.usage_ref.name,
|
||||||
description: m.info.manifest.description,
|
raw: currService,
|
||||||
input: m.module.input,
|
|
||||||
raw: m,
|
|
||||||
// TODO: include the instances that use this module
|
|
||||||
instances: Object.entries(serviceInstancesQuery.data)
|
|
||||||
.filter(
|
|
||||||
([name, i]) =>
|
|
||||||
i.module.module.name === m.module.name &&
|
|
||||||
(!i.module.module.input ||
|
|
||||||
i.module.module.input === m.module.input),
|
|
||||||
)
|
|
||||||
.map(([name, _]) => name),
|
|
||||||
})),
|
})),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -97,8 +83,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
|
||||||
@@ -108,14 +94,14 @@ const SelectService = () => {
|
|||||||
// For now:
|
// For now:
|
||||||
// Create a new instance, if there are no instances yet
|
// Create a new instance, if there are no instances yet
|
||||||
// Update the first instance, if there is one
|
// Update the first instance, if there is one
|
||||||
if (module.instances.length === 0) {
|
if (module.raw.instance_refs.length === 0) {
|
||||||
set("action", "create");
|
set("action", "create");
|
||||||
} else {
|
} else {
|
||||||
if (!serviceInstancesQuery.data) return;
|
if (!serviceInstancesQuery.data) return;
|
||||||
if (!machinesQuery.data) return;
|
if (!machinesQuery.data) return;
|
||||||
set("action", "update");
|
set("action", "update");
|
||||||
|
|
||||||
const instanceName = module.instances[0];
|
const instanceName = module.raw.instance_refs[0];
|
||||||
const instance = serviceInstancesQuery.data[instanceName];
|
const instance = serviceInstancesQuery.data[instanceName];
|
||||||
console.log("Editing existing instance", module);
|
console.log("Editing existing instance", module);
|
||||||
|
|
||||||
@@ -165,7 +151,7 @@ const SelectService = () => {
|
|||||||
</div>
|
</div>
|
||||||
<div class="flex w-full flex-col">
|
<div class="flex w-full flex-col">
|
||||||
<Combobox.ItemLabel class="flex gap-1.5">
|
<Combobox.ItemLabel class="flex gap-1.5">
|
||||||
<Show when={item.instances.length > 0}>
|
<Show when={item.raw.instance_refs.length > 0}>
|
||||||
<div class="flex items-center rounded bg-[#76FFA4] px-1 py-0.5">
|
<div class="flex items-center rounded bg-[#76FFA4] px-1 py-0.5">
|
||||||
<Typography hierarchy="label" weight="bold" size="xxs">
|
<Typography hierarchy="label" weight="bold" size="xxs">
|
||||||
Added
|
Added
|
||||||
@@ -184,11 +170,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-80 truncate align-middle">
|
||||||
{item.description}
|
{item.raw.info.manifest.description}
|
||||||
</span>
|
</span>
|
||||||
<span class="inline-block max-w-8 truncate align-middle">
|
<span class="inline-block max-w-32 truncate align-middle">
|
||||||
by {item.input}
|
<Show when={!item.raw.native} fallback="by clan-core">
|
||||||
|
by {item.raw.usage_ref.input}
|
||||||
|
</Show>
|
||||||
</span>
|
</span>
|
||||||
</Typography>
|
</Typography>
|
||||||
</div>
|
</div>
|
||||||
@@ -539,7 +527,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>;
|
||||||
}
|
}
|
||||||
@@ -552,7 +540,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[]>;
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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",
|
|
||||||
)
|
|
||||||
@@ -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,110 +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
|
||||||
|
|
||||||
if "clan-core" not in modules:
|
|
||||||
msg = "Cannot find 'clan-core' input in the flake. Make sure your clan-core input is named 'clan-core'"
|
|
||||||
raise ClanError(msg)
|
|
||||||
|
|
||||||
res: list[Module] = []
|
def find_instance_refs_for_module(
|
||||||
for input_name, module_set in modules.items():
|
instances: InventoryInstancesType,
|
||||||
for module_name, module_info in module_set.items():
|
module_ref: InventoryInstanceModule,
|
||||||
res.append(
|
core_input_name: str,
|
||||||
Module(
|
) -> list[str]:
|
||||||
module={"name": module_name, "input": input_name},
|
"""Find all usages of a given module by its module_ref
|
||||||
info=ModuleInfo(
|
|
||||||
manifest=ModuleManifest.from_dict(
|
If the module is native:
|
||||||
module_info.get("manifest"),
|
module_ref.input := None
|
||||||
),
|
<instance>.module.name := None
|
||||||
roles=module_info.get("roles", {}),
|
|
||||||
),
|
If module is from explicit input
|
||||||
)
|
<instance>.module.name != None
|
||||||
)
|
module_ref.input could be None, if explicit input refers to a native module
|
||||||
|
|
||||||
|
"""
|
||||||
|
res: list[str] = []
|
||||||
|
for instance_name, instance in instances.items():
|
||||||
|
local_ref = instance.get("module")
|
||||||
|
if not local_ref:
|
||||||
|
continue
|
||||||
|
|
||||||
|
local_name: str = local_ref.get("name", instance_name)
|
||||||
|
local_input: str | None = local_ref.get("input")
|
||||||
|
|
||||||
|
# Normal match
|
||||||
|
if (
|
||||||
|
local_name == module_ref.get("name")
|
||||||
|
and local_input == module_ref.get("input")
|
||||||
|
) or (local_input == core_input_name and local_name == module_ref.get("name")):
|
||||||
|
res.append(instance_name)
|
||||||
|
|
||||||
return res
|
return res
|
||||||
|
|
||||||
|
|
||||||
@API.register
|
@API.register
|
||||||
def 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
|
||||||
@@ -264,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}",
|
||||||
@@ -278,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)
|
||||||
|
|
||||||
@@ -299,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")
|
||||||
@@ -319,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(
|
||||||
@@ -335,8 +403,10 @@ def create_service_instance(
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
class InventoryInstanceInfo(TypedDict):
|
@dataclass
|
||||||
module: Module
|
class InventoryInstanceInfo:
|
||||||
|
resolved: Module
|
||||||
|
module: InventoryInstanceModule
|
||||||
roles: InventoryInstanceRolesType
|
roles: InventoryInstanceRolesType
|
||||||
|
|
||||||
|
|
||||||
@@ -345,24 +415,19 @@ def list_service_instances(flake: Flake) -> dict[str, InventoryInstanceInfo]:
|
|||||||
"""Returns all currently present service instances including their full configuration"""
|
"""Returns all currently present service instances including their full configuration"""
|
||||||
inventory_store = InventoryStore(flake)
|
inventory_store = InventoryStore(flake)
|
||||||
inventory = inventory_store.read()
|
inventory = inventory_store.read()
|
||||||
service_modules = {
|
|
||||||
(mod["module"]["name"], mod["module"].get("input", "clan-core")): mod
|
|
||||||
for mod in list_service_modules(flake)
|
|
||||||
}
|
|
||||||
instances = inventory.get("instances", {})
|
instances = inventory.get("instances", {})
|
||||||
res: dict[str, InventoryInstanceInfo] = {}
|
res: dict[str, InventoryInstanceInfo] = {}
|
||||||
for instance_name, instance in instances.items():
|
for instance_name, instance in instances.items():
|
||||||
module_key = (
|
persisted_ref = instance.get("module", {"name": instance_name})
|
||||||
instance.get("module", {})["name"],
|
module = resolve_service_module_ref(flake, persisted_ref)
|
||||||
instance.get("module", {}).get("input")
|
|
||||||
or "clan-core", # Replace None (or falsey) with "clan-core"
|
|
||||||
)
|
|
||||||
module = service_modules.get(module_key)
|
|
||||||
if module is None:
|
if module is None:
|
||||||
msg = f"Module '{module_key}' for instance '{instance_name}' not found"
|
msg = f"Module for instance '{instance_name}' not found"
|
||||||
raise ClanError(msg)
|
raise ClanError(msg)
|
||||||
res[instance_name] = InventoryInstanceInfo(
|
res[instance_name] = InventoryInstanceInfo(
|
||||||
module=module,
|
resolved=module,
|
||||||
|
module=persisted_ref,
|
||||||
roles=instance.get("roles", {}),
|
roles=instance.get("roles", {}),
|
||||||
)
|
)
|
||||||
return res
|
return res
|
||||||
|
|||||||
@@ -1,35 +1,105 @@
|
|||||||
from collections.abc import Callable
|
from collections.abc import Callable
|
||||||
|
from typing import TYPE_CHECKING
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
from clan_cli.tests.fixtures_flakes import nested_dict
|
from clan_cli.tests.fixtures_flakes import nested_dict
|
||||||
from clan_lib.flake.flake import Flake
|
from clan_lib.flake.flake import Flake
|
||||||
from clan_lib.services.modules import list_service_instances
|
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
|
@pytest.mark.with_core
|
||||||
def test_list_service_instances(
|
def test_list_service_instances(
|
||||||
clan_flake: Callable[..., Flake],
|
clan_flake: Callable[..., Flake],
|
||||||
) -> None:
|
) -> None:
|
||||||
|
# ATTENTION! This method lacks Typechecking
|
||||||
config = nested_dict()
|
config = nested_dict()
|
||||||
config["inventory"]["machines"]["alice"] = {}
|
|
||||||
config["inventory"]["machines"]["bob"] = {}
|
|
||||||
# implicit module selection (defaults to clan-core/admin)
|
|
||||||
config["inventory"]["instances"]["admin"]["roles"]["default"]["tags"]["all"] = {}
|
|
||||||
# explicit module selection
|
# explicit module selection
|
||||||
config["inventory"]["instances"]["my-sshd"]["module"]["input"] = "clan-core"
|
# We use this random string in test to avoid code dependencies on the input name
|
||||||
config["inventory"]["instances"]["my-sshd"]["module"]["name"] = "sshd"
|
config["inventory"]["instances"]["foo"]["module"]["input"] = (
|
||||||
|
"Y2xhbi1jaW9yZS1uZXZlci1kZXBlbmQtb24tbWU"
|
||||||
|
)
|
||||||
|
config["inventory"]["instances"]["foo"]["module"]["name"] = "sshd"
|
||||||
# input = null
|
# input = null
|
||||||
config["inventory"]["instances"]["my-sshd-2"]["module"]["input"] = None
|
config["inventory"]["instances"]["bar"]["module"]["input"] = None
|
||||||
config["inventory"]["instances"]["my-sshd-2"]["module"]["name"] = "sshd"
|
config["inventory"]["instances"]["bar"]["module"]["name"] = "sshd"
|
||||||
|
|
||||||
|
# Omit input
|
||||||
|
config["inventory"]["instances"]["baz"]["module"]["name"] = "sshd"
|
||||||
# external input
|
# external input
|
||||||
flake = clan_flake(config)
|
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)
|
instances = list_service_instances(flake)
|
||||||
|
|
||||||
assert list(instances.keys()) == ["admin", "my-sshd", "my-sshd-2"]
|
assert set(instances.keys()) == {"foo", "bar", "baz"}
|
||||||
assert instances["admin"]["module"]["module"].get("input") == "clan-core"
|
|
||||||
assert instances["admin"]["module"]["module"].get("name") == "admin"
|
# Reference to a built-in module
|
||||||
assert instances["my-sshd"]["module"]["module"].get("input") == "clan-core"
|
assert instances["foo"].resolved.usage_ref.get("input") is None
|
||||||
assert instances["my-sshd"]["module"]["module"].get("name") == "sshd"
|
assert instances["foo"].resolved.usage_ref.get("name") == "sshd"
|
||||||
assert instances["my-sshd-2"]["module"]["module"].get("input") == "clan-core"
|
assert instances["foo"].resolved.info.manifest.name == "clan-core/sshd"
|
||||||
assert instances["my-sshd-2"]["module"]["module"].get("name") == "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({})
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user