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:
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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}'"
|
||||
)
|
||||
|
||||
@@ -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"}},
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
|
||||
Reference in New Issue
Block a user