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:
hsjobeki
2025-08-31 15:12:31 +00:00
12 changed files with 311 additions and 307 deletions

View File

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

View File

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

View File

@@ -143,6 +143,7 @@ export const useMachineStateQuery = (clanURI: string, machineName: string) => {
const client = useApiClient();
return useQuery<MachineState>(() => ({
queryKey: ["clans", encodeBase64(clanURI), "machine", machineName, "state"],
staleTime: 60_000, // 1 minute stale time
queryFn: async () => {
const apiCall = client.fetch("get_machine_state", {
machine: {

View File

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

View File

@@ -45,15 +45,12 @@ import {
} from "@/src/scene/highlightStore";
import { useClickOutside } from "@/src/hooks/useClickOutside";
type ModuleItem = ServiceModules[number];
type ModuleItem = ServiceModules["modules"][number];
interface Module {
value: string;
input?: string;
label: string;
description: string;
raw: ModuleItem;
instances: string[];
}
const SelectService = () => {
@@ -68,21 +65,10 @@ const SelectService = () => {
createEffect(() => {
if (serviceModulesQuery.data && serviceInstancesQuery.data) {
setModuleOptions(
serviceModulesQuery.data.map((m) => ({
value: `${m.module.name}:${m.module.input}`,
label: m.module.name,
description: m.info.manifest.description,
input: m.module.input,
raw: m,
// TODO: include the instances that use this module
instances: Object.entries(serviceInstancesQuery.data)
.filter(
([name, i]) =>
i.module.module.name === m.module.name &&
(!i.module.module.input ||
i.module.module.input === m.module.input),
)
.map(([name, _]) => name),
serviceModulesQuery.data.modules.map((currService) => ({
value: `${currService.usage_ref.name}:${currService.usage_ref.input}`,
label: currService.usage_ref.name,
raw: currService,
})),
);
}
@@ -97,8 +83,8 @@ const SelectService = () => {
if (!module) return;
set("module", {
name: module.raw.module.name,
input: module.raw.module.input,
name: module.raw.usage_ref.name,
input: module.raw.usage_ref.input,
raw: module.raw,
});
// TODO: Ideally we need to ask
@@ -108,14 +94,14 @@ const SelectService = () => {
// For now:
// Create a new instance, if there are no instances yet
// Update the first instance, if there is one
if (module.instances.length === 0) {
if (module.raw.instance_refs.length === 0) {
set("action", "create");
} else {
if (!serviceInstancesQuery.data) return;
if (!machinesQuery.data) return;
set("action", "update");
const instanceName = module.instances[0];
const instanceName = module.raw.instance_refs[0];
const instance = serviceInstancesQuery.data[instanceName];
console.log("Editing existing instance", module);
@@ -165,7 +151,7 @@ const SelectService = () => {
</div>
<div class="flex w-full flex-col">
<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">
<Typography hierarchy="label" weight="bold" size="xxs">
Added
@@ -184,11 +170,13 @@ const SelectService = () => {
inverted
class="flex justify-between"
>
<span class="inline-block max-w-32 truncate align-middle">
{item.description}
<span class="inline-block max-w-80 truncate align-middle">
{item.raw.info.manifest.description}
</span>
<span class="inline-block max-w-8 truncate align-middle">
by {item.input}
<span class="inline-block max-w-32 truncate align-middle">
<Show when={!item.raw.native} fallback="by clan-core">
by {item.raw.usage_ref.input}
</Show>
</span>
</Typography>
</div>
@@ -539,7 +527,7 @@ export interface InventoryInstance {
name: string;
module: {
name: string;
input?: string;
input?: string | null;
};
roles: Record<string, RoleType>;
}
@@ -552,7 +540,7 @@ interface RoleType {
export interface ServiceStoreType {
module: {
name: string;
input: string;
input?: string | null;
raw?: ModuleItem;
};
roles: Record<string, TagType[]>;

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,35 +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
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()
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
config["inventory"]["instances"]["my-sshd"]["module"]["input"] = "clan-core"
config["inventory"]["instances"]["my-sshd"]["module"]["name"] = "sshd"
# 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"]["my-sshd-2"]["module"]["input"] = None
config["inventory"]["instances"]["my-sshd-2"]["module"]["name"] = "sshd"
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 list(instances.keys()) == ["admin", "my-sshd", "my-sshd-2"]
assert instances["admin"]["module"]["module"].get("input") == "clan-core"
assert instances["admin"]["module"]["module"].get("name") == "admin"
assert instances["my-sshd"]["module"]["module"].get("input") == "clan-core"
assert instances["my-sshd"]["module"]["module"].get("name") == "sshd"
assert instances["my-sshd-2"]["module"]["module"].get("input") == "clan-core"
assert instances["my-sshd-2"]["module"]["module"].get("name") == "sshd"
assert set(instances.keys()) == {"foo", "bar", "baz"}
# Reference to a built-in module
assert instances["foo"].resolved.usage_ref.get("input") is None
assert instances["foo"].resolved.usage_ref.get("name") == "sshd"
assert instances["foo"].resolved.info.manifest.name == "clan-core/sshd"
# Actual module
assert (
instances["foo"].module.get("input")
== "Y2xhbi1jaW9yZS1uZXZlci1kZXBlbmQtb24tbWU"
)
# Module exposes the input name?
assert instances["bar"].resolved.usage_ref.get("input") is None
assert instances["bar"].resolved.usage_ref.get("name") == "sshd"
assert instances["baz"].resolved.usage_ref.get("input") is None
assert instances["baz"].resolved.usage_ref.get("name") == "sshd"
@pytest.mark.with_core
def test_list_service_modules(
clan_flake: Callable[..., Flake],
) -> None:
# Nice! This is typechecked :)
clan_config: Clan = {
"inventory": {
"instances": {
# No module spec -> resolves to clan-core/admin
"admin": {},
# Partial module spec
"admin2": {"module": {"name": "admin"}},
# Full explicit module spec
"admin3": {
"module": {
"name": "admin",
"input": "Y2xhbi1jaW9yZS1uZXZlci1kZXBlbmQtb24tbWU",
}
},
}
}
}
flake = clan_flake(clan_config)
service_modules = list_service_modules(flake)
# Detects the input name right
assert service_modules.core_input_name == "Y2xhbi1jaW9yZS1uZXZlci1kZXBlbmQtb24tbWU"
assert len(service_modules.modules)
admin_service = next(
m for m in service_modules.modules if m.usage_ref.get("name") == "admin"
)
assert admin_service
assert admin_service.usage_ref == {"name": "admin", "input": None}
assert set(admin_service.instance_refs) == {"admin", "admin2", "admin3"}
# Negative test: Assert not used
sshd_service = next(
m for m in service_modules.modules if m.usage_ref.get("name") == "sshd"
)
assert sshd_service
assert sshd_service.usage_ref == {"name": "sshd", "input": None}
assert set(sshd_service.instance_refs) == set({})

View File

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

View File

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