diff --git a/lib/build-clan/interface.nix b/lib/build-clan/interface.nix index baf1a45fb..89f0ad0da 100644 --- a/lib/build-clan/interface.nix +++ b/lib/build-clan/interface.nix @@ -101,6 +101,7 @@ in # Those options are interfaced by the CLI # We don't specify the type here, for better performance. inventory = lib.mkOption { type = lib.types.raw; }; + inventoryValuesPrios = lib.mkOption { type = lib.types.raw; }; # all inventory module schemas moduleSchemas = lib.mkOption { type = lib.types.raw; }; inventoryFile = lib.mkOption { type = lib.types.raw; }; diff --git a/lib/build-clan/module.nix b/lib/build-clan/module.nix index efd1cfaf5..e3f3cb0f8 100644 --- a/lib/build-clan/module.nix +++ b/lib/build-clan/module.nix @@ -183,6 +183,7 @@ in inherit serviceConfigs; inherit (clan-core) clanModules; inherit inventoryFile; + inventoryValuesPrios = (clan-core.lib.values.getPrios { options = inventory.options; }); inventory = config.inventory; meta = config.inventory.meta; diff --git a/lib/default.nix b/lib/default.nix index e1ab88473..b48b4582e 100644 --- a/lib/default.nix +++ b/lib/default.nix @@ -15,6 +15,7 @@ in buildClan = import ./build-clan { inherit lib nixpkgs clan-core; }; facts = import ./facts.nix { inherit lib; }; inventory = import ./inventory { inherit lib clan-core; }; + values = import ./values { inherit lib; }; jsonschema = import ./jsonschema { inherit lib; }; modules = import ./frontmatter { inherit lib; diff --git a/lib/flake-module.nix b/lib/flake-module.nix index e9ab69a54..ccfdf3151 100644 --- a/lib/flake-module.nix +++ b/lib/flake-module.nix @@ -9,6 +9,7 @@ ./jsonschema/flake-module.nix ./inventory/flake-module.nix ./build-clan/flake-module.nix + ./values/flake-module.nix ]; flake.lib = import ./default.nix { inherit lib inputs; diff --git a/lib/inventory/build-inventory/interface.nix b/lib/inventory/build-inventory/interface.nix index 3e152f424..ea3e45422 100644 --- a/lib/inventory/build-inventory/interface.nix +++ b/lib/inventory/build-inventory/interface.nix @@ -1,4 +1,9 @@ -{ lib, config, ... }: +{ + lib, + config, + options, + ... +}: let types = lib.types; @@ -92,6 +97,12 @@ in ./assertions.nix ]; options = { + options = lib.mkOption { + internal = true; + visible = false; + type = types.raw; + default = options; + }; modules = lib.mkOption { type = types.attrsOf types.path; default = { }; diff --git a/lib/values/default.nix b/lib/values/default.nix new file mode 100644 index 000000000..05dcdebe1 --- /dev/null +++ b/lib/values/default.nix @@ -0,0 +1,90 @@ +{ + lib ? import , +}: +let + filterOptions = lib.filterAttrs ( + name: _: + !builtins.elem name [ + "_module" + "_freeformOptions" + ] + ); + + getPrios = + { + options, + }: + let + filteredOptions = filterOptions options; + in + lib.mapAttrs ( + _: opt: + let + prio = { + __prio = opt.highestPrio; + }; + subOptions = opt.type.getSubOptions opt.loc; + + attrDefinitions = (lib.modules.mergeAttrDefinitionsWithPrio opt); + zipDefs = builtins.zipAttrsWith (_ns: vs: vs); + defs = zipDefs opt.definitions; + + prioPerValue = + { type, defs }: + lib.mapAttrs ( + attrName: prioSet: + let + # Evaluate the submodule + options = filterOptions subOptions; + modules = ( + [ + { inherit options; } + ] + ++ map (config: { inherit config; }) defs.${attrName} + ); + submoduleEval = lib.evalModules { + inherit modules; + }; + in + (lib.optionalAttrs (prioSet ? highestPrio) { + __prio = prioSet.highestPrio; + # inherit defs options; + }) + // ( + if type.nestedTypes.elemType.name == "submodule" then + getPrios { options = submoduleEval.options; } + else + # Nested attrsOf + (lib.optionalAttrs (type.nestedTypes.elemType.name == "attrsOf") ( + prioPerValue { + type = type.nestedTypes.elemType; + defs = zipDefs defs.${attrName}; + } prioSet.value + )) + ) + ); + + attributePrios = prioPerValue { + type = opt.type; + inherit defs; + } attrDefinitions; + in + if opt ? type && opt.type.name == "submodule" then + prio // (getPrios { options = subOptions; }) + else if opt ? type && opt.type.name == "attrsOf" then + # prio // attributePrios + # else if + # opt ? type && opt.type.name == "attrsOf" && opt.type.nestedTypes.elemType.name == "attrsOf" + # then + # prio // attributePrios + # else if opt ? type && opt.type.name == "attrsOf" then + prio // attributePrios + else if opt ? type && opt._type == "option" then + prio + else + getPrios { options = opt; } + ) filteredOptions; +in +{ + inherit getPrios; +} diff --git a/lib/values/flake-module.nix b/lib/values/flake-module.nix new file mode 100644 index 000000000..db60f92c5 --- /dev/null +++ b/lib/values/flake-module.nix @@ -0,0 +1,24 @@ +{ self, inputs, ... }: +let + inputOverrides = builtins.concatStringsSep " " ( + builtins.map (input: " --override-input ${input} ${inputs.${input}}") (builtins.attrNames inputs) + ); +in +{ + perSystem = + { pkgs, system, ... }: + { + checks = { + lib-values-eval = pkgs.runCommand "tests" { nativeBuildInputs = [ pkgs.nix-unit ]; } '' + export HOME="$(realpath .)" + + nix-unit --eval-store "$HOME" \ + --extra-experimental-features flakes \ + ${inputOverrides} \ + --flake ${self}#legacyPackages.${system}.evalTests-inventory + + touch $out + ''; + }; + }; +} diff --git a/lib/values/test.nix b/lib/values/test.nix new file mode 100644 index 000000000..b18c85179 --- /dev/null +++ b/lib/values/test.nix @@ -0,0 +1,211 @@ +# tests for the nixos options to jsonschema converter +# run these tests via `nix-unit ./test.nix` +{ + lib ? (import { }).lib, + slib ? (import ./. { inherit lib; }), +}: +let + eval = + modules: + let + evaledConfig = lib.evalModules { + inherit modules; + }; + in + evaledConfig; +in +{ + test_default = { + expr = slib.getPrios { + options = + (eval [ + { + options.foo.bar = lib.mkOption { + type = lib.types.bool; + description = "Test Description"; + default = true; + }; + } + ]).options; + }; + expected = { + foo.bar = { + __prio = 1500; + }; + }; + }; + test_no_default = { + expr = slib.getPrios { + options = + (eval [ + { + options.foo.bar = lib.mkOption { + type = lib.types.bool; + }; + } + ]).options; + }; + expected = { + foo.bar = { + __prio = 9999; + }; + }; + }; + + test_submodule = { + expr = slib.getPrios { + options = + (eval [ + { + options.foo = lib.mkOption { + type = lib.types.submodule { + options = { + bar = lib.mkOption { + type = lib.types.bool; + }; + }; + }; + }; + } + ]).options; + }; + expected = { + foo = { + # Prio of the submodule itself + __prio = 9999; + + # Prio of the bar option within the submodule + bar.__prio = 9999; + }; + }; + }; + + # TODO(@hsjobeki): Cover this edge case + # test_freeform = + # let + # evaluated = ( + # eval [ + # { + # freeformType = with lib.types; attrsOf (int); + # options = { + # foo = lib.mkOption { + # type = lib.types.int; + # default = 0; + # }; + # }; + # } + # { + # bar = lib.mkForce 123; + # baz = 1; + # } + # { + # bar = 10; + # } + # ] + # ); + # in + # { + # inherit evaluated; + # expr = slib.getPrios { + # options = evaluated.options; + # }; + # expected = { + # }; + # }; + + test_attrsOf_submodule = + let + evaluated = eval [ + { + options.foo = lib.mkOption { + type = lib.types.attrsOf ( + lib.types.submodule { + options = { + bar = lib.mkOption { + type = lib.types.int; + default = 0; + }; + }; + } + ); + }; + config.foo = { + "nested" = { + "bar" = 2; # <- 100 prio ? + }; + "other" = { + "bar" = lib.mkForce 2; # <- 50 prio ? + }; + }; + } + ]; + in + { + expr = slib.getPrios { options = evaluated.options; }; + expected = { + foo.__prio = 100; + + foo.nested.__prio = 100; + foo.other.__prio = 100; + + foo.nested.bar.__prio = 100; + foo.other.bar.__prio = 50; + }; + }; + test_attrsOf_attrsOf_submodule = + let + evaluated = eval [ + { + options.foo = lib.mkOption { + type = lib.types.attrsOf ( + lib.types.attrsOf ( + lib.types.submodule { + options = { + bar = lib.mkOption { + type = lib.types.int; + default = 0; + }; + }; + } + ) + ); + }; + config.foo = { + a.b = { + bar = 1; + }; + a.c = { + bar = 1; + }; + x.y = { + bar = 1; + }; + x.z = { + bar = 1; + }; + }; + } + ]; + in + { + inherit evaluated; + expr = slib.getPrios { options = evaluated.options; }; + expected = { + foo.__prio = 100; + + # Sub A + foo.a.__prio = 100; + # a.b doesnt have a prio + # a.c doesnt have a prio + foo.a.b.bar.__prio = 100; + foo.a.c.bar.__prio = 100; + + # Sub X + foo.x.__prio = 100; + # x.y doesnt have a prio + # x.z doesnt have a prio + foo.x.y.bar.__prio = 100; + foo.x.z.bar.__prio = 100; + }; + }; +} diff --git a/pkgs/clan-cli/clan_cli/inventory/__init__.py b/pkgs/clan-cli/clan_cli/inventory/__init__.py index c9867c222..18f743e8f 100644 --- a/pkgs/clan-cli/clan_cli/inventory/__init__.py +++ b/pkgs/clan-cli/clan_cli/inventory/__init__.py @@ -119,6 +119,37 @@ def load_inventory_json( return inventory +def patch(d: dict[str, Any], path: str, content: Any) -> None: + """ + Update the value at a specific dot-separated path in a nested dictionary. + + :param d: The dictionary to update. + :param path: The dot-separated path to the key (e.g., 'foo.bar'). + :param content: The new value to set. + """ + keys = path.split(".") + current = d + for key in keys[:-1]: + current = current.setdefault(key, {}) + current[keys[-1]] = content + + +@API.register +def patch_inventory_with(base_dir: Path, section: str, content: dict[str, Any]) -> None: + inventory_file = get_path(base_dir) + + curr_inventory = {} + with inventory_file.open("r") as f: + curr_inventory = json.load(f) + + patch(curr_inventory, section, content) + + with inventory_file.open("w") as f: + json.dump(curr_inventory, f, indent=2) + + commit_file(inventory_file, base_dir, commit_message=f"inventory.{section}: Update") + + @API.register def set_inventory( inventory: Inventory | dict[str, Any], flake_dir: str | Path, message: str @@ -129,6 +160,19 @@ def set_inventory( """ inventory_file = get_path(flake_dir) + # Filter out modules not set via UI. + # It is not possible to set modules from "/nix/store" via the UI + modules = {} + filtered_modules = lambda m: { + key: value for key, value in m.items() if "/nix/store" not in value + } + if isinstance(inventory, dict): + modules = filtered_modules(inventory.get("modules", {})) # type: ignore + inventory["modules"] = modules + else: + modules = filtered_modules(inventory.modules) # type: ignore + inventory.modules = modules + with inventory_file.open("w") as f: if isinstance(inventory, Inventory): json.dump(dataclass_to_dict(inventory), f, indent=2) diff --git a/pkgs/clan-cli/clan_cli/machines/list.py b/pkgs/clan-cli/clan_cli/machines/list.py index b4e931757..ef41d42b3 100644 --- a/pkgs/clan-cli/clan_cli/machines/list.py +++ b/pkgs/clan-cli/clan_cli/machines/list.py @@ -7,11 +7,16 @@ from typing import Literal from clan_cli.api import API from clan_cli.api.modules import parse_frontmatter +from clan_cli.api.serde import dataclass_to_dict from clan_cli.cmd import run_no_stdout from clan_cli.completions import add_dynamic_completer, complete_tags from clan_cli.dirs import specific_machine_dir from clan_cli.errors import ClanCmdError, ClanError -from clan_cli.inventory import Machine, load_inventory_eval, set_inventory +from clan_cli.inventory import ( + Machine, + load_inventory_eval, + patch_inventory_with, +) from clan_cli.machines.hardware import HardwareConfig from clan_cli.nix import nix_eval, nix_shell from clan_cli.tags import list_nixos_machines_by_tags @@ -20,12 +25,10 @@ log = logging.getLogger(__name__) @API.register -def set_machine(flake_url: str | Path, machine_name: str, machine: Machine) -> None: - inventory = load_inventory_eval(flake_url) - - inventory.machines[machine_name] = machine - - set_inventory(inventory, flake_url, "machines: edit '{machine_name}'") +def set_machine(flake_url: Path, machine_name: str, machine: Machine) -> None: + patch_inventory_with( + flake_url, f"machines.{machine_name}", dataclass_to_dict(machine) + ) @API.register diff --git a/pkgs/clan-cli/tests/test_patch_inventory.py b/pkgs/clan-cli/tests/test_patch_inventory.py new file mode 100644 index 000000000..c788288bb --- /dev/null +++ b/pkgs/clan-cli/tests/test_patch_inventory.py @@ -0,0 +1,36 @@ +# Functions to test +from clan_cli.inventory import patch + + +def test_patch_nested() -> None: + orig = {"a": 1, "b": {"a": 2.1, "b": 2.2}, "c": 3} + + patch(orig, "b.b", "foo") + + # Should only update the nested value + assert orig == {"a": 1, "b": {"a": 2.1, "b": "foo"}, "c": 3} + + +def test_patch_nested_dict() -> None: + orig = {"a": 1, "b": {"a": 2.1, "b": 2.2}, "c": 3} + + # This should update the whole "b" dict + # Which also removes all other keys + patch(orig, "b", {"b": "foo"}) + + # Should only update the nested value + assert orig == {"a": 1, "b": {"b": "foo"}, "c": 3} + + +def test_create_missing_paths() -> None: + orig = {"a": 1} + + patch(orig, "b.c", "foo") + + # Should only update the nested value + assert orig == {"a": 1, "b": {"c": "foo"}} + + orig = {} + patch(orig, "a.b.c", "foo") + + assert orig == {"a": {"b": {"c": "foo"}}} diff --git a/pkgs/webview-ui/app/src/api/disk.ts b/pkgs/webview-ui/app/src/api/disk.ts deleted file mode 100644 index 37ad908fb..000000000 --- a/pkgs/webview-ui/app/src/api/disk.ts +++ /dev/null @@ -1,37 +0,0 @@ -import { QueryClient } from "@tanstack/query-core"; -import { get_inventory } from "./inventory"; - -export const instance_name = (machine_name: string) => - `${machine_name}-single-disk` as const; - -export async function set_single_disk_id( - client: QueryClient, - base_path: string, - machine_name: string, - disk_id: string, -) { - const r = await get_inventory(client, base_path); - if (r.status === "error") { - return r; - } - if (!r.data.services) { - return new Error("No services found in inventory"); - } - const inventory = r.data; - inventory.services = inventory.services || {}; - inventory.services["single-disk"] = inventory.services["single-disk"] || {}; - - inventory.services["single-disk"][instance_name(machine_name)] = { - meta: { - name: instance_name(machine_name), - }, - roles: { - default: { - machines: [machine_name], - config: { - device: `/dev/disk/by-id/${disk_id}`, - }, - }, - }, - }; -} diff --git a/pkgs/webview-ui/app/src/routes/machines/details.tsx b/pkgs/webview-ui/app/src/routes/machines/details.tsx index 7b32dc311..857966623 100644 --- a/pkgs/webview-ui/app/src/routes/machines/details.tsx +++ b/pkgs/webview-ui/app/src/routes/machines/details.tsx @@ -1,11 +1,11 @@ import { callApi, ClanService, SuccessData, SuccessQuery } from "@/src/api"; -import { set_single_disk_id } from "@/src/api/disk"; import { get_iwd_service } from "@/src/api/wifi"; import { activeURI } from "@/src/App"; import { BackButton } from "@/src/components/BackButton"; import { Button } from "@/src/components/button"; import { FileInput } from "@/src/components/FileInput"; import Icon from "@/src/components/icon"; +import { RndThumbnail } from "@/src/components/noiseThumbnail"; import { SelectInput } from "@/src/components/SelectInput"; import { TextInput } from "@/src/components/TextInput"; import { selectSshKeys } from "@/src/hooks"; @@ -54,18 +54,6 @@ const InstallMachine = (props: InstallMachineProps) => { const [confirmDisk, setConfirmDisk] = createSignal(!hasDisk()); - const hwInfoQuery = createQuery(() => ({ - queryKey: [curr, "machine", name, "show_machine_hardware_config"], - queryFn: async () => { - const result = await callApi("show_machine_hardware_config", { - clan_dir: curr, - machine_name: name, - }); - if (result.status === "error") throw new Error("Failed to fetch data"); - return result.data === "NIXOS_FACTER"; - }, - })); - const handleInstall = async (values: InstallForm) => { console.log("Installing", values); const curr_uri = activeURI(); @@ -98,7 +86,6 @@ const InstallMachine = (props: InstallMachineProps) => { toast.success("Machine installed successfully"); } }; - const queryClient = useQueryClient(); const handleDiskConfirm = async (e: Event) => { e.preventDefault(); @@ -108,19 +95,6 @@ const InstallMachine = (props: InstallMachineProps) => { if (!curr_uri || !disk_id || !props.name) { return; } - - const r = await set_single_disk_id( - queryClient, - curr_uri, - props.name, - disk_id, - ); - if (!r) { - toast.success("Disk set successfully"); - setConfirmDisk(true); - } else { - toast.error("Failed to set disk"); - } }; const generateReport = async (e: Event) => { @@ -141,7 +115,7 @@ const InstallMachine = (props: InstallMachineProps) => { }, }); toast.dismiss(loading_toast); - hwInfoQuery.refetch(); + // TODO: refresh the machine details if (r.status === "error") { toast.error(`Failed to generate report. ${r.errors[0].message}`); @@ -164,81 +138,36 @@ const InstallMachine = (props: InstallMachineProps) => {
Hardware detection
-
- - - - - - - - close - Not Detected - -
- This might still work, but it is recommended to generate - a hardware report. -
- - } - > - - check - Detected - -
-
-
+
+
+
Disk schema
+
+
+
- - {(field, fieldProps) => ( - - - - {(dev) => ( - - )} - - - } - /> - )} - + {(field, fieldProps) => "disk"}