api/modules: add foreign key to instances

This commit is contained in:
Johannes Kirschbauer
2025-08-31 15:46:36 +02:00
parent c447aec9d3
commit 3862ad2a06
3 changed files with 114 additions and 16 deletions

View File

@@ -49,7 +49,7 @@ type ModuleItem = ServiceModules[number];
interface Module {
value: string;
input?: string;
input?: string | null;
label: string;
description: string;
raw: ModuleItem;
@@ -68,19 +68,20 @@ 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,
serviceModulesQuery.data.modules.map((currService) => ({
value: `${currService.usage_ref.name}:${currService.usage_ref.input}`,
label: currService.info.manifest.name,
description: currService.info.manifest.description,
input: currService.usage_ref.input,
raw: currService,
// 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),
([name, instance]) =>
instance.resolved.usage_ref.name ===
currService.usage_ref.name &&
instance.resolved.usage_ref.input ===
currService.usage_ref.input,
)
.map(([name, _]) => name),
})),

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
@@ -157,7 +158,8 @@ class Module:
# To use this module specify: InventoryInstanceModule :: { input, name } (foreign key)
usage_ref: InventoryInstanceModule
info: ModuleInfo
native: bool # Equivalent to input == None
native: bool
instance_refs: list[str]
@dataclass
@@ -166,6 +168,41 @@ class ClanModules:
core_input_name: str
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 list_service_modules(flake: Flake) -> ClanModules:
"""Show information about a module"""
@@ -178,6 +215,8 @@ def list_service_modules(flake: Flake) -> ClanModules:
builtin_modules: dict[str, Any] = flake.select(
"clanInternals.inventoryClass.staticModules"
)
inventory_store = InventoryStore(flake)
instances = inventory_store.read().get("instances", {})
first_name, first_module = next(iter(builtin_modules.items()))
clan_input_name = None
@@ -197,12 +236,18 @@ def list_service_modules(flake: Flake) -> ClanModules:
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(
usage_ref={
"name": module_name,
"input": None if input_name == clan_input_name else input_name,
},
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"]),

View File

@@ -1,15 +1,20 @@
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
@@ -51,3 +56,50 @@ def test_list_service_instances(
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({})