diff --git a/pkgs/clan-cli/clan_lib/fixtures/flakes/flakes.py b/pkgs/clan-cli/clan_lib/fixtures/flakes/flakes.py index ec0917707..29eb56a4a 100644 --- a/pkgs/clan-cli/clan_lib/fixtures/flakes/flakes.py +++ b/pkgs/clan-cli/clan_lib/fixtures/flakes/flakes.py @@ -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 diff --git a/pkgs/clan-cli/clan_lib/machines/actions.py b/pkgs/clan-cli/clan_lib/machines/actions.py index 5b466c494..d30889f3e 100644 --- a/pkgs/clan-cli/clan_lib/machines/actions.py +++ b/pkgs/clan-cli/clan_lib/machines/actions.py @@ -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) diff --git a/pkgs/clan-cli/clan_lib/machines/actions_test.py b/pkgs/clan-cli/clan_lib/machines/actions_test.py index d3dd044b9..bda087df4 100644 --- a/pkgs/clan-cli/clan_lib/machines/actions_test.py +++ b/pkgs/clan-cli/clan_lib/machines/actions_test.py @@ -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") diff --git a/pkgs/clan-cli/clan_lib/persist/util.py b/pkgs/clan-cli/clan_lib/persist/util.py index 7781634b3..49e2baa4d 100644 --- a/pkgs/clan-cli/clan_lib/persist/util.py +++ b/pkgs/clan-cli/clan_lib/persist/util.py @@ -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: diff --git a/pkgs/clan-cli/clan_lib/services/modules.py b/pkgs/clan-cli/clan_lib/services/modules.py index ba87ad31d..0938ece08 100644 --- a/pkgs/clan-cli/clan_lib/services/modules.py +++ b/pkgs/clan-cli/clan_lib/services/modules.py @@ -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}'" + ) diff --git a/pkgs/clan-cli/clan_lib/services/modules_test.py b/pkgs/clan-cli/clan_lib/services/modules_test.py index 49088d7f7..d7aab3043 100644 --- a/pkgs/clan-cli/clan_lib/services/modules_test.py +++ b/pkgs/clan-cli/clan_lib/services/modules_test.py @@ -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"}}, + } diff --git a/pkgs/clan-cli/clan_lib/tags/list_test.py b/pkgs/clan-cli/clan_lib/tags/list_test.py index f2a64cbd3..f22c18555 100644 --- a/pkgs/clan-cli/clan_lib/tags/list_test.py +++ b/pkgs/clan-cli/clan_lib/tags/list_test.py @@ -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")