from dataclasses import dataclass, field from enum import StrEnum 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.machines.machines import Machine from clan_lib.nix_models.clan import ( InventoryInstance, InventoryMachine, InventoryMachineTagsType, ) from clan_lib.persist.introspection import retrieve_typed_field_names from clan_lib.persist.inventory_store import InventoryStore from clan_lib.persist.path_utils import ( get_value_by_path, list_difference, set_value_by_path, ) from clan_lib.persist.write_rules import is_writeable_key @dataclass class MachineFilter: tags: list[str] | None = None @dataclass class ListOptions: filter: MachineFilter = field(default_factory=MachineFilter) class MachineStatus(StrEnum): NOT_INSTALLED = "not_installed" OFFLINE = "offline" OUT_OF_SYNC = "out_of_sync" ONLINE = "online" class MachineState(TypedDict): status: MachineStatus # add more info later when retrieving remote state @dataclass class MachineResponse: data: InventoryMachine # Reference the installed service instances instance_refs: set[str] = field(default_factory=set) def machine_instances( machine_name: str, instances: dict[str, InventoryInstance], tag_map: dict[str, set[str]], ) -> set[str]: res: set[str] = set() for instance_name, instance in instances.items(): for role in instance.get("roles", {}).values(): if machine_name in role.get("machines", {}): res.add(instance_name) for tag in role.get("tags", {}): if tag in tag_map and machine_name in tag_map[tag]: res.add(instance_name) return res @API.register def list_machines( flake: Flake, opts: ListOptions | None = None, ) -> dict[str, MachineResponse]: """List machines of a clan""" inventory_store = InventoryStore(flake=flake) inventory = inventory_store.read() raw_machines = inventory.get("machines", {}) tag_map: dict[str, set[str]] = {} for machine_name, machine in raw_machines.items(): for tag in machine.get("tags", []): if tag not in tag_map: tag_map[tag] = set() tag_map[tag].add(machine_name) instances = inventory.get("instances", {}) res: dict[str, MachineResponse] = {} for machine_name, machine in raw_machines.items(): m = MachineResponse( data=InventoryMachine(**machine), instance_refs=machine_instances(machine_name, instances, tag_map), ) # Check filters if opts and opts.filter.tags is not None: machine_tags = machine.get("tags", []) if not all(ft in machine_tags for ft in opts.filter.tags): continue res[machine_name] = m return res @API.register def get_machine(flake: Flake, name: str) -> InventoryMachine: """Retrieve a machine's inventory details by name from the given flake. Args: flake (Flake): The flake object representing the configuration source. name (str): The name of the machine to retrieve from the inventory. Returns: InventoryMachine: An instance representing the machine's inventory details. Raises: ClanError: If the machine with the specified name is not found in the clan """ inventory_store = InventoryStore(flake=flake) inventory = inventory_store.read() machine_inv = inventory.get("machines", {}).get(name) if machine_inv is None: msg = f"Machine {name} does not exist" raise ClanError(msg) return InventoryMachine(**machine_inv) @API.register def set_machine(machine: Machine, update: InventoryMachine) -> None: """Update the machine information in the inventory.""" if machine.name != update.get("name", machine.name): msg = "Machine name mismatch" raise ClanError(msg) inventory_store = InventoryStore(flake=machine.flake) inventory = inventory_store.read() set_value_by_path(inventory, f"machines.{machine.name}", update) inventory_store.write( inventory, message=f"Update information about machine {machine.name}", ) class FieldSchema(TypedDict): readonly: bool reason: str | None readonly_members: list[str] @API.register def get_machine_fields_schema(machine: Machine) -> dict[str, FieldSchema]: """Get attributes for each field of the machine. This function checks which fields of the 'machine' resource are readonly and provides a reason if so. Args: machine (Machine): The machine object for which to retrieve fields. Returns: dict[str, FieldSchema]: A map from field-names to { 'readonly' (bool) and 'reason' (str or None ) } """ inventory_store = InventoryStore(machine.flake) write_info = inventory_store.get_write_map() field_names = retrieve_typed_field_names(InventoryMachine) protected_fields = { "name", # name is always readonly "machineClass", # machineClass can only be set during create } # 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", [], InventoryMachineTagsType ) persisted_tags = get_value_by_path( persisted_data, f"machines.{machine.name}.tags", [], InventoryMachineTagsType ) nix_tags = list_difference(all_tags, persisted_tags) return { field: { "readonly": ( True if field in protected_fields else not is_writeable_key( f"machines.{machine.name}.{field}", write_info, ) ), # TODO: Provide a meaningful reason "reason": None, "readonly_members": nix_tags if field == "tags" else [], } for field in field_names } @API.register def list_machine_state(flake: Flake) -> dict[str, MachineState]: """Retrieve the current state of all machines in the clan. Args: flake (Flake): The flake object representing the configuration source. """ inventory_store = InventoryStore(flake=flake) inventory = inventory_store.read() # todo integrate with remote state when implementing https://git.clan.lol/clan/clan-core/issues/4748 machines = inventory.get("machines", {}) return { machine_name: MachineState( status=MachineStatus.OFFLINE if get_value_by_path(machine, "installedAt", None) else MachineStatus.NOT_INSTALLED, ) for machine_name, machine in machines.items() } @API.register def get_machine_state(machine: Machine) -> MachineState: """Retrieve the current state of the machine. Args: machine (Machine): The machine object for which we want to retrieve the latest state. """ inventory_store = InventoryStore(flake=machine.flake) inventory = inventory_store.read() # todo integrate with remote state when implementing https://git.clan.lol/clan/clan-core/issues/4748 return MachineState( status=MachineStatus.OFFLINE if get_value_by_path(inventory, f"machines.{machine.name}.installedAt", None) else MachineStatus.NOT_INSTALLED, )