Merge pull request 'Feat(inventory): remove legacy action functions' (#3778) from inventory-0 into main

Reviewed-on: https://git.clan.lol/clan/clan-core/pulls/3778
This commit is contained in:
hsjobeki
2025-05-27 16:09:16 +00:00
7 changed files with 44 additions and 222 deletions

View File

@@ -137,6 +137,7 @@ def create_machine(
)
inventory_store.write(inventory, message=f"machine '{machine_name}'")
opts.clan_dir.invalidate_cache()
# Commit at the end in that order to avoid committing halve-baked machines
# TODO: automatic rollbacks if something goes wrong

View File

@@ -21,8 +21,11 @@ log = logging.getLogger(__name__)
@API.register
def delete_machine(machine: Machine) -> None:
inventory_store = inventory.InventoryStore(machine.flake)
try:
inventory.delete(machine.flake, {f"machines.{machine.name}"})
inventory_store.delete(
{f"machines.{machine.name}"},
)
except KeyError as exc:
# louis@(2025-03-09): test infrastructure does not seem to set the
# inventory properly, but more importantly only one machine in my

View File

@@ -1,3 +1,4 @@
# ruff: noqa: SLF001
import pytest
from clan_cli.secrets.folders import sops_machines_folder
from clan_cli.tests import fixtures_flakes
@@ -5,7 +6,7 @@ from clan_cli.tests.age_keys import SopsSetup, assert_secrets_file_recipients
from clan_cli.tests.helpers import cli
from clan_cli.tests.stdout import CaptureOutput
from clan_lib.flake import Flake
from clan_lib.inventory import load_inventory_json
from clan_lib.persist.inventory_store import InventoryStore
@pytest.mark.impure
@@ -13,6 +14,10 @@ def test_machine_subcommands(
test_flake_with_core: fixtures_flakes.FlakeForTest,
capture_output: CaptureOutput,
) -> None:
inventory_store = InventoryStore(Flake(str(test_flake_with_core.path)))
inventory = inventory_store.read()
assert "machine1" not in inventory.get("machines", {})
cli.run(
[
"machines",
@@ -24,10 +29,14 @@ def test_machine_subcommands(
"vm",
]
)
# Usually this is done by `inventory.write` but we created a separate flake object in the test that now holds stale data
inventory_store._flake.invalidate_cache()
inventory: dict = dict(load_inventory_json(Flake(str(test_flake_with_core.path))))
assert "machine1" in inventory["machines"]
assert "service" not in inventory
inventory = inventory_store.read()
persisted_inventory = inventory_store._get_persisted()
assert "machine1" in inventory.get("machines", {})
assert "services" not in persisted_inventory
with capture_output as output:
cli.run(["machines", "list", "--flake", str(test_flake_with_core.path)])
@@ -40,10 +49,14 @@ def test_machine_subcommands(
cli.run(
["machines", "delete", "--flake", str(test_flake_with_core.path), "machine1"]
)
# See comment above
inventory_store._flake.invalidate_cache()
inventory_2: dict = dict(load_inventory_json(Flake(str(test_flake_with_core.path))))
inventory_2: dict = dict(inventory_store.read())
assert "machine1" not in inventory_2["machines"]
assert "service" not in inventory_2
persisted_inventory = inventory_store._get_persisted()
assert "services" not in persisted_inventory
with capture_output as output:
cli.run(["machines", "list", "--flake", str(test_flake_with_core.path)])

View File

@@ -11,216 +11,14 @@ Which is an abstraction over the inventory
Interacting with 'clan_lib.inventory' is NOT recommended and will be removed
"""
import json
from pathlib import Path
from typing import Any
from clan_lib.api import API
from clan_lib.errors import ClanError
from clan_lib.flake import Flake
from clan_lib.git import commit_file
from clan_lib.nix_models.inventory import Inventory
from clan_lib.persist.inventory_store import WriteInfo
from clan_lib.persist.util import (
apply_patch,
calc_patches,
delete_by_path,
determine_writeability,
)
def get_inventory_path(flake: Flake) -> Path:
"""
Get the path to the inventory file in the flake directory
"""
inventory_file = (flake.path / "inventory.json").resolve()
return inventory_file
def load_inventory_eval(flake: Flake) -> Inventory:
"""
Loads the evaluated inventory.
After all merge operations with eventual nix code in buildClan.
Evaluates clanInternals.inventory with nix. Which is performant.
- Contains all clan metadata
- Contains all machines
- and more
"""
data = flake.select("clanInternals.inventory")
try:
inventory = Inventory(data) # type: ignore
except json.JSONDecodeError as e:
msg = f"Error decoding inventory from flake: {e}"
raise ClanError(msg) from e
else:
return inventory
def get_inventory_current_priority(flake: Flake) -> dict:
"""
Returns the current priority of the inventory values
machines = {
__prio = 100;
flash-installer = {
__prio = 100;
deploy = {
targetHost = { __prio = 1500; };
};
description = { __prio = 1500; };
icon = { __prio = 1500; };
name = { __prio = 1500; };
tags = { __prio = 1500; };
};
}
"""
try:
data = flake.select("clanInternals.inventoryClass.introspection")
except json.JSONDecodeError as e:
msg = f"Error decoding inventory from flake: {e}"
raise ClanError(msg) from e
else:
return data
@API.register
def load_inventory_json(flake: Flake) -> Inventory:
"""
Load the inventory FILE from the flake directory
If no file is found, returns an empty dictionary
DO NOT USE THIS FUNCTION TO READ THE INVENTORY
Use load_inventory_eval instead
"""
inventory_file = get_inventory_path(flake)
if not inventory_file.exists():
return {}
with inventory_file.open() as f:
try:
res: dict = json.load(f)
inventory = Inventory(res) # type: ignore
except json.JSONDecodeError as e:
# Error decoding the inventory file
msg = f"Error decoding inventory file: {e}"
raise ClanError(msg) from e
return inventory
@API.register
def patch_inventory_with(flake: Flake, section: str, content: dict[str, Any]) -> None:
"""
Pass only the section to update and the content to update with.
Make sure you pass only attributes that you would like to persist.
ATTENTION: Don't pass nix eval values unintentionally.
"""
inventory_file = get_inventory_path(flake)
curr_inventory = {}
if inventory_file.exists():
with inventory_file.open("r") as f:
curr_inventory = json.load(f)
apply_patch(curr_inventory, section, content)
with inventory_file.open("w") as f:
json.dump(curr_inventory, f, indent=2)
commit_file(
inventory_file, flake.path, commit_message=f"inventory.{section}: Update"
)
@API.register
def get_inventory_with_writeable_keys(
flake: Flake,
) -> WriteInfo:
"""
Load the inventory and determine the writeable keys
Performs 2 nix evaluations to get the current priority and the inventory
"""
current_priority = get_inventory_current_priority(flake)
data_eval: Inventory = load_inventory_eval(flake)
data_disk: Inventory = load_inventory_json(flake)
writeables = determine_writeability(
current_priority, dict(data_eval), dict(data_disk)
)
return WriteInfo(writeables, data_eval, data_disk)
# TODO: remove this function in favor of a proper read/write API
@API.register
def set_inventory(
inventory: Inventory, flake: Flake, message: str, commit: bool = True
) -> None:
"""
Write the inventory to the flake directory
and commit it to git with the given message
"""
write_info = get_inventory_with_writeable_keys(flake)
# Remove internals from the inventory
inventory.pop("tags", None) # type: ignore
inventory.pop("options", None) # type: ignore
inventory.pop("assertions", None) # type: ignore
patchset, delete_set = calc_patches(
dict(write_info.data_disk),
dict(inventory),
dict(write_info.data_eval),
write_info.writeables,
)
persisted = dict(write_info.data_disk)
for patch_path, data in patchset.items():
apply_patch(persisted, patch_path, data)
for delete_path in delete_set:
delete_by_path(persisted, delete_path)
inventory_file = get_inventory_path(flake)
with inventory_file.open("w") as f:
json.dump(persisted, f, indent=2)
if commit:
commit_file(inventory_file, flake.path, commit_message=message)
# TODO: wrap this in a proper persistence API
def delete(flake: Flake, delete_set: set[str]) -> None:
"""
Delete keys from the inventory
"""
write_info = get_inventory_with_writeable_keys(flake)
data_disk = dict(write_info.data_disk)
for delete_path in delete_set:
delete_by_path(data_disk, delete_path)
inventory_file = get_inventory_path(flake)
with inventory_file.open("w") as f:
json.dump(data_disk, f, indent=2)
commit_file(
inventory_file,
flake.path,
commit_message=f"Delete inventory keys {delete_set}",
)
from clan_lib.persist.inventory_store import InventoryStore
@API.register
def get_inventory(flake: Flake) -> Inventory:
return load_inventory_eval(flake)
inventory_store = InventoryStore(flake)
inventory = inventory_store.read()
return inventory

View File

@@ -86,6 +86,8 @@ class FlakeInterface(Protocol):
nix_options: list[str] | None = None,
) -> Any: ...
def invalidate_cache(self) -> None: ...
@property
def path(self) -> Path: ...
@@ -251,3 +253,5 @@ class InventoryStore:
self._flake.path,
commit_message=f"update({self.inventory_file.name}): {message}",
)
self._flake.invalidate_cache()

View File

@@ -20,6 +20,9 @@ class MockFlake:
assert f.exists(), f"File {f} does not exist"
self._file = f
def invalidate_cache(self) -> None:
pass
def select(
self,
selector: str,

View File

@@ -22,7 +22,6 @@ from clan_lib.cmd import RunOpts, run
from clan_lib.dirs import specific_machine_dir
from clan_lib.errors import ClanError
from clan_lib.flake import Flake
from clan_lib.inventory import patch_inventory_with
from clan_lib.machines.machines import Machine
from clan_lib.nix import nix_command
from clan_lib.nix_models.inventory import Machine as InventoryMachine
@@ -204,16 +203,17 @@ def test_clan_create_api(
result = check_machine_online(machine)
assert result == "Online", f"Machine {machine.name} is not online"
ssh_keys = [
SSHKeyPair(
private=private_key,
public=public_key,
)
]
# ssh_keys = [
# SSHKeyPair(
# private=private_key,
# public=public_key,
# )
# ]
# ===== CREATE BASE INVENTORY ======
inventory = create_base_inventory(ssh_keys)
patch_inventory_with(Flake(str(dest_clan_dir)), "services", inventory.services)
# TODO(@Qubasa): This seems unused?
# inventory = create_base_inventory(ssh_keys)
# patch_inventory_with(Flake(str(dest_clan_dir)), "services", inventory.services)
# Invalidate cache because of new inventory
clan_dir_flake.invalidate_cache()