Files
clan-core/pkgs/clan-cli/clan_lib/machines/actions.py
2025-09-21 17:30:33 +02:00

253 lines
7.4 KiB
Python

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,
)