Inventory: fix persistence
This commit is contained in:
@@ -3,16 +3,13 @@ import re
|
|||||||
import tomllib
|
import tomllib
|
||||||
from dataclasses import dataclass, field
|
from dataclasses import dataclass, field
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Any, TypedDict, get_args, get_type_hints
|
from typing import Any, TypedDict
|
||||||
|
|
||||||
from clan_cli.cmd import run_no_stdout
|
from clan_cli.cmd import run_no_stdout
|
||||||
from clan_cli.errors import ClanCmdError, ClanError
|
from clan_cli.errors import ClanCmdError, ClanError
|
||||||
from clan_cli.inventory import Inventory, load_inventory_json, set_inventory
|
|
||||||
from clan_cli.inventory.classes import Service
|
|
||||||
from clan_cli.nix import nix_eval
|
from clan_cli.nix import nix_eval
|
||||||
|
|
||||||
from . import API
|
from . import API
|
||||||
from .serde import from_dict
|
|
||||||
|
|
||||||
|
|
||||||
class CategoryInfo(TypedDict):
|
class CategoryInfo(TypedDict):
|
||||||
@@ -247,42 +244,3 @@ def get_module_info(
|
|||||||
features=["inventory"] if has_inventory_feature(module_path) else [],
|
features=["inventory"] if has_inventory_feature(module_path) else [],
|
||||||
constraints=frontmatter.constraints,
|
constraints=frontmatter.constraints,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@API.register
|
|
||||||
def get_inventory(base_path: str) -> Inventory:
|
|
||||||
return load_inventory_json(base_path)
|
|
||||||
|
|
||||||
|
|
||||||
@API.register
|
|
||||||
def set_service_instance(
|
|
||||||
base_path: str, module_name: str, instance_name: str, config: dict[str, Any]
|
|
||||||
) -> None:
|
|
||||||
"""
|
|
||||||
A function that allows to set any service instance in the inventory.
|
|
||||||
Takes any untyped dict. The dict is then checked and converted into the correct type using the type hints of the service.
|
|
||||||
If any conversion error occurs, the function will raise an error.
|
|
||||||
"""
|
|
||||||
service_keys = get_type_hints(Service).keys()
|
|
||||||
|
|
||||||
if module_name not in service_keys:
|
|
||||||
msg = f"{module_name} is not a valid Service attribute. Expected one of {', '.join(service_keys)}."
|
|
||||||
raise ClanError(msg)
|
|
||||||
|
|
||||||
inventory = load_inventory_json(base_path)
|
|
||||||
target_type = get_args(get_type_hints(Service)[module_name])[1]
|
|
||||||
|
|
||||||
module_instance_map: dict[str, Any] = inventory.get("services", {}).get(
|
|
||||||
module_name, {}
|
|
||||||
)
|
|
||||||
|
|
||||||
module_instance_map[instance_name] = from_dict(target_type, config)
|
|
||||||
|
|
||||||
inventory["services"] = inventory.get("services", {})
|
|
||||||
inventory["services"][module_name] = module_instance_map
|
|
||||||
|
|
||||||
set_inventory(
|
|
||||||
inventory, base_path, f"Update {module_name} instance {instance_name}"
|
|
||||||
)
|
|
||||||
|
|
||||||
# TODO: Add a check that rolls back the inventory if the service config is not valid or causes conflicts.
|
|
||||||
|
|||||||
@@ -48,16 +48,11 @@ __all__ = [
|
|||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
def get_inventory_path(flake_dir: str | Path, create: bool = True) -> Path:
|
def get_inventory_path(flake_dir: str | Path) -> Path:
|
||||||
"""
|
"""
|
||||||
Get the path to the inventory file in the flake directory
|
Get the path to the inventory file in the flake directory
|
||||||
"""
|
"""
|
||||||
inventory_file = (Path(flake_dir) / "inventory.json").resolve()
|
inventory_file = (Path(flake_dir) / "inventory.json").resolve()
|
||||||
|
|
||||||
if not inventory_file.exists() and create:
|
|
||||||
# Copy over the meta from the flake if the inventory is not initialized
|
|
||||||
init_inventory(str(flake_dir))
|
|
||||||
|
|
||||||
return inventory_file
|
return inventory_file
|
||||||
|
|
||||||
|
|
||||||
@@ -174,7 +169,7 @@ def calc_patches(
|
|||||||
key "machines.machine1.deploy.targetHost" is specified but writeability is only defined for "machines"
|
key "machines.machine1.deploy.targetHost" is specified but writeability is only defined for "machines"
|
||||||
We pop the last key and check if the parent key is writeable/non-writeable.
|
We pop the last key and check if the parent key is writeable/non-writeable.
|
||||||
"""
|
"""
|
||||||
remaining = key.split(".")[:-1]
|
remaining = key.split(".")
|
||||||
while remaining:
|
while remaining:
|
||||||
if ".".join(remaining) in writeables["writeable"]:
|
if ".".join(remaining) in writeables["writeable"]:
|
||||||
return True
|
return True
|
||||||
@@ -182,7 +177,9 @@ def calc_patches(
|
|||||||
return False
|
return False
|
||||||
|
|
||||||
remaining.pop()
|
remaining.pop()
|
||||||
raise ClanError(f"Cannot determine writeability for key '{key}'")
|
|
||||||
|
msg = f"Cannot determine writeability for key '{key}'"
|
||||||
|
raise ClanError(msg, description="F001")
|
||||||
|
|
||||||
patchset = {}
|
patchset = {}
|
||||||
for update_key, update_data in update_flat.items():
|
for update_key, update_data in update_flat.items():
|
||||||
@@ -342,12 +339,18 @@ def get_inventory_current_priority(flake_dir: str | Path) -> dict:
|
|||||||
@API.register
|
@API.register
|
||||||
def load_inventory_json(flake_dir: str | Path) -> Inventory:
|
def load_inventory_json(flake_dir: str | Path) -> Inventory:
|
||||||
"""
|
"""
|
||||||
Load the inventory file from the flake directory
|
Load the inventory FILE from the flake directory
|
||||||
If no file is found, returns the default inventory
|
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_dir)
|
inventory_file = get_inventory_path(flake_dir)
|
||||||
|
|
||||||
|
if not inventory_file.exists():
|
||||||
|
return {}
|
||||||
with inventory_file.open() as f:
|
with inventory_file.open() as f:
|
||||||
try:
|
try:
|
||||||
res: dict = json.load(f)
|
res: dict = json.load(f)
|
||||||
@@ -400,7 +403,7 @@ class WriteInfo:
|
|||||||
|
|
||||||
|
|
||||||
@API.register
|
@API.register
|
||||||
def load_inventory_with_writeable_keys(
|
def get_inventory_with_writeable_keys(
|
||||||
flake_dir: str | Path,
|
flake_dir: str | Path,
|
||||||
) -> WriteInfo:
|
) -> WriteInfo:
|
||||||
"""
|
"""
|
||||||
@@ -426,7 +429,12 @@ def set_inventory(inventory: Inventory, flake_dir: str | Path, message: str) ->
|
|||||||
and commit it to git with the given message
|
and commit it to git with the given message
|
||||||
"""
|
"""
|
||||||
|
|
||||||
write_info = load_inventory_with_writeable_keys(flake_dir)
|
write_info = get_inventory_with_writeable_keys(flake_dir)
|
||||||
|
|
||||||
|
# Remove internals from the inventory
|
||||||
|
inventory.pop("tags", None) # type: ignore
|
||||||
|
inventory.pop("options", None) # type: ignore
|
||||||
|
inventory.pop("assertions", None) # type: ignore
|
||||||
|
|
||||||
patchset = calc_patches(
|
patchset = calc_patches(
|
||||||
dict(write_info.data_disk),
|
dict(write_info.data_disk),
|
||||||
@@ -435,12 +443,13 @@ def set_inventory(inventory: Inventory, flake_dir: str | Path, message: str) ->
|
|||||||
write_info.writeables,
|
write_info.writeables,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
persisted = dict(write_info.data_disk)
|
||||||
for patch_path, data in patchset.items():
|
for patch_path, data in patchset.items():
|
||||||
patch(dict(write_info.data_disk), patch_path, data)
|
patch(persisted, patch_path, data)
|
||||||
|
|
||||||
inventory_file = get_inventory_path(flake_dir)
|
inventory_file = get_inventory_path(flake_dir)
|
||||||
with inventory_file.open("w") as f:
|
with inventory_file.open("w") as f:
|
||||||
json.dump(write_info.data_disk, f, indent=2)
|
json.dump(persisted, f, indent=2)
|
||||||
|
|
||||||
commit_file(inventory_file, Path(flake_dir), commit_message=message)
|
commit_file(inventory_file, Path(flake_dir), commit_message=message)
|
||||||
|
|
||||||
@@ -462,40 +471,5 @@ def init_inventory(directory: str, init: Inventory | None = None) -> None:
|
|||||||
|
|
||||||
|
|
||||||
@API.register
|
@API.register
|
||||||
def merge_template_inventory(
|
def get_inventory(flake_dir: str | Path) -> Inventory:
|
||||||
inventory: Inventory, template_inventory: Inventory, machine_name: str
|
return load_inventory_eval(flake_dir)
|
||||||
) -> None:
|
|
||||||
"""
|
|
||||||
Merge the template inventory into the current inventory
|
|
||||||
The template inventory is expected to be a subset of the current inventory
|
|
||||||
"""
|
|
||||||
for service_name, instance in template_inventory.get("services", {}).items():
|
|
||||||
if len(instance.keys()) > 0:
|
|
||||||
msg = f"Service {service_name} in template inventory has multiple instances"
|
|
||||||
description = (
|
|
||||||
"Only one instance per service is allowed in a template inventory"
|
|
||||||
)
|
|
||||||
raise ClanError(msg, description=description)
|
|
||||||
|
|
||||||
# services.<service_name>.<instance_name>.config
|
|
||||||
config = next((v for v in instance.values() if "config" in v), None)
|
|
||||||
if not config:
|
|
||||||
msg = f"Service {service_name} in template inventory has no config"
|
|
||||||
description = "Invalid inventory configuration"
|
|
||||||
raise ClanError(msg, description=description)
|
|
||||||
|
|
||||||
# Disallow "config.machines" key
|
|
||||||
if "machines" in config:
|
|
||||||
msg = f"Service {service_name} in template inventory has machines"
|
|
||||||
description = "The 'machines' key is not allowed in template inventory"
|
|
||||||
raise ClanError(msg, description=description)
|
|
||||||
|
|
||||||
# Require "config.roles" key
|
|
||||||
if "roles" not in config:
|
|
||||||
msg = f"Service {service_name} in template inventory has no roles"
|
|
||||||
description = "roles key is required in template inventory"
|
|
||||||
raise ClanError(msg, description=description)
|
|
||||||
|
|
||||||
# TODO: Implement merging of template inventory
|
|
||||||
msg = "Merge template inventory is not implemented yet"
|
|
||||||
raise NotImplementedError(msg)
|
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ from clan_cli.git import commit_file
|
|||||||
from clan_cli.inventory import Machine as InventoryMachine
|
from clan_cli.inventory import Machine as InventoryMachine
|
||||||
from clan_cli.inventory import (
|
from clan_cli.inventory import (
|
||||||
MachineDeploy,
|
MachineDeploy,
|
||||||
load_inventory_json,
|
get_inventory,
|
||||||
set_inventory,
|
set_inventory,
|
||||||
)
|
)
|
||||||
from clan_cli.machines.list import list_nixos_machines
|
from clan_cli.machines.list import list_nixos_machines
|
||||||
@@ -121,7 +121,7 @@ def create_machine(opts: CreateOptions) -> None:
|
|||||||
|
|
||||||
shutil.copytree(src, dst, ignore_dangling_symlinks=True, copy_function=log_copy)
|
shutil.copytree(src, dst, ignore_dangling_symlinks=True, copy_function=log_copy)
|
||||||
|
|
||||||
inventory = load_inventory_json(clan_dir)
|
inventory = get_inventory(clan_dir)
|
||||||
|
|
||||||
target_host = opts.target_host
|
target_host = opts.target_host
|
||||||
# TODO: We should allow the template to specify machine metadata if not defined by user
|
# TODO: We should allow the template to specify machine metadata if not defined by user
|
||||||
|
|||||||
@@ -6,12 +6,12 @@ from clan_cli.clan_uri import FlakeId
|
|||||||
from clan_cli.completions import add_dynamic_completer, complete_machines
|
from clan_cli.completions import add_dynamic_completer, complete_machines
|
||||||
from clan_cli.dirs import specific_machine_dir
|
from clan_cli.dirs import specific_machine_dir
|
||||||
from clan_cli.errors import ClanError
|
from clan_cli.errors import ClanError
|
||||||
from clan_cli.inventory import load_inventory_json, set_inventory
|
from clan_cli.inventory import get_inventory, set_inventory
|
||||||
|
|
||||||
|
|
||||||
@API.register
|
@API.register
|
||||||
def delete_machine(flake: FlakeId, name: str) -> None:
|
def delete_machine(flake: FlakeId, name: str) -> None:
|
||||||
inventory = load_inventory_json(flake.path)
|
inventory = get_inventory(flake.path)
|
||||||
|
|
||||||
machine = inventory.get("machines", {}).pop(name, None)
|
machine = inventory.get("machines", {}).pop(name, None)
|
||||||
if machine is None:
|
if machine is None:
|
||||||
|
|||||||
@@ -6,9 +6,9 @@ import pytest
|
|||||||
from clan_cli.api.modules import list_modules
|
from clan_cli.api.modules import list_modules
|
||||||
from clan_cli.clan_uri import FlakeId
|
from clan_cli.clan_uri import FlakeId
|
||||||
from clan_cli.inventory import (
|
from clan_cli.inventory import (
|
||||||
|
Inventory,
|
||||||
Machine,
|
Machine,
|
||||||
MachineDeploy,
|
MachineDeploy,
|
||||||
load_inventory_json,
|
|
||||||
set_inventory,
|
set_inventory,
|
||||||
)
|
)
|
||||||
from clan_cli.machines.create import CreateOptions, create_machine
|
from clan_cli.machines.create import CreateOptions, create_machine
|
||||||
@@ -70,7 +70,7 @@ def test_add_module_to_inventory(
|
|||||||
)
|
)
|
||||||
subprocess.run(["git", "add", "."], cwd=test_flake_with_core.path, check=True)
|
subprocess.run(["git", "add", "."], cwd=test_flake_with_core.path, check=True)
|
||||||
|
|
||||||
inventory = load_inventory_json(base_path)
|
inventory: Inventory = {}
|
||||||
|
|
||||||
inventory["services"] = {
|
inventory["services"] = {
|
||||||
"borgbackup": {
|
"borgbackup": {
|
||||||
|
|||||||
Reference in New Issue
Block a user