Merge pull request 'clan_lib/api: init update service instance' (#5199) from api-update-service into main

Reviewed-on: https://git.clan.lol/clan/clan-core/pulls/5199
This commit is contained in:
hsjobeki
2025-09-18 14:33:18 +00:00
7 changed files with 228 additions and 16 deletions

View File

@@ -28,6 +28,10 @@ def offline_template(tmp_path_factory: Any, offline_session_flake_hook: Any) ->
with (clan_json_file).open("w") as f:
f.write(r"""{ }""")
inventory_json_file = dst_dir / "inventory.json"
with (inventory_json_file).open("w") as f:
f.write(r"""{ }""")
# expensive call ~6 seconds
# Run in session scope to avoid repeated calls
offline_session_flake_hook(dst_dir)
@@ -56,7 +60,11 @@ def clan_flake(
tmp_path: Path,
patch_clan_template: Any, # noqa: ARG001
) -> Callable[[Clan | None, str | None], Flake]:
def factory(clan: Clan | None = None, raw: str | None = None) -> Flake:
def factory(
clan: Clan | None = None,
raw: str | None = None,
mutable_inventory_json: str | None = None,
) -> Flake:
# TODO: Make more options configurable
if clan is None and raw is None:
msg = "Either 'clan' or 'raw' must be provided to create a Flake."
@@ -78,6 +86,10 @@ def clan_flake(
with (dest / "clan.nix").open("w") as f:
f.write(raw)
if mutable_inventory_json is not None:
with (dest / "inventory.json").open("w") as f:
f.write(json.dumps(mutable_inventory_json))
return Flake(str(dest))
return factory

View File

@@ -9,6 +9,7 @@ from clan_lib.machines.machines import Machine
from clan_lib.nix_models.clan import (
InventoryInstance,
InventoryMachine,
InventoryMachineTagsType,
)
from clan_lib.persist.inventory_store import InventoryStore
from clan_lib.persist.util import (
@@ -181,11 +182,11 @@ def get_machine_fields_schema(machine: Machine) -> dict[str, FieldSchema]:
# TODO: handle this more generically. I.e via json schema
persisted_data = inventory_store._get_persisted() # noqa: SLF001
inventory = inventory_store.read()
all_tags = get_value_by_path(inventory, f"machines.{machine.name}.tags", [])
all_tags = get_value_by_path(
inventory, f"machines.{machine.name}.tags", [], InventoryMachineTagsType
)
persisted_tags = get_value_by_path(
persisted_data,
f"machines.{machine.name}.tags",
[],
persisted_data, f"machines.{machine.name}.tags", [], InventoryMachineTagsType
)
nix_tags = list_difference(all_tags, persisted_tags)

View File

@@ -9,7 +9,12 @@ from clan_lib.errors import ClanError
from clan_lib.flake import Flake
from clan_lib.machines import actions as actions_module
from clan_lib.machines.machines import Machine
from clan_lib.nix_models.clan import Clan, InventoryMachine, Unknown
from clan_lib.nix_models.clan import (
Clan,
InventoryMachine,
InventoryMachineTagsType,
Unknown,
)
from clan_lib.persist.inventory_store import InventoryStore
from clan_lib.persist.util import get_value_by_path, set_value_by_path
@@ -233,7 +238,9 @@ def test_get_machine_writeability(clan_flake: Callable[..., Flake]) -> None:
# TODO: Move this into the api
inventory_store = InventoryStore(flake=flake)
inventory = inventory_store.read()
curr_tags = get_value_by_path(inventory, "machines.jon.tags", [])
curr_tags = get_value_by_path(
inventory, "machines.jon.tags", [], InventoryMachineTagsType
)
new_tags = ["managed1", "managed2"]
set_value_by_path(inventory, "machines.jon.tags", [*curr_tags, *new_tags])
inventory_store.write(inventory, message="Test writeability")

View File

@@ -431,9 +431,11 @@ def delete_by_path(d: dict[str, Any], path: str) -> Any:
last_key = keys[-1]
try:
value = current.pop(last_key)
except KeyError as exc:
msg = f"Cannot delete. Path '{path}' not found in data '{d}'"
raise KeyError(msg) from exc
except KeyError:
# Possibly data was already deleted
# msg = f"Canot delete. Path '{path}' not found in data '{d}'"
# raise KeyError(msg) from exc
return {}
else:
return {last_key: value}
@@ -441,7 +443,15 @@ def delete_by_path(d: dict[str, Any], path: str) -> Any:
type DictLike = dict[str, Any] | Any
def get_value_by_path(d: DictLike, path: str, fallback: Any = None) -> Any:
V = TypeVar("V")
def get_value_by_path(
d: DictLike,
path: str,
fallback: V | None = None,
expected_type: type[V] | None = None, # noqa: ARG001
) -> V:
"""Get the value at a specific dot-separated path in a nested dictionary.
If the path does not exist, it returns fallback.
@@ -455,9 +465,9 @@ def get_value_by_path(d: DictLike, path: str, fallback: Any = None) -> Any:
current = current.setdefault(key, {})
if isinstance(current, dict):
return current.get(keys[-1], fallback)
return cast("V", current.get(keys[-1], fallback))
return fallback
return cast("V", fallback)
def set_value_by_path(d: DictLike, path: str, content: Any) -> None:

View File

@@ -14,7 +14,7 @@ from clan_lib.nix_models.clan import (
InventoryInstancesType,
)
from clan_lib.persist.inventory_store import InventoryStore
from clan_lib.persist.util import set_value_by_path
from clan_lib.persist.util import get_value_by_path, set_value_by_path
class CategoryInfo(TypedDict):
@@ -431,3 +431,64 @@ def list_service_instances(flake: Flake) -> dict[str, InventoryInstanceInfo]:
roles=instance.get("roles", {}),
)
return res
@API.register
def update_service_instance(
flake: Flake, instance_ref: str, roles: InventoryInstanceRolesType
) -> None:
"""Update the roles of a service instance
:param instance_ref: The name of the instance to update
:param roles: The roles to update
:raises ClanError: If the instance_ref is invalid or missing required fields
"""
inventory_store = InventoryStore(flake)
inventory = inventory_store.read()
instance: InventoryInstance | None = get_value_by_path(
inventory, f"instances.{instance_ref}", None
)
if instance is None:
msg = f"Instance '{instance_ref}' not found"
raise ClanError(msg)
module_ref = instance.get("module")
if module_ref is None:
msg = f"Instance '{instance_ref}' seems invalid: Missing module reference"
raise ClanError(msg)
module = resolve_service_module_ref(flake, module_ref)
allowed_roles = module.info.roles.keys()
for role_name in roles:
if role_name not in allowed_roles:
msg = f"Role '{role_name}' cannot be used in the module"
description = f"Allowed roles: {', '.join(allowed_roles)}"
raise ClanError(msg, description=description)
for role_name, role_cfg in roles.items():
if forbidden_keys := {"extraModules"} & role_cfg.keys():
msg = f"Role '{role_name}' cannot contain {', '.join(f"'{k}'" for k in forbidden_keys)} directly"
raise ClanError(msg)
static = get_value_by_path(
instance, f"roles.{role_name}", {}, expected_type=InventoryInstanceRolesType
)
# override settings, machines only if passed
merged = {
**static,
**role_cfg,
}
set_value_by_path(
inventory, f"instances.{instance_ref}.roles.{role_name}", merged
)
inventory_store.write(
inventory, message=f"Update service instance '{instance_ref}'"
)

View File

@@ -3,8 +3,14 @@ from typing import TYPE_CHECKING
import pytest
from clan_cli.tests.fixtures_flakes import nested_dict
from clan_lib.errors import ClanError
from clan_lib.flake.flake import Flake
from clan_lib.services.modules import list_service_instances, list_service_modules
from clan_lib.nix_models.clan import Inventory
from clan_lib.services.modules import (
list_service_instances,
list_service_modules,
update_service_instance,
)
if TYPE_CHECKING:
from clan_lib.nix_models.clan import Clan
@@ -103,3 +109,115 @@ def test_list_service_modules(
assert sshd_service
assert sshd_service.usage_ref == {"name": "sshd", "input": None}
assert set(sshd_service.instance_refs) == set({})
@pytest.mark.with_core
def test_update_service_instance(
clan_flake: Callable[..., Flake],
) -> None:
# Data that can be mutated via API calls
mutable_inventory_json: Inventory = {
"instances": {
"hello-world": {
"roles": {
"morning": {
"machines": {
"jon": {
"settings": { # type: ignore[typeddict-item]
"greeting": "jon",
},
},
"sara": {
"settings": { # type: ignore[typeddict-item]
"greeting": "sara",
},
},
},
"tags": {
"all": {},
},
"settings": { # type: ignore[typeddict-item]
"greeting": "hello",
},
}
}
}
}
}
flake = clan_flake({}, mutable_inventory_json=mutable_inventory_json)
# Ensure preconditions
instances = list_service_instances(flake)
assert set(instances.keys()) == {"hello-world"}
# Wrong instance
with pytest.raises(ClanError) as excinfo:
update_service_instance(
flake,
"admin",
{},
)
assert "Instance 'admin' not found" in str(excinfo.value)
# Wrong roles
with pytest.raises(ClanError) as excinfo:
update_service_instance(
flake,
"hello-world",
{"default": {"machines": {}}},
)
assert "Role 'default' cannot be used" in str(excinfo.value)
# Remove 'settings' from jon machine
update_service_instance(
flake,
"hello-world",
{
"morning": {
"machines": {
"jon": {}, # Unset the machine settings
"sara": {
"settings": { # type: ignore[typeddict-item]
"greeting": "sara",
},
},
},
}
},
)
updated_instances = list_service_instances(flake)
updated_machines = (
updated_instances["hello-world"].roles.get("morning", {}).get("machines", {})
)
assert updated_machines == {
"jon": {"settings": {}},
"sara": {"settings": {"greeting": "sara"}},
}
# Remove jon
update_service_instance(
flake,
"hello-world",
{
"morning": {
"machines": {
"sara": {
"settings": { # type: ignore[typeddict-item]
"greeting": "sara",
},
},
},
}
},
)
updated_instances = list_service_instances(flake)
updated_machines = (
updated_instances["hello-world"].roles.get("morning", {}).get("machines", {})
)
assert updated_machines == {
"sara": {"settings": {"greeting": "sara"}},
}

View File

@@ -3,6 +3,7 @@ from collections.abc import Callable
import pytest
from clan_lib.flake import Flake
from clan_lib.nix_models.clan import InventoryMachineTagsType
from clan_lib.persist.inventory_store import InventoryStore
from clan_lib.persist.util import get_value_by_path, set_value_by_path
from clan_lib.tags.list import list_tags
@@ -45,7 +46,9 @@ def test_list_inventory_tags(clan_flake: Callable[..., Flake]) -> None:
inventory_store = InventoryStore(flake=flake)
inventory = inventory_store.read()
curr_tags = get_value_by_path(inventory, "machines.jon.tags", [])
curr_tags = get_value_by_path(
inventory, "machines.jon.tags", [], InventoryMachineTagsType
)
new_tags = ["managed1", "managed2"]
set_value_by_path(inventory, "machines.jon.tags", [*curr_tags, *new_tags])
inventory_store.write(inventory, message="Test add tags via API")