import argparse import logging import re from dataclasses import dataclass from typing import TypeVar, cast from clan_lib.api import API 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.git import commit_file 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.handler import machine_template from clan_cli.completions import add_dynamic_completer, complete_tags log = logging.getLogger(__name__) @dataclass class CreateOptions: clan_dir: Flake machine: InventoryMachine template: str = "new-machine" target_host: str | None = None T = TypeVar("T") def merge_objects(obj1: T, obj2: T) -> T: """ Updates values in obj2 by values of Obj1 The output contains values for all keys of Obj1 and Obj2 together Lists are deduplicated and appended almost like in the nix module system. """ result = {} msg = f"cannot update non-dictionary values: {obj2} by {obj1}" if not isinstance(obj1, dict): raise ClanError(msg) if not isinstance(obj2, dict): raise ClanError(msg) all_keys = set(obj1.keys()).union(obj2.keys()) for key in all_keys: val1 = obj1.get(key) val2 = obj2.get(key) if isinstance(val1, dict) and isinstance(val2, dict): result[key] = merge_objects(val1, val2) elif isinstance(val1, list) and isinstance(val2, list): result[key] = list(dict.fromkeys(val2 + val1)) # type: ignore elif key in obj1: result[key] = val1 # type: ignore elif key in obj2: result[key] = val2 # type: ignore return cast(T, result) @API.register def create_machine( opts: CreateOptions, commit: bool = True, ) -> None: """ Create a new machine in the clan directory. This function will create a new machine based on a template. :param opts: Options for creating the machine, including clan directory, machine details, and template name. :param commit: Whether to commit the changes to the git repository. :param _persist: Temporary workaround for 'morph'. Whether to persist the changes to the inventory store. """ if not opts.clan_dir.is_local: msg = f"Clan {opts.clan_dir} is not a local clan." description = "Import machine only works on local clans" raise ClanError(msg, description=description) clan_dir = opts.clan_dir.path machine_name = opts.machine.get("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: if args.flake: clan_dir = args.flake else: tmp = get_clan_flake_toplevel_or_env() clan_dir = Flake(str(tmp)) if tmp else None if not clan_dir: msg = "No clan found." description = ( "Run this command in a clan directory or specify the --flake option" ) raise ClanError(msg, description=description) machine = InventoryMachine( name=args.machine_name, tags=args.tags, deploy=MachineDeploy(targetHost=args.target_host), ) opts = CreateOptions( clan_dir=clan_dir, machine=machine, template=args.template, ) create_machine(opts) def register_create_parser(parser: argparse.ArgumentParser) -> None: parser.set_defaults(func=create_command) parser.add_argument( "machine_name", type=str, help="The name of the machine to create", ) tag_parser = parser.add_argument( "--tags", nargs="+", default=[], 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( "--target-host", type=str, help="Address of the machine to install and update, in the format of user@host:1234", ) parser.add_argument( "-t", "--template", type=str, 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", )