Files
clan-core/pkgs/clan-cli/clan_cli/machines/create.py
Johannes Kirschbauer 665b2095b2 Refactor(cli/list_machines): rename to list_full_machines
This makes it clear that this should be used with care
It is potentially more expensive to create the full object, therefore it should be discouraged by its longer name
This listing is implemented based on the basic listing, where each item is turned into the bigger machine class
2025-06-09 13:40:57 +02:00

225 lines
7.2 KiB
Python

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
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 import (
InputPrio,
TemplateName,
copy_from_nixstore,
get_template,
)
from clan_cli.completions import add_dynamic_completer, complete_tags
from clan_cli.machines.list import list_full_machines
log = logging.getLogger(__name__)
@dataclass
class CreateOptions:
clan_dir: Flake
machine: InventoryMachine
target_host: str | None = None
input_prio: InputPrio | None = None
template_name: str | None = None
@API.register
def create_machine(
opts: CreateOptions, commit: bool = True, _persist: 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)
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
# TODO: Move this into nix code
hostname_regex = r"^(?!-)[A-Za-z0-9-]{1,63}(?<!-)$"
if not re.match(hostname_regex, machine_name):
msg = "Machine name must be a valid hostname"
raise ClanError(msg, location="Create Machine")
if dst.exists():
msg = f"Machine {machine_name} already exists in {clan_dir}"
description = "Please remove the existing machine folder"
raise ClanError(msg, description=description)
# TODO(@Qubasa): move this into the template handler
if not (src / "configuration.nix").exists():
msg = f"Template machine '{opts.template_name}' does not contain a configuration.nix"
description = "Template machine must contain a configuration.nix"
raise ClanError(msg, description=description)
# TODO(@Qubasa): move this into the template handler
copy_from_nixstore(src, dst)
if _persist:
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()
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,
)
inventory_store.write(inventory, message=f"machine '{machine_name}'")
opts.clan_dir.invalidate_cache()
# Commit at the end in that order to avoid committing halve-baked machines
# TODO: automatic rollbacks if something goes wrong
if commit:
commit_file(
clan_dir / "machines" / machine_name,
repo_dir=clan_dir,
commit_message=f"Add machine {machine_name}",
)
def create_command(args: argparse.Namespace) -> 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)
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,
)
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(
"--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",
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,
)