Files
clan-core/pkgs/clan-cli/clan_cli/machines/install.py

313 lines
9.6 KiB
Python

import argparse
import logging
import os
import sys
from dataclasses import dataclass, field
from enum import Enum
from pathlib import Path
from tempfile import TemporaryDirectory
from clan_lib.api import API
from clan_lib.cmd import Log, RunOpts, run
from clan_lib.errors import ClanError
from clan_lib.machines.machines import Machine
from clan_lib.nix import nix_shell
from clan_lib.ssh.remote import HostKeyCheck, Remote
from clan_cli.completions import (
add_dynamic_completer,
complete_machines,
complete_target_host,
)
from clan_cli.facts.generate import generate_facts
from clan_cli.machines.hardware import HardwareConfig
from clan_cli.ssh.deploy_info import DeployInfo, find_reachable_host, ssh_command_parse
from clan_cli.vars.generate import generate_vars
log = logging.getLogger(__name__)
class BuildOn(Enum):
AUTO = "auto"
LOCAL = "local"
REMOTE = "remote"
@dataclass
class InstallOptions:
machine: Machine
kexec: str | None = None
debug: bool = False
no_reboot: bool = False
phases: str | None = None
build_on: BuildOn | None = None
nix_options: list[str] = field(default_factory=list)
update_hardware_config: HardwareConfig = HardwareConfig.NONE
password: str | None = None
identity_file: Path | None = None
use_tor: bool = False
@API.register
def install_machine(opts: InstallOptions, target_host: Remote) -> None:
machine = opts.machine
machine.debug(f"installing {machine.name}")
generate_facts([machine])
generate_vars([machine])
with (
TemporaryDirectory(prefix="nixos-install-") as _base_directory,
):
base_directory = Path(_base_directory).resolve()
activation_secrets = base_directory / "activation_secrets"
upload_dir = activation_secrets / machine.secrets_upload_directory.lstrip("/")
upload_dir.mkdir(parents=True)
machine.secret_facts_store.upload(upload_dir)
machine.secret_vars_store.populate_dir(
upload_dir, phases=["activation", "users", "services"]
)
partitioning_secrets = base_directory / "partitioning_secrets"
partitioning_secrets.mkdir(parents=True)
machine.secret_vars_store.populate_dir(
partitioning_secrets, phases=["partitioning"]
)
if opts.password:
os.environ["SSHPASS"] = opts.password
cmd = [
"nixos-anywhere",
"--flake",
f"{machine.flake}#{machine.name}",
"--extra-files",
str(activation_secrets),
]
for path in partitioning_secrets.rglob("*"):
if path.is_file():
cmd.extend(
[
"--disk-encryption-keys",
str(
"/run/partitioning-secrets"
/ path.relative_to(partitioning_secrets)
),
str(path),
]
)
if opts.no_reboot:
cmd.append("--no-reboot")
if opts.phases:
cmd += ["--phases", str(opts.phases)]
if opts.update_hardware_config is not HardwareConfig.NONE:
cmd.extend(
[
"--generate-hardware-config",
str(opts.update_hardware_config.value),
str(opts.update_hardware_config.config_path(machine)),
]
)
if opts.password:
cmd += [
"--env-password",
"--ssh-option",
"IdentitiesOnly=yes",
]
if opts.identity_file:
cmd += ["-i", str(opts.identity_file)]
if opts.build_on:
cmd += ["--build-on", opts.build_on.value]
if target_host.port:
cmd += ["--ssh-port", str(target_host.port)]
if opts.kexec:
cmd += ["--kexec", opts.kexec]
if opts.debug:
cmd.append("--debug")
# Add nix options to nixos-anywhere
cmd.extend(opts.nix_options)
cmd.append(target_host.target)
if opts.use_tor:
# nix copy does not support tor socks proxy
# cmd.append("--ssh-option")
# cmd.append("ProxyCommand=nc -x 127.0.0.1:9050 -X 5 %h %p")
cmd = nix_shell(
[
"nixos-anywhere",
"tor",
],
["torify", *cmd],
)
else:
cmd = nix_shell(
["nixos-anywhere"],
cmd,
)
run(cmd, RunOpts(log=Log.BOTH, prefix=machine.name, needs_user_terminal=True))
def install_command(args: argparse.Namespace) -> None:
try:
# Only if the caller did not specify a target_host via args.target_host
# Find a suitable target_host that is reachable
target_host_str = args.target_host
deploy_info: DeployInfo | None = ssh_command_parse(args)
use_tor = False
if deploy_info and not args.target_host:
host = find_reachable_host(deploy_info)
if host is None:
use_tor = True
target_host_str = deploy_info.tor.target
else:
target_host_str = host.target
if args.password:
password = args.password
elif deploy_info and deploy_info.addrs[0].password:
password = deploy_info.addrs[0].password
else:
password = None
machine = Machine(name=args.machine, flake=args.flake, nix_options=args.option)
host_key_check = (
HostKeyCheck.from_str(args.host_key_check)
if args.host_key_check
else HostKeyCheck.ASK
)
if target_host_str is not None:
target_host = Remote.from_deployment_address(
machine_name=machine.name,
address=target_host_str,
host_key_check=host_key_check,
)
else:
target_host = machine.target_host().override(host_key_check=host_key_check)
if machine._class_ == "darwin":
msg = "Installing macOS machines is not yet supported"
raise ClanError(msg)
if args.flake is None:
msg = "Could not find clan flake toplevel directory"
raise ClanError(msg)
if not args.yes:
ask = input(f"Install {args.machine} to {target_host.target}? [y/N] ")
if ask != "y":
return None
return install_machine(
InstallOptions(
machine=machine,
kexec=args.kexec,
phases=args.phases,
debug=args.debug,
no_reboot=args.no_reboot,
nix_options=args.option,
build_on=BuildOn(args.build_on) if args.build_on is not None else None,
update_hardware_config=HardwareConfig(args.update_hardware_config),
password=password,
identity_file=args.identity_file,
use_tor=use_tor,
),
target_host=target_host,
)
except KeyboardInterrupt:
log.warning("Interrupted by user")
sys.exit(1)
def register_install_parser(parser: argparse.ArgumentParser) -> None:
parser.add_argument(
"--kexec",
type=str,
help="use another kexec tarball to bootstrap NixOS",
)
parser.add_argument(
"--no-reboot",
action="store_true",
help="do not reboot after installation (deprecated)",
default=False,
)
parser.add_argument(
"--host-key-check",
choices=["strict", "ask", "tofu", "none"],
default="ask",
help="Host key (.ssh/known_hosts) check mode.",
)
parser.add_argument(
"--build-on",
choices=[x.value for x in BuildOn],
default=None,
help="where to build the NixOS configuration",
)
parser.add_argument(
"--yes",
action="store_true",
help="do not ask for confirmation",
default=False,
)
parser.add_argument(
"--update-hardware-config",
type=str,
default="none",
help="update the hardware configuration",
choices=[x.value for x in HardwareConfig],
)
parser.add_argument(
"--phases",
type=str,
help="comma separated list of phases to run. Default is: kexec,disko,install,reboot",
)
machines_parser = parser.add_argument(
"machine",
type=str,
help="machine to install",
)
add_dynamic_completer(machines_parser, complete_machines)
group = parser.add_mutually_exclusive_group(required=False)
group.add_argument(
"-j",
"--json",
help="specify the json file for ssh data (generated by starting the clan installer)",
)
target_host_parser = group.add_argument(
"--target-host",
help="ssh address to install to in the form of user@host:2222",
)
add_dynamic_completer(target_host_parser, complete_target_host)
authentication_group = parser.add_mutually_exclusive_group()
authentication_group.add_argument(
"--password",
help="specify the password for the ssh connection (generated by starting the clan installer)",
)
authentication_group.add_argument(
"-i",
dest="identity_file",
type=Path,
help="specify which SSH private key file to use",
)
group.add_argument(
"-P",
"--png",
help="specify the json file for ssh data as the qrcode image (generated by starting the clan installer)",
)
parser.set_defaults(func=install_command)