From 2608bee30ae2f1e79a14d633907c3cd275223e17 Mon Sep 17 00:00:00 2001 From: Brian McGee Date: Mon, 11 Aug 2025 14:44:16 +0100 Subject: [PATCH 1/5] feat(api): add list_inventory_tags --- pkgs/clan-cli/clan_lib/inventory/actions.py | 29 ++++++++ .../clan_lib/inventory/actions_test.py | 72 +++++++++++++++++++ 2 files changed, 101 insertions(+) create mode 100644 pkgs/clan-cli/clan_lib/inventory/actions.py create mode 100644 pkgs/clan-cli/clan_lib/inventory/actions_test.py diff --git a/pkgs/clan-cli/clan_lib/inventory/actions.py b/pkgs/clan-cli/clan_lib/inventory/actions.py new file mode 100644 index 000000000..7977521e3 --- /dev/null +++ b/pkgs/clan-cli/clan_lib/inventory/actions.py @@ -0,0 +1,29 @@ +from typing import TypedDict + +from clan_lib.api import API +from clan_lib.flake import Flake +from clan_lib.persist.inventory_store import InventoryStore + +readonly_tags = {"all", "nixos", "darwin"} + + +class MachineTag(TypedDict): + name: str + readonly: bool + + +@API.register +def list_inventory_tags(flake: Flake) -> list[MachineTag]: + inventory_store = InventoryStore(flake=flake) + inventory = inventory_store.read() + + machines = inventory.get("machines", {}) + + tags: dict[str, MachineTag] = {} + + for _, machine in machines.items(): + machine_tags = machine.get("tags", []) + for tag in machine_tags: + tags[tag] = MachineTag(name=tag, readonly=tag in readonly_tags) + + return sorted(tags.values(), key=lambda x: x["name"]) diff --git a/pkgs/clan-cli/clan_lib/inventory/actions_test.py b/pkgs/clan-cli/clan_lib/inventory/actions_test.py new file mode 100644 index 000000000..f55d43896 --- /dev/null +++ b/pkgs/clan-cli/clan_lib/inventory/actions_test.py @@ -0,0 +1,72 @@ +from collections.abc import Callable + +import pytest + +from clan_lib.flake import Flake + +from .actions import MachineTag, list_inventory_tags + + +@pytest.mark.with_core +def test_list_inventory_tags(clan_flake: Callable[..., Flake]) -> None: + flake = clan_flake( + { + "inventory": { + "machines": { + "jon": { + # machineClass defaults to nixos + "tags": ["foo", "bar"], + }, + "sara": { + "machineClass": "darwin", + "tags": ["foo", "baz", "fizz"], + }, + "bob": { + "machineClass": "nixos", + "tags": ["foo", "bar"], + }, + }, + } + }, + ) + + tags = list_inventory_tags(flake) + + assert tags == [ + MachineTag(name="all", readonly=True), + MachineTag(name="bar", readonly=False), + MachineTag(name="baz", readonly=False), + MachineTag(name="darwin", readonly=True), + MachineTag(name="fizz", readonly=False), + MachineTag(name="foo", readonly=False), + MachineTag(name="nixos", readonly=True), + ] + + +@pytest.mark.with_core +def test_list_inventory_tags_defaults(clan_flake: Callable[..., Flake]) -> None: + flake = clan_flake( + { + "inventory": { + "machines": { + "jon": { + # machineClass defaults to nixos + }, + "sara": { + "machineClass": "darwin", + }, + "bob": { + "machineClass": "nixos", + }, + }, + } + }, + ) + + tags = list_inventory_tags(flake) + + assert tags == [ + MachineTag(name="all", readonly=True), + MachineTag(name="darwin", readonly=True), + MachineTag(name="nixos", readonly=True), + ] From cbd3b08296683f1cac1c1a71d9e2e178b6370dde Mon Sep 17 00:00:00 2001 From: Johannes Kirschbauer Date: Mon, 11 Aug 2025 23:23:01 +0200 Subject: [PATCH 2/5] api/tags: add from all possible sources --- pkgs/clan-cli/clan_lib/inventory/__init__.py | 12 --- pkgs/clan-cli/clan_lib/inventory/actions.py | 29 ------- .../clan_lib/inventory/actions_test.py | 72 ----------------- .../clan_lib/persist/inventory_store.py | 2 + pkgs/clan-cli/clan_lib/tags/list.py | 34 ++++++++ pkgs/clan-cli/clan_lib/tags/list_test.py | 78 +++++++++++++++++++ 6 files changed, 114 insertions(+), 113 deletions(-) delete mode 100644 pkgs/clan-cli/clan_lib/inventory/__init__.py delete mode 100644 pkgs/clan-cli/clan_lib/inventory/actions.py delete mode 100644 pkgs/clan-cli/clan_lib/inventory/actions_test.py create mode 100644 pkgs/clan-cli/clan_lib/tags/list.py create mode 100644 pkgs/clan-cli/clan_lib/tags/list_test.py diff --git a/pkgs/clan-cli/clan_lib/inventory/__init__.py b/pkgs/clan-cli/clan_lib/inventory/__init__.py deleted file mode 100644 index e2fd4eba4..000000000 --- a/pkgs/clan-cli/clan_lib/inventory/__init__.py +++ /dev/null @@ -1,12 +0,0 @@ -""" -DEPRECATED: - -Don't use this module anymore - -Instead use: -'clan_lib.persist.inventoryStore' - -Which is an abstraction over the inventory - -Interacting with 'clan_lib.inventory' is NOT recommended and will be removed -""" diff --git a/pkgs/clan-cli/clan_lib/inventory/actions.py b/pkgs/clan-cli/clan_lib/inventory/actions.py deleted file mode 100644 index 7977521e3..000000000 --- a/pkgs/clan-cli/clan_lib/inventory/actions.py +++ /dev/null @@ -1,29 +0,0 @@ -from typing import TypedDict - -from clan_lib.api import API -from clan_lib.flake import Flake -from clan_lib.persist.inventory_store import InventoryStore - -readonly_tags = {"all", "nixos", "darwin"} - - -class MachineTag(TypedDict): - name: str - readonly: bool - - -@API.register -def list_inventory_tags(flake: Flake) -> list[MachineTag]: - inventory_store = InventoryStore(flake=flake) - inventory = inventory_store.read() - - machines = inventory.get("machines", {}) - - tags: dict[str, MachineTag] = {} - - for _, machine in machines.items(): - machine_tags = machine.get("tags", []) - for tag in machine_tags: - tags[tag] = MachineTag(name=tag, readonly=tag in readonly_tags) - - return sorted(tags.values(), key=lambda x: x["name"]) diff --git a/pkgs/clan-cli/clan_lib/inventory/actions_test.py b/pkgs/clan-cli/clan_lib/inventory/actions_test.py deleted file mode 100644 index f55d43896..000000000 --- a/pkgs/clan-cli/clan_lib/inventory/actions_test.py +++ /dev/null @@ -1,72 +0,0 @@ -from collections.abc import Callable - -import pytest - -from clan_lib.flake import Flake - -from .actions import MachineTag, list_inventory_tags - - -@pytest.mark.with_core -def test_list_inventory_tags(clan_flake: Callable[..., Flake]) -> None: - flake = clan_flake( - { - "inventory": { - "machines": { - "jon": { - # machineClass defaults to nixos - "tags": ["foo", "bar"], - }, - "sara": { - "machineClass": "darwin", - "tags": ["foo", "baz", "fizz"], - }, - "bob": { - "machineClass": "nixos", - "tags": ["foo", "bar"], - }, - }, - } - }, - ) - - tags = list_inventory_tags(flake) - - assert tags == [ - MachineTag(name="all", readonly=True), - MachineTag(name="bar", readonly=False), - MachineTag(name="baz", readonly=False), - MachineTag(name="darwin", readonly=True), - MachineTag(name="fizz", readonly=False), - MachineTag(name="foo", readonly=False), - MachineTag(name="nixos", readonly=True), - ] - - -@pytest.mark.with_core -def test_list_inventory_tags_defaults(clan_flake: Callable[..., Flake]) -> None: - flake = clan_flake( - { - "inventory": { - "machines": { - "jon": { - # machineClass defaults to nixos - }, - "sara": { - "machineClass": "darwin", - }, - "bob": { - "machineClass": "nixos", - }, - }, - } - }, - ) - - tags = list_inventory_tags(flake) - - assert tags == [ - MachineTag(name="all", readonly=True), - MachineTag(name="darwin", readonly=True), - MachineTag(name="nixos", readonly=True), - ] diff --git a/pkgs/clan-cli/clan_lib/persist/inventory_store.py b/pkgs/clan-cli/clan_lib/persist/inventory_store.py index 07dc85cb2..c85ed0c83 100644 --- a/pkgs/clan-cli/clan_lib/persist/inventory_store.py +++ b/pkgs/clan-cli/clan_lib/persist/inventory_store.py @@ -12,6 +12,7 @@ from clan_lib.nix_models.clan import ( InventoryMachinesType, InventoryMetaType, InventoryServicesType, + InventoryTagsType, ) from .util import ( @@ -106,6 +107,7 @@ class InventorySnapshot(TypedDict): instances: NotRequired[InventoryInstancesType] meta: NotRequired[InventoryMetaType] services: NotRequired[InventoryServicesType] + tags: NotRequired[InventoryTagsType] class InventoryStore: diff --git a/pkgs/clan-cli/clan_lib/tags/list.py b/pkgs/clan-cli/clan_lib/tags/list.py new file mode 100644 index 000000000..9fcb16cbb --- /dev/null +++ b/pkgs/clan-cli/clan_lib/tags/list.py @@ -0,0 +1,34 @@ +from typing import Any + +from clan_lib.api import API +from clan_lib.flake import Flake +from clan_lib.persist.inventory_store import InventoryStore + + +@API.register +def list_tags(flake: Flake) -> set[str]: + inventory_store = InventoryStore(flake=flake) + inventory = inventory_store.read() + + machines = inventory.get("machines", {}) + + tags: set[str] = set() + + for machine in machines.values(): + machine_tags = machine.get("tags", []) + for tag in machine_tags: + tags.add(tag) + + instances = inventory.get("instances", {}) + for instance in instances.values(): + roles: dict[str, Any] = instance.get("roles", {}) + for role in roles.values(): + role_tags = role.get("tags", {}) + for tag in role_tags: + tags.add(tag) + + global_tags = inventory.get("tags", {}) + for tag in global_tags: + tags.add(tag) + + return tags diff --git a/pkgs/clan-cli/clan_lib/tags/list_test.py b/pkgs/clan-cli/clan_lib/tags/list_test.py new file mode 100644 index 000000000..41cfdda3e --- /dev/null +++ b/pkgs/clan-cli/clan_lib/tags/list_test.py @@ -0,0 +1,78 @@ +from collections.abc import Callable + +import pytest +from clan_lib.flake import Flake +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 + + +@pytest.mark.with_core +def test_list_inventory_tags(clan_flake: Callable[..., Flake]) -> None: + flake = clan_flake( + { + "inventory": { + "machines": { + "jon": { + "tags": ["foo", "bar"], + }, + "sara": { + "tags": ["foo", "baz", "fizz"], + }, + "bob": { + "tags": ["foo", "bar"], + }, + }, + "instances": { + "instance1": { + "roles": { + "role1": {"tags": {"predefined": {}, "maybe": {}}}, + "role2": {"tags": {"predefined2": {}, "maybe2": {}}}, + } + }, + }, + } + }, + raw=r""" + { + inventory.tags = { + "global" = [ "future_machine" ]; + }; + } + """, + ) + + inventory_store = InventoryStore(flake=flake) + inventory = inventory_store.read() + curr_tags = get_value_by_path(inventory, "machines.jon.tags", []) + 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") + + # Check that the tags were updated + persisted = inventory_store._get_persisted() # noqa: SLF001 + assert get_value_by_path(persisted, "machines.jon.tags", []) == new_tags + + tags = list_tags(flake) + + assert tags == set( + { + # Predefined tags + "all", + "global", + "darwin", + "nixos", + # Tags defined in nix + "bar", + "baz", + "fizz", + "foo", + "predefined", + "predefined2", + "maybe", + "maybe2", + # Tags managed by the UI + "managed1", + "managed2", + } + ) From a9d1ff83f28f6cac835f455a1e99cbc690df90bf Mon Sep 17 00:00:00 2001 From: Johannes Kirschbauer Date: Tue, 12 Aug 2025 12:40:12 +0200 Subject: [PATCH 3/5] api/tags: split list into options and non-configurable tags --- pkgs/clan-cli/clan_lib/tags/list.py | 19 +++++++++++++++---- pkgs/clan-cli/clan_lib/tags/list_test.py | 17 +++++++++++------ 2 files changed, 26 insertions(+), 10 deletions(-) diff --git a/pkgs/clan-cli/clan_lib/tags/list.py b/pkgs/clan-cli/clan_lib/tags/list.py index 9fcb16cbb..5510e3457 100644 --- a/pkgs/clan-cli/clan_lib/tags/list.py +++ b/pkgs/clan-cli/clan_lib/tags/list.py @@ -1,3 +1,4 @@ +from dataclasses import dataclass from typing import Any from clan_lib.api import API @@ -5,8 +6,14 @@ from clan_lib.flake import Flake from clan_lib.persist.inventory_store import InventoryStore +@dataclass +class TagList: + options: set[str] + special: set[str] + + @API.register -def list_tags(flake: Flake) -> set[str]: +def list_tags(flake: Flake) -> TagList: inventory_store = InventoryStore(flake=flake) inventory = inventory_store.read() @@ -28,7 +35,11 @@ def list_tags(flake: Flake) -> set[str]: tags.add(tag) global_tags = inventory.get("tags", {}) - for tag in global_tags: - tags.add(tag) - return tags + for tag in global_tags: + if tag not in tags: + continue + + tags.remove(tag) + + return TagList(options=tags, special=set(global_tags.keys())) diff --git a/pkgs/clan-cli/clan_lib/tags/list_test.py b/pkgs/clan-cli/clan_lib/tags/list_test.py index 41cfdda3e..8de53d562 100644 --- a/pkgs/clan-cli/clan_lib/tags/list_test.py +++ b/pkgs/clan-cli/clan_lib/tags/list_test.py @@ -55,13 +55,8 @@ def test_list_inventory_tags(clan_flake: Callable[..., Flake]) -> None: tags = list_tags(flake) - assert tags == set( + assert tags.options == set( { - # Predefined tags - "all", - "global", - "darwin", - "nixos", # Tags defined in nix "bar", "baz", @@ -76,3 +71,13 @@ def test_list_inventory_tags(clan_flake: Callable[..., Flake]) -> None: "managed2", } ) + + assert tags.special == set( + { + # Predefined tags + "all", + "global", + "darwin", + "nixos", + } + ) From 0ef57bfc8ebc3c5ffda29c4eb441d30f6dcaf7a8 Mon Sep 17 00:00:00 2001 From: Johannes Kirschbauer Date: Tue, 12 Aug 2025 13:07:14 +0200 Subject: [PATCH 4/5] api/tags: add init.py for pytest --- pkgs/clan-cli/clan_lib/tags/__init__.py | 0 pkgs/clan-cli/clan_lib/tags/list_test.py | 1 + 2 files changed, 1 insertion(+) create mode 100644 pkgs/clan-cli/clan_lib/tags/__init__.py diff --git a/pkgs/clan-cli/clan_lib/tags/__init__.py b/pkgs/clan-cli/clan_lib/tags/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/pkgs/clan-cli/clan_lib/tags/list_test.py b/pkgs/clan-cli/clan_lib/tags/list_test.py index 8de53d562..3baa31666 100644 --- a/pkgs/clan-cli/clan_lib/tags/list_test.py +++ b/pkgs/clan-cli/clan_lib/tags/list_test.py @@ -1,6 +1,7 @@ from collections.abc import Callable import pytest + from clan_lib.flake import Flake from clan_lib.persist.inventory_store import InventoryStore from clan_lib.persist.util import get_value_by_path, set_value_by_path From f74e444120f680737e2e3e71e01dd7644ba656e7 Mon Sep 17 00:00:00 2001 From: Johannes Kirschbauer Date: Tue, 12 Aug 2025 13:19:11 +0200 Subject: [PATCH 5/5] api/tags: add docs --- pkgs/clan-cli/clan_lib/tags/list.py | 9 +++++++++ pkgs/clan-cli/openapi.py | 1 + 2 files changed, 10 insertions(+) diff --git a/pkgs/clan-cli/clan_lib/tags/list.py b/pkgs/clan-cli/clan_lib/tags/list.py index 5510e3457..650ba2fc6 100644 --- a/pkgs/clan-cli/clan_lib/tags/list.py +++ b/pkgs/clan-cli/clan_lib/tags/list.py @@ -14,6 +14,15 @@ class TagList: @API.register def list_tags(flake: Flake) -> TagList: + """ + List all tags of a clan. + + Returns: + - 'options' - Existing Tags that can be added to machines + - 'special' - Prefined Tags that are special and cannot be added to machines, they can be used in roles and refer to a fixed set of machines. + + """ + inventory_store = InventoryStore(flake=flake) inventory = inventory_store.read() diff --git a/pkgs/clan-cli/openapi.py b/pkgs/clan-cli/openapi.py index f052aebb1..68c007023 100644 --- a/pkgs/clan-cli/openapi.py +++ b/pkgs/clan-cli/openapi.py @@ -36,6 +36,7 @@ COMMON_VERBS = { # If you need a new top-level resource, create an issue to discuss it first. TOP_LEVEL_RESOURCES = { "clan", # clan management + "tag", # Tags "machine", # machine management "task", # task management "secret", # sops & key operations