diff --git a/pkgs/clan-app/ui/src/components/Sidebar/SidebarBody.tsx b/pkgs/clan-app/ui/src/components/Sidebar/SidebarBody.tsx index f682675ee..9ad81d549 100644 --- a/pkgs/clan-app/ui/src/components/Sidebar/SidebarBody.tsx +++ b/pkgs/clan-app/ui/src/components/Sidebar/SidebarBody.tsx @@ -136,7 +136,7 @@ export const SidebarBody = (props: SidebarProps) => { )} diff --git a/pkgs/clan-app/ui/src/workflows/AddMachine/AddMachine.stories.tsx b/pkgs/clan-app/ui/src/workflows/AddMachine/AddMachine.stories.tsx index f1942c717..fef1ebec6 100644 --- a/pkgs/clan-app/ui/src/workflows/AddMachine/AddMachine.stories.tsx +++ b/pkgs/clan-app/ui/src/workflows/AddMachine/AddMachine.stories.tsx @@ -26,13 +26,19 @@ const mockFetcher: Fetcher = ( const resultData: Partial = { list_machines: { pandora: { - name: "pandora", + data: { + name: "pandora", + }, }, enceladus: { - name: "enceladus", + data: { + name: "enceladus", + }, }, dione: { - name: "dione", + data: { + name: "dione", + }, }, }, }; diff --git a/pkgs/clan-app/ui/src/workflows/Service/Service.stories.tsx b/pkgs/clan-app/ui/src/workflows/Service/Service.stories.tsx index 92359c054..663a158c8 100644 --- a/pkgs/clan-app/ui/src/workflows/Service/Service.stories.tsx +++ b/pkgs/clan-app/ui/src/workflows/Service/Service.stories.tsx @@ -62,20 +62,28 @@ const mockFetcher: Fetcher = ( }, list_machines: { jon: { - name: "jon", - tags: ["all", "nixos", "tag1"], + data: { + name: "jon", + tags: ["all", "nixos", "tag1"], + }, }, sara: { - name: "sara", - tags: ["all", "darwin", "tag2"], + data: { + name: "sara", + tags: ["all", "darwin", "tag2"], + }, }, kyra: { - name: "kyra", - tags: ["all", "darwin", "tag2"], + data: { + name: "kyra", + tags: ["all", "darwin", "tag2"], + }, }, leila: { - name: "leila", - tags: ["all", "darwin", "tag2"], + data: { + name: "leila", + tags: ["all", "darwin", "tag2"], + }, }, }, list_tags: { diff --git a/pkgs/clan-app/ui/src/workflows/Service/Service.tsx b/pkgs/clan-app/ui/src/workflows/Service/Service.tsx index 59f913b9e..d7f04bb96 100644 --- a/pkgs/clan-app/ui/src/workflows/Service/Service.tsx +++ b/pkgs/clan-app/ui/src/workflows/Service/Service.tsx @@ -120,7 +120,7 @@ const SelectService = () => { label: t, type: "tag" as const, members: Object.entries(machinesQuery.data || {}) - .filter(([_, m]) => m.tags?.includes(t)) + .filter(([_, m]) => m.data.tags?.includes(t)) .map(([k]) => k), }; }); @@ -206,7 +206,7 @@ const useOptions = (tagsQuery: TagsQuery, machinesQuery: MachinesQuery) => label: tag, value: "t_" + tag, members: Object.entries(machines) - .filter(([_, v]) => v.tags?.includes(tag)) + .filter(([_, v]) => v.data.tags?.includes(tag)) .map(([k]) => k), })); diff --git a/pkgs/clan-cli/clan_cli/machines/update.py b/pkgs/clan-cli/clan_cli/machines/update.py index eb274e3e6..57a4cb889 100644 --- a/pkgs/clan-cli/clan_cli/machines/update.py +++ b/pkgs/clan-cli/clan_cli/machines/update.py @@ -103,7 +103,9 @@ def get_machines_for_update( machines_to_update = list( filter( requires_explicit_update, - instantiate_inventory_to_machines(flake, machines_with_tags).values(), + instantiate_inventory_to_machines( + flake, {name: m.data for name, m in machines_with_tags.items()} + ).values(), ), ) # all machines that are in the clan but not included in the update list @@ -128,13 +130,13 @@ def get_machines_for_update( machines_to_update = [] valid_names = validate_machine_names(explicit_names, flake) for name in valid_names: - inventory_machine = machines_with_tags.get(name) - if not inventory_machine: + machine = machines_with_tags.get(name) + if not machine: msg = "This is an internal bug" raise ClanError(msg) machines_to_update.append( - Machine.from_inventory(name, flake, inventory_machine), + Machine.from_inventory(name, flake, machine.data), ) return machines_to_update diff --git a/pkgs/clan-cli/clan_lib/machines/actions.py b/pkgs/clan-cli/clan_lib/machines/actions.py index fd7212b5f..5b466c494 100644 --- a/pkgs/clan-cli/clan_lib/machines/actions.py +++ b/pkgs/clan-cli/clan_lib/machines/actions.py @@ -7,6 +7,7 @@ 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, ) from clan_lib.persist.inventory_store import InventoryStore @@ -41,28 +42,68 @@ class MachineState(TypedDict): # 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, InventoryMachine]: +) -> dict[str, MachineResponse]: """List machines of a clan""" inventory_store = InventoryStore(flake=flake) inventory = inventory_store.read() - machines = inventory.get("machines", {}) + raw_machines = inventory.get("machines", {}) - if opts and opts.filter.tags is not None: - filtered_machines = {} + tag_map: dict[str, set[str]] = {} - for machine_name, machine in machines.items(): + 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 all(ft in machine_tags for ft in opts.filter.tags): - filtered_machines[machine_name] = machine + if not all(ft in machine_tags for ft in opts.filter.tags): + continue - return filtered_machines + res[machine_name] = m - return machines + return res @API.register diff --git a/pkgs/clan-cli/clan_lib/machines/actions_test.py b/pkgs/clan-cli/clan_lib/machines/actions_test.py index bea24e0ed..d3dd044b9 100644 --- a/pkgs/clan-cli/clan_lib/machines/actions_test.py +++ b/pkgs/clan-cli/clan_lib/machines/actions_test.py @@ -67,6 +67,33 @@ def test_list_inventory_machines(clan_flake: Callable[..., Flake]) -> None: assert list(machines.keys()) == ["jon", "sara", "vanessa"] +@pytest.mark.with_core +def test_list_machines_instance_refs(clan_flake: Callable[..., Flake]) -> None: + flake = clan_flake( + { + "inventory": { + "machines": { + "jon": {}, + "sara": {}, + }, + "instances": { + "admin": { + "roles": {"default": {"machines": {"jon": {}}}}, + }, + "borgbackup": { + "roles": {"default": {"tags": {"all": {}}}}, + }, + }, + }, + }, + ) + + machines = list_machines(flake) + + assert machines["sara"].instance_refs == set({"borgbackup"}) + assert machines["jon"].instance_refs == set({"admin", "borgbackup"}) + + @pytest.mark.with_core def test_set_machine_no_op(clan_flake: Callable[..., Flake]) -> None: flake = clan_flake( diff --git a/pkgs/clan-cli/clan_lib/machines/list.py b/pkgs/clan-cli/clan_lib/machines/list.py index b80ddbd4a..17ee2f412 100644 --- a/pkgs/clan-cli/clan_lib/machines/list.py +++ b/pkgs/clan-cli/clan_lib/machines/list.py @@ -30,7 +30,9 @@ def list_full_machines(flake: Flake) -> dict[str, Machine]: """Like `list_machines`, but returns a full 'machine' instance for each machine.""" machines = list_machines(flake) - return instantiate_inventory_to_machines(flake, machines) + return instantiate_inventory_to_machines( + flake, {name: m.data for name, m in machines.items()} + ) @dataclass