From d166f73c0097adbce41f147abc37ab4e8a89f67d Mon Sep 17 00:00:00 2001 From: Johannes Kirschbauer Date: Wed, 11 Jun 2025 10:51:05 +0200 Subject: [PATCH 01/13] Feat(templates): add template selector tranformation --- .../clan_lib/templates/template_url.py | 75 +++++++++++ .../clan_lib/templates/template_url_test.py | 119 ++++++++++++++++++ 2 files changed, 194 insertions(+) create mode 100644 pkgs/clan-cli/clan_lib/templates/template_url.py create mode 100644 pkgs/clan-cli/clan_lib/templates/template_url_test.py diff --git a/pkgs/clan-cli/clan_lib/templates/template_url.py b/pkgs/clan-cli/clan_lib/templates/template_url.py new file mode 100644 index 000000000..2e85394b7 --- /dev/null +++ b/pkgs/clan-cli/clan_lib/templates/template_url.py @@ -0,0 +1,75 @@ +import logging + +from clan_lib.errors import ClanError + +log = logging.getLogger(__name__) + + +def transform_url(template_type: str, identifier: str) -> tuple[str, str]: + """ + Transform a template flake ref by injecting the context (clan|machine|disko) into the url. + We do this for shorthand notation of URLs. + If the attribute selector path is longer than one, don't transform it. + + Examples: + + # injects "machine" as context + clan machines create --template .#new-machine + or + clan machines create --template #new-machine + -> .#clan.templates.machine.new-machine + + # injects "clan" as context + clan create --template .#default + -> .#clan.templates.clan.default + + # Dont transform explicit paths (e.g. when more than one attribute selector is present) + clan machines create --template .#clan.templates.machine.new-machine + -> .#clan.templates.machine.new-machine + + clan machines create --template .#"new.machine" + -> .#clan.templates.machine."new.machine" + + # Builtin templates + clan machines create --template new.machine + -> clanInternals.templates.machine."new.machine" + + # Remote templates + clan machines create --template github:/org/repo#new.machine + -> clanInternals.templates.machine."new.machine" + + As of URL specification (RFC 3986). + scheme:[//[user:password@]host[:port]][/path][?query][#fragment] + + We can safely split the URL into a front part and the fragment + We can then analyze the fragment and inject the context into the path. + + Of there is no fragment and no URL its a builtin template path. + new-machine -> #clanInternals.templates.machine."new-machine" + + """ + if identifier.count("#") > 1: + msg = "Invalid template identifier: More than one '#' found. Please use a single '#'" + raise ClanError(msg) + + [flake_ref, selector] = ( + identifier.split("#", 1) if "#" in identifier else ["", identifier] + ) + + if "#" not in identifier: + # No fragment, so we assume its a builtin template + return ("", f'clanInternals.templates.{template_type}."{selector}"') + + # TODO: implement support for quotes in the tail "a.b".c + # If the tail contains a dot, or is quoted we assume its a path and don't transform it. + if '"' in selector or "'" in selector: + log.warning( + "Quotes in template paths are not yet supported. Please use unquoted paths." + ) + return (flake_ref, selector) + + if "." in selector: + return (flake_ref, selector) + + # Tail doesn't contain a dot at this point, so we can inject the context. + return (flake_ref, f'clan.templates.{template_type}."{selector}"') diff --git a/pkgs/clan-cli/clan_lib/templates/template_url_test.py b/pkgs/clan-cli/clan_lib/templates/template_url_test.py new file mode 100644 index 000000000..ecef1c88d --- /dev/null +++ b/pkgs/clan-cli/clan_lib/templates/template_url_test.py @@ -0,0 +1,119 @@ +from pathlib import Path + +import pytest + +from clan_lib.errors import ClanError +from clan_lib.templates.template_url import transform_url + +template_type = "machine" + + +class DummyFlake: + def __init__(self, path: str) -> None: + self.path: Path = Path(path) + + +local_path = DummyFlake(".") + + +def test_transform_url_self_explizit_dot() -> None: + user_input = ".#new-machine" + expected_selector = 'clan.templates.machine."new-machine"' + + flake_ref, selector = transform_url(template_type, user_input) + assert flake_ref == "." + assert selector == expected_selector + + +def test_transform_url_self_no_dot() -> None: + user_input = "#new-machine" + expected_selector = 'clan.templates.machine."new-machine"' + + flake_ref, selector = transform_url(template_type, user_input) + assert flake_ref == "" + assert selector == expected_selector + + +def test_transform_url_builtin_template() -> None: + user_input = "new-machine" + expected_selector = 'clanInternals.templates.machine."new-machine"' + + flake_ref, selector = transform_url(template_type, user_input) + assert flake_ref == "" + assert selector == expected_selector + + +def test_transform_url_remote_template() -> None: + user_input = "github:/org/repo#new-machine" + expected_selector = 'clan.templates.machine."new-machine"' + + flake_ref, selector = transform_url(template_type, user_input) + + assert flake_ref == "github:/org/repo" + assert selector == expected_selector + + +def test_transform_url_explicit_path() -> None: + user_input = ".#clan.templates.machine.new-machine" + expected_selector = "clan.templates.machine.new-machine" + + flake_ref, selector = transform_url(template_type, user_input) + assert flake_ref == "." + assert selector == expected_selector + + +# Currently quoted selectors are treated as explicit paths. +def test_transform_url_quoted_selector() -> None: + user_input = '.#"new.machine"' + expected_selector = '"new.machine"' + flake_ref, selector = transform_url(template_type, user_input) + assert flake_ref == "." + assert selector == expected_selector + + +def test_single_quote_selector() -> None: + user_input = ".#'new.machine'" + expected_selector = "'new.machine'" + flake_ref, selector = transform_url(template_type, user_input) + assert flake_ref == "." + assert selector == expected_selector + + +def test_custom_template_path() -> None: + user_input = "github:/org/repo#my.templates.custom.machine" + expected_selector = "my.templates.custom.machine" + + flake_ref, selector = transform_url(template_type, user_input) + assert flake_ref == "github:/org/repo" + assert selector == expected_selector + + +def test_full_url_query_and_fragment() -> None: + user_input = "github:/org/repo?query=param#my.templates.custom.machine" + expected_flake_ref = "github:/org/repo?query=param" + expected_selector = "my.templates.custom.machine" + + flake_ref, selector = transform_url(template_type, user_input) + assert flake_ref == expected_flake_ref + assert selector == expected_selector + + +def test_custom_template_type() -> None: + user_input = "#my.templates.custom.machine" + expected_selector = "my.templates.custom.machine" + + flake_ref, selector = transform_url("custom", user_input) + assert flake_ref == "" + assert selector == expected_selector + + +def test_malformed_identifier() -> None: + user_input = "github:/org/repo#my.templates.custom.machine#extra" + with pytest.raises(ClanError) as exc_info: + _flake_ref, _selector = transform_url(template_type, user_input) + + assert isinstance(exc_info.value, ClanError) + assert ( + str(exc_info.value) + == "Invalid template identifier: More than one '#' found. Please use a single '#'" + ) From 4a126fee12638244d1c2432f38026bad4ac6d452 Mon Sep 17 00:00:00 2001 From: Johannes Kirschbauer Date: Wed, 11 Jun 2025 12:02:32 +0200 Subject: [PATCH 02/13] Feat(templates): export clan templates statically --- lib/build-clan/interface.nix | 2 ++ lib/build-clan/module.nix | 3 +++ 2 files changed, 5 insertions(+) diff --git a/lib/build-clan/interface.nix b/lib/build-clan/interface.nix index 7e70b90e8..f71e97d90 100644 --- a/lib/build-clan/interface.nix +++ b/lib/build-clan/interface.nix @@ -213,6 +213,8 @@ in secrets = lib.mkOption { type = lib.types.raw; }; + templates = lib.mkOption { type = lib.types.raw; }; + machines = lib.mkOption { type = lib.types.raw; }; }; }; diff --git a/lib/build-clan/module.nix b/lib/build-clan/module.nix index aee0a2b79..a137f8202 100644 --- a/lib/build-clan/module.nix +++ b/lib/build-clan/module.nix @@ -250,6 +250,9 @@ in # We should have only clan.modules. (consistent with clan.templates) inherit (clan-core) clanModules; + # Statically export the predefined clan modules + templates = clan-core.clan.templates; + secrets = config.secrets; # machine specifics From a9c933ac0146df70bac6de6495ecae6ea804efd9 Mon Sep 17 00:00:00 2001 From: Johannes Kirschbauer Date: Wed, 11 Jun 2025 12:03:08 +0200 Subject: [PATCH 03/13] Feat(templates): init with_machine_template context handler --- pkgs/clan-cli/clan_lib/templates/handler.py | 100 ++++++++++++++++++++ 1 file changed, 100 insertions(+) create mode 100644 pkgs/clan-cli/clan_lib/templates/handler.py diff --git a/pkgs/clan-cli/clan_lib/templates/handler.py b/pkgs/clan-cli/clan_lib/templates/handler.py new file mode 100644 index 000000000..9fecef37b --- /dev/null +++ b/pkgs/clan-cli/clan_lib/templates/handler.py @@ -0,0 +1,100 @@ +import logging +import shutil +from collections.abc import Iterator +from contextlib import contextmanager +from pathlib import Path + +from clan_lib.dirs import specific_machine_dir +from clan_lib.errors import ClanError +from clan_lib.flake import Flake +from clan_lib.machines.actions import MachineID, list_machines +from clan_lib.templates.filesystem import copy_from_nixstore, realize_nix_path +from clan_lib.templates.template_url import transform_url + +log = logging.getLogger(__name__) + + +@contextmanager +def with_machine_template( + flake: Flake, template_ident: str, dst_machine_name: str +) -> Iterator[Path]: + """ + Create a machine from a template. + This function will copy the template files to the machine specific directory of the specified flake. + + :param flake: The flake to create the machine in. + :param template_ident: The identifier of the template to use. Example ".#template_name" + :param dst_machine_name: The name of the machine to create. + + Example usage: + + >>> with with_machine_template( + ... Flake("/home/johannes/git/clan-core"), ".#new-machine", "my-machine" + ... ) as machine_path: + ... # Use `machine_path` here if you want to access the created machine directory + + ... The machine directory is removed if the context raised any errors. + ... Only if the context is exited without errors, the machine directory is kept. + """ + + # Check for duplicates + if dst_machine_name in list_machines(flake): + msg = f"Machine '{dst_machine_name}' already exists" + raise ClanError( + msg, + description="Please remove the existing machine or choose a different name", + ) + + # Get the clan template from the specifier + [flake_ref, template_selector] = transform_url("machine", template_ident) + template_flake = Flake(flake_ref) + template = template_flake.select(template_selector) + + # For pretty error messages + printable_template_ref = f"{flake_ref}#{template_ident}" + + src = template.get("path") + if not src: + msg = f"Malformed template: {printable_template_ref} does not have a 'path' attribute" + raise ClanError(msg) + + src_path = Path(src).resolve() + + realize_nix_path(template_flake, str(src_path)) + + if not src_path.exists(): + msg = f"Template {printable_template_ref} does not exist at {src_path}" + raise ClanError(msg) + + if not src_path.is_dir(): + msg = f"Template {printable_template_ref} is not a directory at {src_path}" + raise ClanError(msg) + + # TODO: Do we really need to check for a specific file in the template? + if not (src_path / "configuration.nix").exists(): + msg = f"Template {printable_template_ref} does not contain a configuration.nix" + raise ClanError( + msg, + description="Template machine must contain a configuration.nix", + ) + + tmp_machine = MachineID(flake=flake, name=dst_machine_name) + + dst_machine_dir = specific_machine_dir(tmp_machine) + + copy_from_nixstore(src_path, dst_machine_dir) + + try: + yield dst_machine_dir + except Exception as e: + log.error(f"An error occurred inside the 'with_machine_template' context: {e}") + + # Ensure that the directory is removed to avoid half-created machines + # Everything in the with block is considered part of the context + # So if the context raises an error, we clean up the machine directory + log.info(f"Removing left-over machine directory: {dst_machine_dir}") + shutil.rmtree(dst_machine_dir, ignore_errors=True) + raise + finally: + # If no error occurred, the machine directory is kept + pass From 43bc5f08126511bbe30703142feab216c4c20b2c Mon Sep 17 00:00:00 2001 From: Johannes Kirschbauer Date: Wed, 11 Jun 2025 15:56:45 +0200 Subject: [PATCH 04/13] Feat(template_url): substitute local refs To execute the CLI in foreign directories .#new-machine needs to get tranformed into /path/to/clan#new-machine Otherwise it might pick-up some random flake that is in scope where the cli started executing --- pkgs/clan-cli/clan_lib/templates/handler.py | 2 +- .../clan_lib/templates/template_url.py | 38 ++++++++++--- .../clan_lib/templates/template_url_test.py | 56 +++++++++++++------ 3 files changed, 69 insertions(+), 27 deletions(-) diff --git a/pkgs/clan-cli/clan_lib/templates/handler.py b/pkgs/clan-cli/clan_lib/templates/handler.py index 9fecef37b..cecc887c3 100644 --- a/pkgs/clan-cli/clan_lib/templates/handler.py +++ b/pkgs/clan-cli/clan_lib/templates/handler.py @@ -46,7 +46,7 @@ def with_machine_template( ) # Get the clan template from the specifier - [flake_ref, template_selector] = transform_url("machine", template_ident) + [flake_ref, template_selector] = transform_url("machine", template_ident, local_path=flake) template_flake = Flake(flake_ref) template = template_flake.select(template_selector) diff --git a/pkgs/clan-cli/clan_lib/templates/template_url.py b/pkgs/clan-cli/clan_lib/templates/template_url.py index 2e85394b7..0f4586f27 100644 --- a/pkgs/clan-cli/clan_lib/templates/template_url.py +++ b/pkgs/clan-cli/clan_lib/templates/template_url.py @@ -1,40 +1,58 @@ import logging +from pathlib import Path +from typing import Protocol from clan_lib.errors import ClanError log = logging.getLogger(__name__) -def transform_url(template_type: str, identifier: str) -> tuple[str, str]: +class Flake(Protocol): + """ + Protocol for a local flake, which has a path attribute. + Pass clan_lib.flake.Flake or any other object that implements this protocol. + """ + + @property + def path(self) -> Path: ... + + +def transform_url( + template_type: str, identifier: str, local_path: Flake +) -> tuple[str, str]: """ Transform a template flake ref by injecting the context (clan|machine|disko) into the url. We do this for shorthand notation of URLs. - If the attribute selector path is longer than one, don't transform it. + If the attribute selector path is explicitly selecting an attribute, we don't transform it. + + :param template_type: The type of the template (clan, machine, disko) + :param identifier: The identifier of the template, which can be a flake reference with a fragment. + :param local_path: The local flake path, which is used to resolve to a local flake reference, i.e. ".#" shorthand. Examples: - # injects "machine" as context + 1. injects "machine" as context clan machines create --template .#new-machine or clan machines create --template #new-machine -> .#clan.templates.machine.new-machine - # injects "clan" as context + 2. injects "clan" as context clan create --template .#default -> .#clan.templates.clan.default - # Dont transform explicit paths (e.g. when more than one attribute selector is present) + 3. Dont transform explicit paths (e.g. when more than one attribute selector is present) clan machines create --template .#clan.templates.machine.new-machine -> .#clan.templates.machine.new-machine clan machines create --template .#"new.machine" -> .#clan.templates.machine."new.machine" - # Builtin templates + 4. Builtin templates clan machines create --template new.machine -> clanInternals.templates.machine."new.machine" - # Remote templates + 5. Remote templates clan machines create --template github:/org/repo#new.machine -> clanInternals.templates.machine."new.machine" @@ -55,10 +73,14 @@ def transform_url(template_type: str, identifier: str) -> tuple[str, str]: [flake_ref, selector] = ( identifier.split("#", 1) if "#" in identifier else ["", identifier] ) + # Substitute the flake reference with the local flake path if it is empty or just a dot. + # This is required if the command will be executed from a different place, than the local flake root. + if not flake_ref or flake_ref == ".": + flake_ref = str(local_path.path) if "#" not in identifier: # No fragment, so we assume its a builtin template - return ("", f'clanInternals.templates.{template_type}."{selector}"') + return (flake_ref, f'clanInternals.templates.{template_type}."{selector}"') # TODO: implement support for quotes in the tail "a.b".c # If the tail contains a dot, or is quoted we assume its a path and don't transform it. diff --git a/pkgs/clan-cli/clan_lib/templates/template_url_test.py b/pkgs/clan-cli/clan_lib/templates/template_url_test.py index ecef1c88d..9ec74805b 100644 --- a/pkgs/clan-cli/clan_lib/templates/template_url_test.py +++ b/pkgs/clan-cli/clan_lib/templates/template_url_test.py @@ -20,8 +20,10 @@ def test_transform_url_self_explizit_dot() -> None: user_input = ".#new-machine" expected_selector = 'clan.templates.machine."new-machine"' - flake_ref, selector = transform_url(template_type, user_input) - assert flake_ref == "." + flake_ref, selector = transform_url( + template_type, user_input, local_path=local_path + ) + assert flake_ref == str(local_path.path) assert selector == expected_selector @@ -29,8 +31,10 @@ def test_transform_url_self_no_dot() -> None: user_input = "#new-machine" expected_selector = 'clan.templates.machine."new-machine"' - flake_ref, selector = transform_url(template_type, user_input) - assert flake_ref == "" + flake_ref, selector = transform_url( + template_type, user_input, local_path=local_path + ) + assert flake_ref == str(local_path.path) assert selector == expected_selector @@ -38,8 +42,10 @@ def test_transform_url_builtin_template() -> None: user_input = "new-machine" expected_selector = 'clanInternals.templates.machine."new-machine"' - flake_ref, selector = transform_url(template_type, user_input) - assert flake_ref == "" + flake_ref, selector = transform_url( + template_type, user_input, local_path=local_path + ) + assert flake_ref == str(local_path.path) assert selector == expected_selector @@ -47,7 +53,9 @@ def test_transform_url_remote_template() -> None: user_input = "github:/org/repo#new-machine" expected_selector = 'clan.templates.machine."new-machine"' - flake_ref, selector = transform_url(template_type, user_input) + flake_ref, selector = transform_url( + template_type, user_input, local_path=local_path + ) assert flake_ref == "github:/org/repo" assert selector == expected_selector @@ -57,8 +65,10 @@ def test_transform_url_explicit_path() -> None: user_input = ".#clan.templates.machine.new-machine" expected_selector = "clan.templates.machine.new-machine" - flake_ref, selector = transform_url(template_type, user_input) - assert flake_ref == "." + flake_ref, selector = transform_url( + template_type, user_input, local_path=local_path + ) + assert flake_ref == str(local_path.path) assert selector == expected_selector @@ -66,16 +76,20 @@ def test_transform_url_explicit_path() -> None: def test_transform_url_quoted_selector() -> None: user_input = '.#"new.machine"' expected_selector = '"new.machine"' - flake_ref, selector = transform_url(template_type, user_input) - assert flake_ref == "." + flake_ref, selector = transform_url( + template_type, user_input, local_path=local_path + ) + assert flake_ref == str(local_path.path) assert selector == expected_selector def test_single_quote_selector() -> None: user_input = ".#'new.machine'" expected_selector = "'new.machine'" - flake_ref, selector = transform_url(template_type, user_input) - assert flake_ref == "." + flake_ref, selector = transform_url( + template_type, user_input, local_path=local_path + ) + assert flake_ref == str(local_path.path) assert selector == expected_selector @@ -83,7 +97,9 @@ def test_custom_template_path() -> None: user_input = "github:/org/repo#my.templates.custom.machine" expected_selector = "my.templates.custom.machine" - flake_ref, selector = transform_url(template_type, user_input) + flake_ref, selector = transform_url( + template_type, user_input, local_path=local_path + ) assert flake_ref == "github:/org/repo" assert selector == expected_selector @@ -93,7 +109,9 @@ def test_full_url_query_and_fragment() -> None: expected_flake_ref = "github:/org/repo?query=param" expected_selector = "my.templates.custom.machine" - flake_ref, selector = transform_url(template_type, user_input) + flake_ref, selector = transform_url( + template_type, user_input, local_path=local_path + ) assert flake_ref == expected_flake_ref assert selector == expected_selector @@ -102,15 +120,17 @@ def test_custom_template_type() -> None: user_input = "#my.templates.custom.machine" expected_selector = "my.templates.custom.machine" - flake_ref, selector = transform_url("custom", user_input) - assert flake_ref == "" + flake_ref, selector = transform_url("custom", user_input, local_path=local_path) + assert flake_ref == str(local_path.path) assert selector == expected_selector def test_malformed_identifier() -> None: user_input = "github:/org/repo#my.templates.custom.machine#extra" with pytest.raises(ClanError) as exc_info: - _flake_ref, _selector = transform_url(template_type, user_input) + _flake_ref, _selector = transform_url( + template_type, user_input, local_path=local_path + ) assert isinstance(exc_info.value, ClanError) assert ( From b80395af444b585723ded53ebe31d18801fac08c Mon Sep 17 00:00:00 2001 From: Johannes Kirschbauer Date: Wed, 11 Jun 2025 15:57:46 +0200 Subject: [PATCH 05/13] Chore(machine/templates): simplify template args for machines command --- pkgs/clan-cli/clan_cli/machines/create.py | 150 +++++--------------- pkgs/clan-cli/clan_cli/machines/morph.py | 8 +- pkgs/clan-cli/clan_lib/templates/handler.py | 13 +- 3 files changed, 51 insertions(+), 120 deletions(-) diff --git a/pkgs/clan-cli/clan_cli/machines/create.py b/pkgs/clan-cli/clan_cli/machines/create.py index 3ee7a5233..b2f523034 100644 --- a/pkgs/clan-cli/clan_cli/machines/create.py +++ b/pkgs/clan-cli/clan_cli/machines/create.py @@ -2,7 +2,6 @@ import argparse import logging import re from dataclasses import dataclass -from pathlib import Path from clan_lib.api import API from clan_lib.dirs import get_clan_flake_toplevel_or_env @@ -13,15 +12,9 @@ from clan_lib.nix_models.clan import InventoryMachine from clan_lib.nix_models.clan import InventoryMachineDeploy as MachineDeploy from clan_lib.persist.inventory_store import InventoryStore from clan_lib.persist.util import set_value_by_path -from clan_lib.templates import ( - InputPrio, - TemplateName, - get_template, -) -from clan_lib.templates.filesystem import copy_from_nixstore +from clan_lib.templates.handler import machine_template from clan_cli.completions import add_dynamic_completer, complete_tags -from clan_cli.machines.list import list_full_machines log = logging.getLogger(__name__) @@ -30,9 +23,8 @@ log = logging.getLogger(__name__) class CreateOptions: clan_dir: Flake machine: InventoryMachine + template: str = "new-machine" target_host: str | None = None - input_prio: InputPrio | None = None - template_name: str | None = None @API.register @@ -54,42 +46,12 @@ def create_machine( description = "Import machine only works on local clans" raise ClanError(msg, description=description) - if not opts.template_name: - opts.template_name = "new-machine" - clan_dir = opts.clan_dir.path - # TODO(@Qubasa): make this a proper template handler - # i.e. with_template (use context manager) - # And move the checks and template handling into the template handler - template = get_template( - TemplateName(opts.template_name), - "machine", - input_prio=opts.input_prio, - clan_dir=opts.clan_dir, - ) - log.info(f"Found template '{template.name}' in '{template.input_variant}'") - machine_name = opts.machine.get("name") - if opts.template_name in list_full_machines( - Flake(str(clan_dir)) - ) and not opts.machine.get("name"): - msg = f"{opts.template_name} is already defined in {clan_dir}" - raise ClanError(msg) - - machine_name = machine_name if machine_name else opts.template_name - src = Path(template.src["path"]) - - if not src.exists(): - msg = f"Template {template} does not exist" - raise ClanError(msg) - if not src.is_dir(): - msg = f"Template {template} is not a directory" - raise ClanError(msg) - - dst = clan_dir / "machines" - dst.mkdir(exist_ok=True) - dst /= machine_name + if not machine_name: + msg = "Machine name is required" + raise ClanError(msg, location="Create Machine") # TODO: Move this into nix code hostname_regex = r"^(?!-)[A-Za-z0-9-]{1,63}(? None: @@ -159,25 +109,15 @@ def create_command(args: argparse.Namespace) -> None: ) raise ClanError(msg, description=description) - if len(args.input) == 0: - args.input = ["clan", "clan-core"] - - if args.no_self: - input_prio = InputPrio.try_inputs(tuple(args.input)) - else: - input_prio = InputPrio.try_self_then_inputs(tuple(args.input)) - machine = InventoryMachine( name=args.machine_name, tags=args.tags, deploy=MachineDeploy(targetHost=args.target_host), ) opts = CreateOptions( - input_prio=input_prio, clan_dir=clan_dir, machine=machine, - template_name=args.template_name, - target_host=args.target_host, + template=args.template, ) create_machine(opts) @@ -196,29 +136,15 @@ def register_create_parser(parser: argparse.ArgumentParser) -> None: help="Tags to associate with the machine. Can be used to assign multiple machines to services.", ) add_dynamic_completer(tag_parser, complete_tags) - parser.add_argument( - "--template-name", - type=str, - help="The name of the template machine to import", - ) parser.add_argument( "--target-host", type=str, help="Address of the machine to install and update, in the format of user@host:1234", ) parser.add_argument( - "--input", + "-t", + "--template", type=str, - help="""Flake input name to use as template source - can be specified multiple times, inputs are tried in order of definition - Example: --input clan --input clan-core - """, - action="append", - default=[], - ) - parser.add_argument( - "--no-self", - help="Do not look into own flake for templates", - action="store_true", - default=False, + help="Reference to the template to use for the machine. In the format '#template_name'", + default="new-machine", ) diff --git a/pkgs/clan-cli/clan_cli/machines/morph.py b/pkgs/clan-cli/clan_cli/machines/morph.py index 6dbf7f669..2357b57d2 100644 --- a/pkgs/clan-cli/clan_cli/machines/morph.py +++ b/pkgs/clan-cli/clan_cli/machines/morph.py @@ -41,7 +41,7 @@ def random_hostname() -> str: def morph_machine( - flake: Flake, template_name: str, ask_confirmation: bool, name: str | None = None + flake: Flake, template: str, ask_confirmation: bool, name: str | None = None ) -> None: cmd = nix_command( [ @@ -70,7 +70,7 @@ def morph_machine( name = random_hostname() create_opts = CreateOptions( - template_name=template_name, + template=template, machine=InventoryMachine(name=name), clan_dir=Flake(str(flakedir)), ) @@ -149,7 +149,7 @@ def morph_command(args: argparse.Namespace) -> None: morph_machine( flake=Flake(str(args.flake)), - template_name=args.template_name, + template=args.template, ask_confirmation=args.confirm_firing, name=args.name, ) @@ -159,7 +159,7 @@ def register_morph_parser(parser: argparse.ArgumentParser) -> None: parser.set_defaults(func=morph_command) parser.add_argument( - "template_name", + "template", default="new-machine", type=str, help="The name of the template to use", diff --git a/pkgs/clan-cli/clan_lib/templates/handler.py b/pkgs/clan-cli/clan_lib/templates/handler.py index cecc887c3..c8189e2ef 100644 --- a/pkgs/clan-cli/clan_lib/templates/handler.py +++ b/pkgs/clan-cli/clan_lib/templates/handler.py @@ -15,7 +15,7 @@ log = logging.getLogger(__name__) @contextmanager -def with_machine_template( +def machine_template( flake: Flake, template_ident: str, dst_machine_name: str ) -> Iterator[Path]: """ @@ -28,7 +28,7 @@ def with_machine_template( Example usage: - >>> with with_machine_template( + >>> with machine_template( ... Flake("/home/johannes/git/clan-core"), ".#new-machine", "my-machine" ... ) as machine_path: ... # Use `machine_path` here if you want to access the created machine directory @@ -46,7 +46,10 @@ def with_machine_template( ) # Get the clan template from the specifier - [flake_ref, template_selector] = transform_url("machine", template_ident, local_path=flake) + [flake_ref, template_selector] = transform_url( + "machine", template_ident, local_path=flake + ) + template_flake = Flake(flake_ref) template = template_flake.select(template_selector) @@ -82,12 +85,14 @@ def with_machine_template( dst_machine_dir = specific_machine_dir(tmp_machine) + dst_machine_dir.mkdir(exist_ok=True, parents=True) + copy_from_nixstore(src_path, dst_machine_dir) try: yield dst_machine_dir except Exception as e: - log.error(f"An error occurred inside the 'with_machine_template' context: {e}") + log.error(f"An error occurred inside the 'machine_template' context: {e}") # Ensure that the directory is removed to avoid half-created machines # Everything in the with block is considered part of the context From b6a0c6cb38fba615f8eecc43a562b9694a7a5591 Mon Sep 17 00:00:00 2001 From: Johannes Kirschbauer Date: Wed, 11 Jun 2025 16:05:50 +0200 Subject: [PATCH 06/13] Docs(cli/machines): improve description of '--template' --- pkgs/clan-cli/clan_cli/machines/create.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/pkgs/clan-cli/clan_cli/machines/create.py b/pkgs/clan-cli/clan_cli/machines/create.py index b2f523034..34b34875e 100644 --- a/pkgs/clan-cli/clan_cli/machines/create.py +++ b/pkgs/clan-cli/clan_cli/machines/create.py @@ -145,6 +145,8 @@ def register_create_parser(parser: argparse.ArgumentParser) -> None: "-t", "--template", type=str, - help="Reference to the template to use for the machine. In the format '#template_name'", + help="""Reference to the template to use for the machine. In the format '#template_name' Where is a flake reference (e.g. github:org/repo) or a local path (e.g. '.' ). + Omitting '#' will use the pre-provided templates (e.g. just 'new-machine' (default) ). + """, default="new-machine", ) From e80a3fd2fcf7d5095c0cd3969f9e50dc20740277 Mon Sep 17 00:00:00 2001 From: Johannes Kirschbauer Date: Wed, 11 Jun 2025 16:50:44 +0200 Subject: [PATCH 07/13] fix(tess/morph): skip creating existing machine --- pkgs/clan-cli/clan_cli/machines/create.py | 42 ++++++++++++----------- pkgs/clan-cli/clan_cli/machines/morph.py | 14 ++++---- 2 files changed, 30 insertions(+), 26 deletions(-) diff --git a/pkgs/clan-cli/clan_cli/machines/create.py b/pkgs/clan-cli/clan_cli/machines/create.py index 34b34875e..118565d3f 100644 --- a/pkgs/clan-cli/clan_cli/machines/create.py +++ b/pkgs/clan-cli/clan_cli/machines/create.py @@ -29,7 +29,8 @@ class CreateOptions: @API.register def create_machine( - opts: CreateOptions, commit: bool = True, _persist: bool = True + opts: CreateOptions, + commit: bool = True, ) -> None: """ Create a new machine in the clan directory. @@ -60,30 +61,31 @@ def create_machine( raise ClanError(msg, location="Create Machine") with machine_template( - flake=opts.clan_dir, template_ident=opts.template, dst_machine_name=machine_name + flake=opts.clan_dir, + template_ident=opts.template, + dst_machine_name=machine_name, ) as _machine_dir: # Write to the inventory if persist is true - if _persist: - target_host = opts.target_host - new_machine = opts.machine - new_machine["deploy"] = {"targetHost": target_host} # type: ignore + target_host = opts.target_host + new_machine = opts.machine + new_machine["deploy"] = {"targetHost": target_host} # type: ignore - inventory_store = InventoryStore(opts.clan_dir) - inventory = inventory_store.read() + inventory_store = InventoryStore(opts.clan_dir) + inventory = inventory_store.read() - if machine_name in inventory.get("machines", {}): - msg = f"Machine {machine_name} already exists in inventory" - description = ( - "Please delete the existing machine or import with a different name" - ) - raise ClanError(msg, description=description) - - set_value_by_path( - inventory, - f"machines.{machine_name}", - new_machine, + if machine_name in inventory.get("machines", {}): + msg = f"Machine {machine_name} already exists in inventory" + description = ( + "Please delete the existing machine or import with a different name" ) - inventory_store.write(inventory, message=f"machine '{machine_name}'") + raise ClanError(msg, description=description) + + set_value_by_path( + inventory, + f"machines.{machine_name}", + new_machine, + ) + inventory_store.write(inventory, message=f"machine '{machine_name}'") if commit: commit_file( diff --git a/pkgs/clan-cli/clan_cli/machines/morph.py b/pkgs/clan-cli/clan_cli/machines/morph.py index 2357b57d2..65e195827 100644 --- a/pkgs/clan-cli/clan_cli/machines/morph.py +++ b/pkgs/clan-cli/clan_cli/machines/morph.py @@ -11,6 +11,7 @@ from clan_lib.cmd import Log, RunOpts, run from clan_lib.dirs import get_clan_flake_toplevel_or_env from clan_lib.errors import ClanError from clan_lib.flake import Flake +from clan_lib.machines.actions import list_machines from clan_lib.machines.machines import Machine from clan_lib.nix import nix_build, nix_command from clan_lib.nix_models.clan import InventoryMachine @@ -69,12 +70,13 @@ def morph_machine( if name is None: name = random_hostname() - create_opts = CreateOptions( - template=template, - machine=InventoryMachine(name=name), - clan_dir=Flake(str(flakedir)), - ) - create_machine(create_opts, commit=False, _persist=False) + if name not in list_machines(flake): + create_opts = CreateOptions( + template=template, + machine=InventoryMachine(name=name), + clan_dir=Flake(str(flakedir)), + ) + create_machine(create_opts, commit=False) machine = Machine(name=name, flake=Flake(str(flakedir))) From acc41be9b3f1b7a1d872be757f529dcbbc690b6a Mon Sep 17 00:00:00 2001 From: Johannes Kirschbauer Date: Wed, 11 Jun 2025 16:51:15 +0200 Subject: [PATCH 08/13] doc(cli/templates): improve help description --- pkgs/clan-cli/clan_cli/machines/create.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/pkgs/clan-cli/clan_cli/machines/create.py b/pkgs/clan-cli/clan_cli/machines/create.py index 118565d3f..481938fa7 100644 --- a/pkgs/clan-cli/clan_cli/machines/create.py +++ b/pkgs/clan-cli/clan_cli/machines/create.py @@ -147,8 +147,8 @@ def register_create_parser(parser: argparse.ArgumentParser) -> None: "-t", "--template", type=str, - help="""Reference to the template to use for the machine. In the format '#template_name' Where is a flake reference (e.g. github:org/repo) or a local path (e.g. '.' ). - Omitting '#' will use the pre-provided templates (e.g. just 'new-machine' (default) ). + help="""Reference to the template to use for the machine. default="new-machine". In the format '#template_name' Where is a flake reference (e.g. github:org/repo) or a local path (e.g. '.' ). + Omitting '#' will use the builtin templates (e.g. just 'new-machine' from clan-core ). """, default="new-machine", ) From 0e88b0ff667469ca67f830b25dc7a8031860ab1e Mon Sep 17 00:00:00 2001 From: Johannes Kirschbauer Date: Wed, 11 Jun 2025 20:05:39 +0200 Subject: [PATCH 09/13] feat(flake/select): add apply argument --- pkgs/clan-cli/clan_lib/flake/flake.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/pkgs/clan-cli/clan_lib/flake/flake.py b/pkgs/clan-cli/clan_lib/flake/flake.py index d8184e258..85ae81e65 100644 --- a/pkgs/clan-cli/clan_lib/flake/flake.py +++ b/pkgs/clan-cli/clan_lib/flake/flake.py @@ -680,6 +680,7 @@ class Flake: self, selectors: list[str], nix_options: list[str] | None = None, + apply: str = "v: v", ) -> None: """ Retrieves specific attributes from a Nix flake using the provided selectors. @@ -754,7 +755,7 @@ class Flake: result = builtins.toJSON [ {" ".join( [ - f"(selectLib.applySelectors (builtins.fromJSON ''{attr}'') flake)" + f"(({apply}) (selectLib.applySelectors (builtins.fromJSON ''{attr}'') flake))" for attr in str_selectors ] )} @@ -823,6 +824,7 @@ class Flake: self, selector: str, nix_options: list[str] | None = None, + apply: str = "v: v", ) -> Any: """ Selects a value from the cache based on the provided selector string. @@ -839,6 +841,6 @@ class Flake: if not self._cache.is_cached(selector): log.debug(f"Cache miss for {selector}") - self.get_from_nix([selector], nix_options) + self.get_from_nix([selector], nix_options, apply=apply) value = self._cache.select(selector) return value From 2d2af10c78ae505154fdac6865706affb5189ee4 Mon Sep 17 00:00:00 2001 From: Johannes Kirschbauer Date: Wed, 11 Jun 2025 20:05:59 +0200 Subject: [PATCH 10/13] feat(flake): add function to get input names --- pkgs/clan-cli/clan_lib/flake/flake.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/pkgs/clan-cli/clan_lib/flake/flake.py b/pkgs/clan-cli/clan_lib/flake/flake.py index 85ae81e65..8be0ff0a0 100644 --- a/pkgs/clan-cli/clan_lib/flake/flake.py +++ b/pkgs/clan-cli/clan_lib/flake/flake.py @@ -598,6 +598,9 @@ class Flake: assert isinstance(self._is_local, bool) return self._is_local + def get_input_names(self) -> list[str]: + return self.select("inputs", apply="builtins.attrNames") + @property def path(self) -> Path: if self._path is None: From 70bc7d3f0c56462d7aa897df09bf0ecb64cf55d9 Mon Sep 17 00:00:00 2001 From: Johannes Kirschbauer Date: Wed, 11 Jun 2025 20:06:31 +0200 Subject: [PATCH 11/13] feat(templates_urls): short circuit input names --- pkgs/clan-cli/clan_lib/templates/handler.py | 2 +- .../clan_lib/templates/template_url.py | 25 +++++-- .../clan_lib/templates/template_url_test.py | 70 +++++++++++-------- 3 files changed, 60 insertions(+), 37 deletions(-) diff --git a/pkgs/clan-cli/clan_lib/templates/handler.py b/pkgs/clan-cli/clan_lib/templates/handler.py index c8189e2ef..deee2b39f 100644 --- a/pkgs/clan-cli/clan_lib/templates/handler.py +++ b/pkgs/clan-cli/clan_lib/templates/handler.py @@ -47,7 +47,7 @@ def machine_template( # Get the clan template from the specifier [flake_ref, template_selector] = transform_url( - "machine", template_ident, local_path=flake + "machine", template_ident, flake=flake ) template_flake = Flake(flake_ref) diff --git a/pkgs/clan-cli/clan_lib/templates/template_url.py b/pkgs/clan-cli/clan_lib/templates/template_url.py index 0f4586f27..d99e2f328 100644 --- a/pkgs/clan-cli/clan_lib/templates/template_url.py +++ b/pkgs/clan-cli/clan_lib/templates/template_url.py @@ -16,10 +16,10 @@ class Flake(Protocol): @property def path(self) -> Path: ... + def get_input_names(self) -> list[str]: ... -def transform_url( - template_type: str, identifier: str, local_path: Flake -) -> tuple[str, str]: + +def transform_url(template_type: str, identifier: str, flake: Flake) -> tuple[str, str]: """ Transform a template flake ref by injecting the context (clan|machine|disko) into the url. We do this for shorthand notation of URLs. @@ -56,6 +56,11 @@ def transform_url( clan machines create --template github:/org/repo#new.machine -> clanInternals.templates.machine."new.machine" + 6. Templates locked via inputs: + clan machines create --template clan-core#new-machine + path: clan-core matches one of the input attributes. + -> #inputs.clan-core.clan.templates.machine."new-machine" + As of URL specification (RFC 3986). scheme:[//[user:password@]host[:port]][/path][?query][#fragment] @@ -76,22 +81,28 @@ def transform_url( # Substitute the flake reference with the local flake path if it is empty or just a dot. # This is required if the command will be executed from a different place, than the local flake root. if not flake_ref or flake_ref == ".": - flake_ref = str(local_path.path) + flake_ref = str(flake.path) if "#" not in identifier: # No fragment, so we assume its a builtin template return (flake_ref, f'clanInternals.templates.{template_type}."{selector}"') + input_prefix = "" + if flake_ref in flake.get_input_names(): + # Interpret the flake reference as an input of the local flake. + input_prefix = f"inputs.{flake_ref}." + flake_ref = str(flake.path) + # TODO: implement support for quotes in the tail "a.b".c # If the tail contains a dot, or is quoted we assume its a path and don't transform it. if '"' in selector or "'" in selector: log.warning( "Quotes in template paths are not yet supported. Please use unquoted paths." ) - return (flake_ref, selector) + return (flake_ref, input_prefix + selector) if "." in selector: - return (flake_ref, selector) + return (flake_ref, input_prefix + selector) # Tail doesn't contain a dot at this point, so we can inject the context. - return (flake_ref, f'clan.templates.{template_type}."{selector}"') + return (flake_ref, input_prefix + f'clan.templates.{template_type}."{selector}"') diff --git a/pkgs/clan-cli/clan_lib/templates/template_url_test.py b/pkgs/clan-cli/clan_lib/templates/template_url_test.py index 9ec74805b..3193f0447 100644 --- a/pkgs/clan-cli/clan_lib/templates/template_url_test.py +++ b/pkgs/clan-cli/clan_lib/templates/template_url_test.py @@ -12,6 +12,9 @@ class DummyFlake: def __init__(self, path: str) -> None: self.path: Path = Path(path) + def get_input_names(self) -> list[str]: + return ["locked-input"] + local_path = DummyFlake(".") @@ -20,9 +23,7 @@ def test_transform_url_self_explizit_dot() -> None: user_input = ".#new-machine" expected_selector = 'clan.templates.machine."new-machine"' - flake_ref, selector = transform_url( - template_type, user_input, local_path=local_path - ) + flake_ref, selector = transform_url(template_type, user_input, flake=local_path) assert flake_ref == str(local_path.path) assert selector == expected_selector @@ -31,9 +32,7 @@ def test_transform_url_self_no_dot() -> None: user_input = "#new-machine" expected_selector = 'clan.templates.machine."new-machine"' - flake_ref, selector = transform_url( - template_type, user_input, local_path=local_path - ) + flake_ref, selector = transform_url(template_type, user_input, flake=local_path) assert flake_ref == str(local_path.path) assert selector == expected_selector @@ -42,9 +41,7 @@ def test_transform_url_builtin_template() -> None: user_input = "new-machine" expected_selector = 'clanInternals.templates.machine."new-machine"' - flake_ref, selector = transform_url( - template_type, user_input, local_path=local_path - ) + flake_ref, selector = transform_url(template_type, user_input, flake=local_path) assert flake_ref == str(local_path.path) assert selector == expected_selector @@ -53,9 +50,7 @@ def test_transform_url_remote_template() -> None: user_input = "github:/org/repo#new-machine" expected_selector = 'clan.templates.machine."new-machine"' - flake_ref, selector = transform_url( - template_type, user_input, local_path=local_path - ) + flake_ref, selector = transform_url(template_type, user_input, flake=local_path) assert flake_ref == "github:/org/repo" assert selector == expected_selector @@ -65,9 +60,7 @@ def test_transform_url_explicit_path() -> None: user_input = ".#clan.templates.machine.new-machine" expected_selector = "clan.templates.machine.new-machine" - flake_ref, selector = transform_url( - template_type, user_input, local_path=local_path - ) + flake_ref, selector = transform_url(template_type, user_input, flake=local_path) assert flake_ref == str(local_path.path) assert selector == expected_selector @@ -76,9 +69,7 @@ def test_transform_url_explicit_path() -> None: def test_transform_url_quoted_selector() -> None: user_input = '.#"new.machine"' expected_selector = '"new.machine"' - flake_ref, selector = transform_url( - template_type, user_input, local_path=local_path - ) + flake_ref, selector = transform_url(template_type, user_input, flake=local_path) assert flake_ref == str(local_path.path) assert selector == expected_selector @@ -86,9 +77,7 @@ def test_transform_url_quoted_selector() -> None: def test_single_quote_selector() -> None: user_input = ".#'new.machine'" expected_selector = "'new.machine'" - flake_ref, selector = transform_url( - template_type, user_input, local_path=local_path - ) + flake_ref, selector = transform_url(template_type, user_input, flake=local_path) assert flake_ref == str(local_path.path) assert selector == expected_selector @@ -97,9 +86,7 @@ def test_custom_template_path() -> None: user_input = "github:/org/repo#my.templates.custom.machine" expected_selector = "my.templates.custom.machine" - flake_ref, selector = transform_url( - template_type, user_input, local_path=local_path - ) + flake_ref, selector = transform_url(template_type, user_input, flake=local_path) assert flake_ref == "github:/org/repo" assert selector == expected_selector @@ -109,9 +96,7 @@ def test_full_url_query_and_fragment() -> None: expected_flake_ref = "github:/org/repo?query=param" expected_selector = "my.templates.custom.machine" - flake_ref, selector = transform_url( - template_type, user_input, local_path=local_path - ) + flake_ref, selector = transform_url(template_type, user_input, flake=local_path) assert flake_ref == expected_flake_ref assert selector == expected_selector @@ -120,7 +105,7 @@ def test_custom_template_type() -> None: user_input = "#my.templates.custom.machine" expected_selector = "my.templates.custom.machine" - flake_ref, selector = transform_url("custom", user_input, local_path=local_path) + flake_ref, selector = transform_url("custom", user_input, flake=local_path) assert flake_ref == str(local_path.path) assert selector == expected_selector @@ -129,7 +114,7 @@ def test_malformed_identifier() -> None: user_input = "github:/org/repo#my.templates.custom.machine#extra" with pytest.raises(ClanError) as exc_info: _flake_ref, _selector = transform_url( - template_type, user_input, local_path=local_path + template_type, user_input, flake=local_path ) assert isinstance(exc_info.value, ClanError) @@ -137,3 +122,30 @@ def test_malformed_identifier() -> None: str(exc_info.value) == "Invalid template identifier: More than one '#' found. Please use a single '#'" ) + + +def test_locked_input_template() -> None: + user_input = "locked-input#new-machine" + expected_selector = 'inputs.locked-input.clan.templates.machine."new-machine"' + + flake_ref, selector = transform_url(template_type, user_input, flake=local_path) + assert flake_ref == str(local_path.path) + assert selector == expected_selector + + +def test_locked_input_template_no_quotes() -> None: + user_input = 'locked-input#"new.machine"' + expected_selector = 'inputs.locked-input."new.machine"' + + flake_ref, selector = transform_url(template_type, user_input, flake=local_path) + assert selector == expected_selector + assert flake_ref == str(local_path.path) + + +def test_locked_input_template_no_dot() -> None: + user_input = "locked-input#new.machine" + expected_selector = "inputs.locked-input.new.machine" + + flake_ref, selector = transform_url(template_type, user_input, flake=local_path) + assert selector == expected_selector + assert flake_ref == str(local_path.path) From 8c02119ac006ee9715c513d3ea0306d19511f8b9 Mon Sep 17 00:00:00 2001 From: Johannes Kirschbauer Date: Wed, 11 Jun 2025 20:21:49 +0200 Subject: [PATCH 12/13] fix(templates): add error handling --- pkgs/clan-cli/clan_lib/templates/handler.py | 13 ++++++++----- 1 file changed, 8 insertions(+), 5 deletions(-) diff --git a/pkgs/clan-cli/clan_lib/templates/handler.py b/pkgs/clan-cli/clan_lib/templates/handler.py index deee2b39f..72d28a627 100644 --- a/pkgs/clan-cli/clan_lib/templates/handler.py +++ b/pkgs/clan-cli/clan_lib/templates/handler.py @@ -49,12 +49,15 @@ def machine_template( [flake_ref, template_selector] = transform_url( "machine", template_ident, flake=flake ) + # For pretty error messages + printable_template_ref = f"{flake_ref}#{template_selector}" template_flake = Flake(flake_ref) - template = template_flake.select(template_selector) - - # For pretty error messages - printable_template_ref = f"{flake_ref}#{template_ident}" + try: + template = template_flake.select(template_selector) + except ClanError as e: + msg = f"Failed to select template '{template_ident}' from flake '{flake_ref}' (via attribute path: {printable_template_ref})" + raise ClanError(msg) from e src = template.get("path") if not src: @@ -85,7 +88,7 @@ def machine_template( dst_machine_dir = specific_machine_dir(tmp_machine) - dst_machine_dir.mkdir(exist_ok=True, parents=True) + dst_machine_dir.parent.mkdir(exist_ok=True, parents=True) copy_from_nixstore(src_path, dst_machine_dir) From 0b6c30e8add1dcd05619fc970d6a5637ad33c950 Mon Sep 17 00:00:00 2001 From: Johannes Kirschbauer Date: Wed, 11 Jun 2025 20:38:15 +0200 Subject: [PATCH 13/13] Fix(morph): ensure machine dir exists --- pkgs/clan-cli/clan_cli/machines/morph.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/pkgs/clan-cli/clan_cli/machines/morph.py b/pkgs/clan-cli/clan_cli/machines/morph.py index 65e195827..3e1e8e1ef 100644 --- a/pkgs/clan-cli/clan_cli/machines/morph.py +++ b/pkgs/clan-cli/clan_cli/machines/morph.py @@ -8,7 +8,7 @@ from pathlib import Path from tempfile import TemporaryDirectory from clan_lib.cmd import Log, RunOpts, run -from clan_lib.dirs import get_clan_flake_toplevel_or_env +from clan_lib.dirs import get_clan_flake_toplevel_or_env, specific_machine_dir from clan_lib.errors import ClanError from clan_lib.flake import Flake from clan_lib.machines.actions import list_machines @@ -91,9 +91,9 @@ def morph_machine( # facter_json = run(["nixos-facter"]).stdout # run(["cp", "facter.json", f"{flakedir}/machines/{name}/facter.json"]).stdout - Path(f"{flakedir}/machines/{name}/facter.json").write_text( - '{"system": "x86_64-linux"}' - ) + machine_dir = specific_machine_dir(machine) + machine_dir.mkdir(parents=True, exist_ok=True) + Path(f"{machine_dir}/facter.json").write_text('{"system": "x86_64-linux"}') result_path = run( nix_build( [f"{flakedir}#nixosConfigurations.{name}.config.system.build.toplevel"]