Merge pull request 'Inventory: warning on undefined tags, instead of error.' (#2696) from hsjobeki/clan-core:hsjobeki-main into main

Reviewed-on: https://git.clan.lol/clan/clan-core/pulls/2696
This commit is contained in:
hsjobeki
2025-01-10 12:06:01 +00:00
11 changed files with 120 additions and 69 deletions

View File

@@ -28,10 +28,10 @@ let
); );
in in
if tagMembers == [ ] then if tagMembers == [ ] then
throw '' lib.warn ''
inventory.services.${serviceName}.${instanceName}: - ${roleName} tags: no machine with tag '${tag}' found. inventory.services.${serviceName}.${instanceName}: - ${roleName} tags: no machine with tag '${tag}' found.
Available tags: ${builtins.toJSON (lib.unique availableTags)} Available tags: ${builtins.toJSON (lib.unique availableTags)}
'' '' [ ]
else else
acc ++ tagMembers acc ++ tagMembers
) [ ] members.tags or [ ]); ) [ ] members.tags or [ ]);

View File

@@ -42,7 +42,7 @@ in
checks = { checks = {
lib-inventory-eval = pkgs.runCommand "tests" { nativeBuildInputs = [ pkgs.nix-unit ]; } '' lib-inventory-eval = pkgs.runCommand "tests" { nativeBuildInputs = [ pkgs.nix-unit ]; } ''
export HOME="$(realpath .)" export HOME="$(realpath .)"
export NIX_ABORT_ON_WARN=1
nix-unit --eval-store "$HOME" \ nix-unit --eval-store "$HOME" \
--extra-experimental-features flakes \ --extra-experimental-features flakes \
${inputOverrides} \ ${inputOverrides} \

View File

@@ -178,6 +178,8 @@ in
msg = "Roles roleXYZ are not defined in the service borgbackup."; msg = "Roles roleXYZ are not defined in the service borgbackup.";
}; };
}; };
# Needs NIX_ABORT_ON_WARN=1
# So the lib.warn is turned into abort
test_inventory_tag_doesnt_exist = test_inventory_tag_doesnt_exist =
let let
configs = buildInventory { configs = buildInventory {
@@ -201,8 +203,9 @@ in
{ {
expr = configs; expr = configs;
expectedError = { expectedError = {
type = "ThrownError"; type = "Error";
msg = "no machine with tag '\\w+' found"; # TODO: Add warning matching in nix-unit
msg = ".*";
}; };
}; };
test_inventory_disabled_service = test_inventory_disabled_service =

View File

@@ -6,8 +6,17 @@ let
in in
{ {
perSystem = perSystem =
{ pkgs, system, ... }:
{ {
pkgs,
system,
lib,
...
}:
let
tests = import ./test.nix { inherit lib; };
in
{
legacyPackages.evalTests-values = tests;
checks = { checks = {
lib-values-eval = pkgs.runCommand "tests" { nativeBuildInputs = [ pkgs.nix-unit ]; } '' lib-values-eval = pkgs.runCommand "tests" { nativeBuildInputs = [ pkgs.nix-unit ]; } ''
export HOME="$(realpath .)" export HOME="$(realpath .)"
@@ -15,7 +24,7 @@ in
nix-unit --eval-store "$HOME" \ nix-unit --eval-store "$HOME" \
--extra-experimental-features flakes \ --extra-experimental-features flakes \
${inputOverrides} \ ${inputOverrides} \
--flake ${self}#legacyPackages.${system}.evalTests-inventory --flake ${self}#legacyPackages.${system}.evalTests-values
touch $out touch $out
''; '';

View File

@@ -82,43 +82,47 @@ class Webview:
method_name: str = name, method_name: str = name,
) -> None: ) -> None:
def thread_task() -> None: def thread_task() -> None:
args = json.loads(req.decode())
try: try:
log.debug(f"Calling {method_name}({args[0]})") args = json.loads(req.decode())
# Initialize dataclasses from the payload
reconciled_arguments = {}
for k, v in args[0].items():
# Some functions expect to be called with dataclass instances
# But the js api returns dictionaries.
# Introspect the function and create the expected dataclass from dict dynamically
# Depending on the introspected argument_type
arg_class = api.get_method_argtype(method_name, k)
# TODO: rename from_dict into something like construct_checked_value try:
# from_dict really takes Anything and returns an instance of the type/class log.debug(f"Calling {method_name}({args[0]})")
reconciled_arguments[k] = from_dict(arg_class, v) # Initialize dataclasses from the payload
reconciled_arguments = {}
for k, v in args[0].items():
# Some functions expect to be called with dataclass instances
# But the js api returns dictionaries.
# Introspect the function and create the expected dataclass from dict dynamically
# Depending on the introspected argument_type
arg_class = api.get_method_argtype(method_name, k)
reconciled_arguments["op_key"] = seq.decode() # TODO: rename from_dict into something like construct_checked_value
# TODO: We could remove the wrapper in the MethodRegistry # from_dict really takes Anything and returns an instance of the type/class
# and just call the method directly reconciled_arguments[k] = from_dict(arg_class, v)
result = wrap_method(**reconciled_arguments)
success = True reconciled_arguments["op_key"] = seq.decode()
# TODO: We could remove the wrapper in the MethodRegistry
# and just call the method directly
result = wrap_method(**reconciled_arguments)
success = True
except Exception as e:
log.exception(f"Error calling {method_name}")
result = str(e)
success = False
try:
serialized = json.dumps(
dataclass_to_dict(result), indent=4, ensure_ascii=False
)
except TypeError:
log.exception(f"Error serializing result for {method_name}")
raise
log.debug(f"Result for {method_name}: {serialized}")
self.return_(seq.decode(), 0 if success else 1, serialized)
except Exception as e: except Exception as e:
log.exception(f"Error calling {method_name}") log.exception(f"Unhandled error in webview {method_name}")
result = str(e) self.return_(seq.decode(), 1, str(e))
success = False
try:
serialized = json.dumps(
dataclass_to_dict(result), indent=4, ensure_ascii=False
)
except TypeError:
log.exception(f"Error serializing result for {method_name}")
raise
log.debug(f"Result for {method_name}: {serialized}")
self.return_(seq.decode(), 0 if success else 1, serialized)
thread = threading.Thread(target=thread_task) thread = threading.Thread(target=thread_task)
thread.start() thread.start()

View File

@@ -260,7 +260,11 @@ def _ask_prompts(
prompt_values: dict[str, str] = {} prompt_values: dict[str, str] = {}
for prompt in generator.prompts: for prompt in generator.prompts:
var_id = f"{generator.name}/{prompt.name}" var_id = f"{generator.name}/{prompt.name}"
prompt_values[prompt.name] = ask(var_id, prompt.prompt_type) prompt_values[prompt.name] = ask(
var_id,
prompt.prompt_type,
prompt.description if prompt.description != prompt.name else None,
)
return prompt_values return prompt_values

View File

@@ -2,10 +2,10 @@ import argparse
import logging import logging
import sys import sys
from clan_cli.api import API
from clan_cli.clan_uri import FlakeId 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.errors import ClanError from clan_cli.errors import ClanError
from clan_cli.machines.machines import Machine
from .generate import Var from .generate import Var
from .list import get_vars from .list import get_vars
@@ -13,8 +13,9 @@ from .list import get_vars
log = logging.getLogger(__name__) log = logging.getLogger(__name__)
def get_var(machine: Machine, var_id: str) -> Var: @API.register
vars_ = get_vars(machine) def get_var(base_dir: str, machine_name: str, var_id: str) -> Var:
vars_ = get_vars(base_dir=base_dir, machine_name=machine_name)
results = [] results = []
for var in vars_: for var in vars_:
if var.id == var_id: if var.id == var_id:
@@ -42,8 +43,7 @@ def get_var(machine: Machine, var_id: str) -> Var:
def get_command(machine_name: str, var_id: str, flake: FlakeId) -> None: def get_command(machine_name: str, var_id: str, flake: FlakeId) -> None:
machine = Machine(name=machine_name, flake=flake) var = get_var(str(flake.path), machine_name, var_id)
var = get_var(machine, var_id)
if not var.exists: if not var.exists:
msg = f"Var {var.id} has not been generated yet" msg = f"Var {var.id} has not been generated yet"
raise ClanError(msg) raise ClanError(msg)

View File

@@ -25,7 +25,9 @@ def secret_store(machine: Machine) -> StoreBase:
return secret_vars_module.SecretStore(machine=machine) return secret_vars_module.SecretStore(machine=machine)
def get_vars(machine: Machine) -> list[Var]: @API.register
def get_vars(base_dir: str, machine_name: str) -> list[Var]:
machine = Machine(name=machine_name, flake=FlakeId(base_dir))
pub_store = public_store(machine) pub_store = public_store(machine)
sec_store = secret_store(machine) sec_store = secret_store(machine)
all_vars = [] all_vars = []
@@ -58,7 +60,7 @@ def _get_previous_value(
@API.register @API.register
def get_prompts(base_dir: str, machine_name: str) -> list[Generator]: def get_generators(base_dir: str, machine_name: str) -> list[Generator]:
machine = Machine(name=machine_name, flake=FlakeId(base_dir)) machine = Machine(name=machine_name, flake=FlakeId(base_dir))
generators: list[Generator] = machine.vars_generators generators: list[Generator] = machine.vars_generators
for generator in generators: for generator in generators:
@@ -96,7 +98,7 @@ def stringify_vars(_vars: list[Var]) -> str:
def stringify_all_vars(machine: Machine) -> str: def stringify_all_vars(machine: Machine) -> str:
return stringify_vars(get_vars(machine)) return stringify_vars(get_vars(str(machine.flake), machine.name))
def list_command(args: argparse.Namespace) -> None: def list_command(args: argparse.Namespace) -> None:

View File

@@ -37,16 +37,25 @@ class Prompt:
) )
def ask(description: str, input_type: PromptType) -> str: def ask(
ident: str,
input_type: PromptType,
label: str | None,
) -> str:
text = f"Enter the value for {ident}:"
if label:
text = f"{label}"
if MOCK_PROMPT_RESPONSE: if MOCK_PROMPT_RESPONSE:
return next(MOCK_PROMPT_RESPONSE) return next(MOCK_PROMPT_RESPONSE)
match input_type: match input_type:
case PromptType.LINE: case PromptType.LINE:
result = input(f"Enter the value for {description}: ") result = input(f"{text}: ")
case PromptType.MULTILINE: case PromptType.MULTILINE:
print(f"Enter the value for {description} (Finish with Ctrl-D): ") print(f"{text} (Finish with Ctrl-D): ")
result = sys.stdin.read() result = sys.stdin.read()
case PromptType.HIDDEN: case PromptType.HIDDEN:
result = getpass(f"Enter the value for {description} (hidden): ") result = getpass(f"{text} (hidden): ")
log.info("Input received. Processing...") log.info("Input received. Processing...")
return result return result

View File

@@ -23,7 +23,7 @@ def set_var(
else: else:
_machine = machine _machine = machine
if isinstance(var, str): if isinstance(var, str):
_var = get_var(_machine, var) _var = get_var(str(flake.path), _machine.name, var)
else: else:
_var = var _var = var
path = _var.set(value) path = _var.set(value)
@@ -36,12 +36,17 @@ def set_var(
def set_via_stdin(machine: str, var_id: str, flake: FlakeId) -> None: def set_via_stdin(machine: str, var_id: str, flake: FlakeId) -> None:
_machine = Machine(name=machine, flake=flake) var = get_var(str(flake.path), machine, var_id)
var = get_var(_machine, var_id)
if sys.stdin.isatty(): if sys.stdin.isatty():
new_value = ask(var.id, PromptType.HIDDEN).encode("utf-8") new_value = ask(
var.id,
PromptType.HIDDEN,
None,
).encode("utf-8")
else: else:
new_value = sys.stdin.buffer.read() new_value = sys.stdin.buffer.read()
_machine = Machine(name=machine, flake=flake)
set_var(_machine, var, new_value, flake) set_var(_machine, var, new_value, flake)

View File

@@ -170,9 +170,16 @@ def test_generate_public_and_secret_vars(
"Update vars via generator my_shared_generator for machine my_machine" "Update vars via generator my_shared_generator for machine my_machine"
in commit_message in commit_message
) )
assert get_var(machine, "my_generator/my_value").printable_value == "public"
assert ( assert (
get_var(machine, "my_shared_generator/my_shared_value").printable_value get_var(
str(machine.flake.path), machine.name, "my_generator/my_value"
).printable_value
== "public"
)
assert (
get_var(
str(machine.flake.path), machine.name, "my_shared_generator/my_shared_value"
).printable_value
== "shared" == "shared"
) )
vars_text = stringify_all_vars(machine) vars_text = stringify_all_vars(machine)
@@ -587,7 +594,7 @@ def test_api_set_prompts(
flake: ClanFlake, flake: ClanFlake,
) -> None: ) -> None:
from clan_cli.vars._types import GeneratorUpdate from clan_cli.vars._types import GeneratorUpdate
from clan_cli.vars.list import get_prompts, set_prompts from clan_cli.vars.list import get_generators, set_prompts
config = flake.machines["my_machine"] config = flake.machines["my_machine"]
config["nixpkgs"]["hostPlatform"] = "x86_64-linux" config["nixpkgs"]["hostPlatform"] = "x86_64-linux"
@@ -623,11 +630,11 @@ def test_api_set_prompts(
) )
assert store.get(Generator("my_generator"), "prompt1").decode() == "input2" assert store.get(Generator("my_generator"), "prompt1").decode() == "input2"
api_prompts = get_prompts(**params) generators = get_generators(**params)
assert len(api_prompts) == 1 assert len(generators) == 1
assert api_prompts[0].name == "my_generator" assert generators[0].name == "my_generator"
assert api_prompts[0].prompts[0].name == "prompt1" assert generators[0].prompts[0].name == "prompt1"
assert api_prompts[0].prompts[0].previous_value == "input2" assert generators[0].prompts[0].previous_value == "input2"
@pytest.mark.with_core @pytest.mark.with_core
@@ -843,19 +850,27 @@ def test_invalidation(
monkeypatch.chdir(flake.path) monkeypatch.chdir(flake.path)
cli.run(["vars", "generate", "--flake", str(flake.path), "my_machine"]) cli.run(["vars", "generate", "--flake", str(flake.path), "my_machine"])
machine = Machine(name="my_machine", flake=FlakeId(str(flake.path))) machine = Machine(name="my_machine", flake=FlakeId(str(flake.path)))
value1 = get_var(machine, "my_generator/my_value").printable_value value1 = get_var(
str(machine.flake.path), machine.name, "my_generator/my_value"
).printable_value
# generate again and make sure nothing changes without the invalidation data being set # generate again and make sure nothing changes without the invalidation data being set
cli.run(["vars", "generate", "--flake", str(flake.path), "my_machine"]) cli.run(["vars", "generate", "--flake", str(flake.path), "my_machine"])
value1_new = get_var(machine, "my_generator/my_value").printable_value value1_new = get_var(
str(machine.flake.path), machine.name, "my_generator/my_value"
).printable_value
assert value1 == value1_new assert value1 == value1_new
# set the invalidation data of the generator # set the invalidation data of the generator
my_generator["validation"] = 1 my_generator["validation"] = 1
flake.refresh() flake.refresh()
# generate again and make sure the value changes # generate again and make sure the value changes
cli.run(["vars", "generate", "--flake", str(flake.path), "my_machine"]) cli.run(["vars", "generate", "--flake", str(flake.path), "my_machine"])
value2 = get_var(machine, "my_generator/my_value").printable_value value2 = get_var(
str(machine.flake.path), machine.name, "my_generator/my_value"
).printable_value
assert value1 != value2 assert value1 != value2
# generate again without changing invalidation data -> value should not change # generate again without changing invalidation data -> value should not change
cli.run(["vars", "generate", "--flake", str(flake.path), "my_machine"]) cli.run(["vars", "generate", "--flake", str(flake.path), "my_machine"])
value2_new = get_var(machine, "my_generator/my_value").printable_value value2_new = get_var(
str(machine.flake.path), machine.name, "my_generator/my_value"
).printable_value
assert value2 == value2_new assert value2 == value2_new