From b01691cb64fdbe1523bd9bb67fec6e2a1cbe2748 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=C3=B6rg=20Thalheim?= Date: Sun, 4 May 2025 16:11:26 +0200 Subject: [PATCH] bind ssh controlmaster to live time of CLI --- pkgs/clan-cli/clan_cli/backups/create.py | 24 +++++----- pkgs/clan-cli/clan_cli/backups/list.py | 16 ++++--- pkgs/clan-cli/clan_cli/backups/restore.py | 32 +++++++------ .../clan_cli/facts/secret_modules/__init__.py | 3 +- .../facts/secret_modules/password_store.py | 5 +- .../clan_cli/facts/secret_modules/sops.py | 3 +- .../clan_cli/facts/secret_modules/vm.py | 2 + pkgs/clan-cli/clan_cli/facts/upload.py | 11 +++-- pkgs/clan-cli/clan_cli/flash/flash.py | 4 +- pkgs/clan-cli/clan_cli/machines/hardware.py | 34 ++++++------- pkgs/clan-cli/clan_cli/machines/install.py | 35 ++++++-------- pkgs/clan-cli/clan_cli/machines/machines.py | 20 ++++---- pkgs/clan-cli/clan_cli/machines/update.py | 48 ++++++++++--------- pkgs/clan-cli/clan_cli/ssh/host.py | 43 ++++++++--------- pkgs/clan-cli/clan_cli/vars/_types.py | 3 +- .../clan_cli/vars/public_modules/in_repo.py | 3 +- .../clan_cli/vars/public_modules/vm.py | 3 +- .../clan_cli/vars/secret_modules/fs.py | 3 +- .../vars/secret_modules/password_store.py | 11 +++-- .../clan_cli/vars/secret_modules/sops.py | 7 +-- .../clan_cli/vars/secret_modules/vm.py | 3 +- pkgs/clan-cli/clan_cli/vars/upload.py | 22 +++++---- pkgs/clan-cli/clan_cli/vms/run.py | 4 +- .../app/src/components/MachineListItem.tsx | 2 +- .../app/src/routes/machines/details.tsx | 2 +- 25 files changed, 184 insertions(+), 159 deletions(-) diff --git a/pkgs/clan-cli/clan_cli/backups/create.py b/pkgs/clan-cli/clan_cli/backups/create.py index 612d3cf45..48b9ae721 100644 --- a/pkgs/clan-cli/clan_cli/backups/create.py +++ b/pkgs/clan-cli/clan_cli/backups/create.py @@ -19,21 +19,23 @@ def create_backup(machine: Machine, provider: str | None = None) -> None: if not backup_scripts["providers"]: msg = "No providers specified" raise ClanError(msg) - for provider in backup_scripts["providers"]: - proc = machine.target_host.run( - [backup_scripts["providers"][provider]["create"]], - ) - if proc.returncode != 0: - msg = "failed to start backup" - raise ClanError(msg) - print("successfully started backup") + with machine.target_host() as host: + for provider in backup_scripts["providers"]: + proc = host.run( + [backup_scripts["providers"][provider]["create"]], + ) + if proc.returncode != 0: + msg = "failed to start backup" + raise ClanError(msg) + print("successfully started backup") else: if provider not in backup_scripts["providers"]: msg = f"provider {provider} not found" raise ClanError(msg) - proc = machine.target_host.run( - [backup_scripts["providers"][provider]["create"]], - ) + with machine.target_host() as host: + proc = host.run( + [backup_scripts["providers"][provider]["create"]], + ) if proc.returncode != 0: msg = "failed to start backup" raise ClanError(msg) diff --git a/pkgs/clan-cli/clan_cli/backups/list.py b/pkgs/clan-cli/clan_cli/backups/list.py index af2c4ba46..5d684ab99 100644 --- a/pkgs/clan-cli/clan_cli/backups/list.py +++ b/pkgs/clan-cli/clan_cli/backups/list.py @@ -10,6 +10,7 @@ from clan_cli.completions import ( ) from clan_cli.errors import ClanError from clan_cli.machines.machines import Machine +from clan_cli.ssh.host import Host @dataclass @@ -18,11 +19,11 @@ class Backup: job_name: str | None = None -def list_provider(machine: Machine, provider: str) -> list[Backup]: +def list_provider(machine: Machine, host: Host, provider: str) -> list[Backup]: results = [] backup_metadata = machine.eval_nix("config.clan.core.backups") list_command = backup_metadata["providers"][provider]["list"] - proc = machine.target_host.run( + proc = host.run( [list_command], RunOpts(log=Log.NONE, check=False), ) @@ -48,12 +49,13 @@ def list_provider(machine: Machine, provider: str) -> list[Backup]: def list_backups(machine: Machine, provider: str | None = None) -> list[Backup]: backup_metadata = machine.eval_nix("config.clan.core.backups") results = [] - if provider is None: - for _provider in backup_metadata["providers"]: - results += list_provider(machine, _provider) + with machine.target_host() as host: + if provider is None: + for _provider in backup_metadata["providers"]: + results += list_provider(machine, host, _provider) - else: - results += list_provider(machine, provider) + else: + results += list_provider(machine, host, provider) return results diff --git a/pkgs/clan-cli/clan_cli/backups/restore.py b/pkgs/clan-cli/clan_cli/backups/restore.py index d9746b32e..32d53f8c2 100644 --- a/pkgs/clan-cli/clan_cli/backups/restore.py +++ b/pkgs/clan-cli/clan_cli/backups/restore.py @@ -8,9 +8,12 @@ from clan_cli.completions import ( ) from clan_cli.errors import ClanError from clan_cli.machines.machines import Machine +from clan_cli.ssh.host import Host -def restore_service(machine: Machine, name: str, provider: str, service: str) -> None: +def restore_service( + machine: Machine, host: Host, name: str, provider: str, service: str +) -> None: backup_metadata = machine.eval_nix("config.clan.core.backups") backup_folders = machine.eval_nix("config.clan.core.state") @@ -25,7 +28,7 @@ def restore_service(machine: Machine, name: str, provider: str, service: str) -> env["FOLDERS"] = ":".join(set(folders)) if pre_restore := backup_folders[service]["preRestoreCommand"]: - proc = machine.target_host.run( + proc = host.run( [pre_restore], RunOpts(log=Log.STDERR), extra_env=env, @@ -34,7 +37,7 @@ def restore_service(machine: Machine, name: str, provider: str, service: str) -> msg = f"failed to run preRestoreCommand: {pre_restore}, error was: {proc.stdout}" raise ClanError(msg) - proc = machine.target_host.run( + proc = host.run( [backup_metadata["providers"][provider]["restore"]], RunOpts(log=Log.STDERR), extra_env=env, @@ -44,7 +47,7 @@ def restore_service(machine: Machine, name: str, provider: str, service: str) -> raise ClanError(msg) if post_restore := backup_folders[service]["postRestoreCommand"]: - proc = machine.target_host.run( + proc = host.run( [post_restore], RunOpts(log=Log.STDERR), extra_env=env, @@ -61,18 +64,19 @@ def restore_backup( service: str | None = None, ) -> None: errors = [] - if service is None: - backup_folders = machine.eval_nix("config.clan.core.state") - for _service in backup_folders: + with machine.target_host() as host: + if service is None: + backup_folders = machine.eval_nix("config.clan.core.state") + for _service in backup_folders: + try: + restore_service(machine, host, name, provider, _service) + except ClanError as e: + errors.append(f"{_service}: {e}") + else: try: - restore_service(machine, name, provider, _service) + restore_service(machine, host, name, provider, service) except ClanError as e: - errors.append(f"{_service}: {e}") - else: - try: - restore_service(machine, name, provider, service) - except ClanError as e: - errors.append(f"{service}: {e}") + errors.append(f"{service}: {e}") if errors: raise ClanError( "Restore failed for the following services:\n" + "\n".join(errors) diff --git a/pkgs/clan-cli/clan_cli/facts/secret_modules/__init__.py b/pkgs/clan-cli/clan_cli/facts/secret_modules/__init__.py index 6850153bf..a099eb8bc 100644 --- a/pkgs/clan-cli/clan_cli/facts/secret_modules/__init__.py +++ b/pkgs/clan-cli/clan_cli/facts/secret_modules/__init__.py @@ -4,6 +4,7 @@ from abc import ABC, abstractmethod from pathlib import Path import clan_cli.machines.machines as machines +from clan_cli.ssh.host import Host class SecretStoreBase(ABC): @@ -25,7 +26,7 @@ class SecretStoreBase(ABC): def exists(self, service: str, name: str) -> bool: pass - def needs_upload(self) -> bool: + def needs_upload(self, host: Host) -> bool: return True @abstractmethod diff --git a/pkgs/clan-cli/clan_cli/facts/secret_modules/password_store.py b/pkgs/clan-cli/clan_cli/facts/secret_modules/password_store.py index ca7caaadf..876358753 100644 --- a/pkgs/clan-cli/clan_cli/facts/secret_modules/password_store.py +++ b/pkgs/clan-cli/clan_cli/facts/secret_modules/password_store.py @@ -6,6 +6,7 @@ from typing import override from clan_cli.cmd import Log, RunOpts from clan_cli.machines.machines import Machine from clan_cli.nix import nix_shell +from clan_cli.ssh.host import Host from . import SecretStoreBase @@ -93,9 +94,9 @@ class SecretStore(SecretStoreBase): return b"\n".join(hashes) @override - def needs_upload(self) -> bool: + def needs_upload(self, host: Host) -> bool: local_hash = self.generate_hash() - remote_hash = self.machine.target_host.run( + remote_hash = host.run( # TODO get the path to the secrets from the machine ["cat", f"{self.machine.secrets_upload_directory}/.pass_info"], RunOpts(log=Log.STDERR, check=False), diff --git a/pkgs/clan-cli/clan_cli/facts/secret_modules/sops.py b/pkgs/clan-cli/clan_cli/facts/secret_modules/sops.py index 746948895..dfe17305d 100644 --- a/pkgs/clan-cli/clan_cli/facts/secret_modules/sops.py +++ b/pkgs/clan-cli/clan_cli/facts/secret_modules/sops.py @@ -6,6 +6,7 @@ from clan_cli.secrets.folders import sops_secrets_folder from clan_cli.secrets.machines import add_machine, has_machine from clan_cli.secrets.secrets import decrypt_secret, encrypt_secret, has_secret from clan_cli.secrets.sops import generate_private_key +from clan_cli.ssh.host import Host from . import SecretStoreBase @@ -60,7 +61,7 @@ class SecretStore(SecretStoreBase): ) @override - def needs_upload(self) -> bool: + def needs_upload(self, host: Host) -> bool: return False # We rely now on the vars backend to upload the age key diff --git a/pkgs/clan-cli/clan_cli/facts/secret_modules/vm.py b/pkgs/clan-cli/clan_cli/facts/secret_modules/vm.py index 5e0f4892a..29209536e 100644 --- a/pkgs/clan-cli/clan_cli/facts/secret_modules/vm.py +++ b/pkgs/clan-cli/clan_cli/facts/secret_modules/vm.py @@ -1,5 +1,6 @@ import shutil from pathlib import Path +from typing import override from clan_cli.dirs import vm_state_dir from clan_cli.machines.machines import Machine @@ -28,6 +29,7 @@ class SecretStore(SecretStoreBase): def exists(self, service: str, name: str) -> bool: return (self.dir / service / name).exists() + @override def upload(self, output_dir: Path) -> None: if output_dir.exists(): shutil.rmtree(output_dir) diff --git a/pkgs/clan-cli/clan_cli/facts/upload.py b/pkgs/clan-cli/clan_cli/facts/upload.py index 63f347c73..317f260ba 100644 --- a/pkgs/clan-cli/clan_cli/facts/upload.py +++ b/pkgs/clan-cli/clan_cli/facts/upload.py @@ -5,13 +5,14 @@ from tempfile import TemporaryDirectory from clan_cli.completions import add_dynamic_completer, complete_machines from clan_cli.machines.machines import Machine +from clan_cli.ssh.host import Host from clan_cli.ssh.upload import upload log = logging.getLogger(__name__) -def upload_secrets(machine: Machine) -> None: - if not machine.secret_facts_store.needs_upload(): +def upload_secrets(machine: Machine, host: Host) -> None: + if not machine.secret_facts_store.needs_upload(host): machine.info("Secrets already uploaded") return @@ -19,13 +20,13 @@ def upload_secrets(machine: Machine) -> None: local_secret_dir = Path(_tempdir).resolve() machine.secret_facts_store.upload(local_secret_dir) remote_secret_dir = Path(machine.secrets_upload_directory) - - upload(machine.target_host, local_secret_dir, remote_secret_dir) + upload(host, local_secret_dir, remote_secret_dir) def upload_command(args: argparse.Namespace) -> None: machine = Machine(name=args.machine, flake=args.flake) - upload_secrets(machine) + with machine.target_host() as host: + upload_secrets(machine, host) def register_upload_parser(parser: argparse.ArgumentParser) -> None: diff --git a/pkgs/clan-cli/clan_cli/flash/flash.py b/pkgs/clan-cli/clan_cli/flash/flash.py index 92949f2b3..36021fb97 100644 --- a/pkgs/clan-cli/clan_cli/flash/flash.py +++ b/pkgs/clan-cli/clan_cli/flash/flash.py @@ -14,7 +14,7 @@ from clan_cli.facts.generate import generate_facts from clan_cli.machines.machines import Machine from clan_cli.nix import nix_shell from clan_cli.vars.generate import generate_vars -from clan_cli.vars.upload import upload_secret_vars +from clan_cli.vars.upload import populate_secret_vars from .automount import pause_automounting from .list import list_possible_keymaps, list_possible_languages @@ -107,7 +107,7 @@ def flash_machine( local_dir.mkdir(parents=True) machine.secret_facts_store.upload(local_dir) - upload_secret_vars(machine, local_dir) + populate_secret_vars(machine, local_dir) disko_install = [] if os.geteuid() != 0: diff --git a/pkgs/clan-cli/clan_cli/machines/hardware.py b/pkgs/clan-cli/clan_cli/machines/hardware.py index 535b24b86..3d0b9709c 100644 --- a/pkgs/clan-cli/clan_cli/machines/hardware.py +++ b/pkgs/clan-cli/clan_cli/machines/hardware.py @@ -139,26 +139,26 @@ def generate_machine_hardware_info(opts: HardwareGenerateOptions) -> HardwareCon "--show-hardware-config", ] - host = machine.target_host - host.ssh_options["StrictHostKeyChecking"] = "accept-new" - host.ssh_options["UserKnownHostsFile"] = "/dev/null" - if opts.password: - host.password = opts.password + with machine.target_host() as host: + host.ssh_options["StrictHostKeyChecking"] = "accept-new" + host.ssh_options["UserKnownHostsFile"] = "/dev/null" + if opts.password: + host.password = opts.password - out = host.run(config_command, become_root=True, opts=RunOpts(check=False)) - if out.returncode != 0: - if "nixos-facter" in out.stderr and "not found" in out.stderr: - machine.error(str(out.stderr)) - msg = ( - "Please use our custom nixos install images from https://github.com/nix-community/nixos-images/releases/tag/nixos-unstable. " - "nixos-factor only works on nixos / clan systems currently." - ) + out = host.run(config_command, become_root=True, opts=RunOpts(check=False)) + if out.returncode != 0: + if "nixos-facter" in out.stderr and "not found" in out.stderr: + machine.error(str(out.stderr)) + msg = ( + "Please use our custom nixos install images from https://github.com/nix-community/nixos-images/releases/tag/nixos-unstable. " + "nixos-factor only works on nixos / clan systems currently." + ) + raise ClanError(msg) + + machine.error(str(out)) + msg = f"Failed to inspect {opts.machine}. Address: {host.target}" raise ClanError(msg) - machine.error(str(out)) - msg = f"Failed to inspect {opts.machine}. Address: {host.target}" - raise ClanError(msg) - backup_file = None if hw_file.exists(): backup_file = hw_file.with_suffix(".bak") diff --git a/pkgs/clan-cli/clan_cli/machines/install.py b/pkgs/clan-cli/clan_cli/machines/install.py index d58ed287d..e05bfa767 100644 --- a/pkgs/clan-cli/clan_cli/machines/install.py +++ b/pkgs/clan-cli/clan_cli/machines/install.py @@ -36,7 +36,6 @@ class BuildOn(Enum): @dataclass class InstallOptions: machine: Machine - target_host: str kexec: str | None = None debug: bool = False no_reboot: bool = False @@ -52,17 +51,16 @@ class InstallOptions: @API.register def install_machine(opts: InstallOptions) -> None: machine = opts.machine - machine.override_target_host = opts.target_host - machine.info(f"installing {machine.name}") - - h = machine.target_host - machine.info(f"target host: {h.target}") + machine.debug(f"installing {machine.name}") generate_facts([machine]) generate_vars([machine]) - with TemporaryDirectory(prefix="nixos-install-") as _base_directory: + with ( + TemporaryDirectory(prefix="nixos-install-") as _base_directory, + machine.target_host() as host, + ): base_directory = Path(_base_directory).resolve() activation_secrets = base_directory / "activation_secrets" upload_dir = activation_secrets / machine.secrets_upload_directory.lstrip("/") @@ -134,14 +132,14 @@ def install_machine(opts: InstallOptions) -> None: if opts.build_on: cmd += ["--build-on", opts.build_on.value] - if h.port: - cmd += ["--ssh-port", str(h.port)] + if host.port: + cmd += ["--ssh-port", str(host.port)] if opts.kexec: cmd += ["--kexec", opts.kexec] if opts.debug: cmd.append("--debug") - cmd.append(h.target) + cmd.append(host.target) if opts.use_tor: # nix copy does not support tor socks proxy # cmd.append("--ssh-option") @@ -178,17 +176,15 @@ def install_command(args: argparse.Namespace) -> None: deploy_info: DeployInfo | None = ssh_command_parse(args) if args.target_host: - target_host = args.target_host + machine.override_target_host = args.target_host elif deploy_info: host = find_reachable_host(deploy_info, host_key_check) if host is None: use_tor = True - target_host = f"root@{deploy_info.tor}" + machine.override_target_host = f"root@{deploy_info.tor}" else: - target_host = host.target + machine.override_target_host = host.target password = deploy_info.pwd - else: - target_host = machine.target_host.target if args.password: password = args.password @@ -197,19 +193,16 @@ def install_command(args: argparse.Namespace) -> None: else: password = None - if not target_host: - msg = "No target host provided, please provide a target host." - raise ClanError(msg) - if not args.yes: - ask = input(f"Install {args.machine} to {target_host}? [y/N] ") + ask = input( + f"Install {args.machine} to {machine.target_host_address}? [y/N] " + ) if ask != "y": return None return install_machine( InstallOptions( machine=machine, - target_host=target_host, kexec=args.kexec, phases=args.phases, debug=args.debug, diff --git a/pkgs/clan-cli/clan_cli/machines/machines.py b/pkgs/clan-cli/clan_cli/machines/machines.py index 76366e923..cc370b46b 100644 --- a/pkgs/clan-cli/clan_cli/machines/machines.py +++ b/pkgs/clan-cli/clan_cli/machines/machines.py @@ -2,6 +2,8 @@ import importlib import json import logging import re +from collections.abc import Iterator +from contextlib import contextmanager from dataclasses import dataclass, field from functools import cached_property from pathlib import Path @@ -145,9 +147,9 @@ class Machine: def flake_dir(self) -> Path: return self.flake.path - @property - def target_host(self) -> Host: - return parse_deployment_address( + @contextmanager + def target_host(self) -> Iterator[Host]: + yield parse_deployment_address( self.name, self.target_host_address, self.host_key_check, @@ -155,23 +157,25 @@ class Machine: meta={"machine": self}, ) - @property - def build_host(self) -> Host: + @contextmanager + def build_host(self) -> Iterator[Host | None]: """ The host where the machine is built and deployed from. Can be the same as the target host. """ build_host = self.override_build_host or self.deployment.get("buildHost") if build_host is None: - return self.target_host + with self.target_host() as target_host: + yield target_host + return # enable ssh agent forwarding to allow the build host to access the target host - return parse_deployment_address( + yield parse_deployment_address( self.name, build_host, self.host_key_check, forward_agent=True, private_key=self.private_key, - meta={"machine": self, "target_host": self.target_host}, + meta={"machine": self}, ) @cached_property diff --git a/pkgs/clan-cli/clan_cli/machines/update.py b/pkgs/clan-cli/clan_cli/machines/update.py index 4ab543f5e..5f1096732 100644 --- a/pkgs/clan-cli/clan_cli/machines/update.py +++ b/pkgs/clan-cli/clan_cli/machines/update.py @@ -5,6 +5,7 @@ import os import re import shlex import sys +from contextlib import ExitStack from clan_lib.api import API @@ -43,8 +44,7 @@ def is_local_input(node: dict[str, dict[str, str]]) -> bool: ) -def upload_sources(machine: Machine) -> str: - host = machine.build_host +def upload_sources(machine: Machine, host: Host) -> str: env = host.nix_ssh_env(os.environ.copy()) flake_url = ( @@ -126,22 +126,25 @@ def update_machines(base_path: str, machines: list[InventoryMachine]) -> None: deploy_machines(group_machines) -def deploy_machines(machines: list[Machine]) -> None: - """ - Deploy to all hosts in parallel - """ +def deploy_machine(machine: Machine) -> None: + with ExitStack() as stack: + target_host = stack.enter_context(machine.target_host()) + build_host = stack.enter_context(machine.build_host()) + + if machine._class_ == "darwin": + if not machine.deploy_as_root and target_host.user == "root": + msg = f"'targetHost' should be set to a non-root user for deploying to nix-darwin on machine '{machine.name}'" + raise ClanError(msg) + + host = build_host or target_host - def deploy(machine: Machine) -> None: - host = machine.build_host generate_facts([machine], service=None, regenerate=False) generate_vars([machine], generator_name=None, regenerate=False) - upload_secrets(machine) - upload_secret_vars(machine) + upload_secrets(machine, target_host) + upload_secret_vars(machine, target_host) - path = upload_sources( - machine=machine, - ) + path = upload_sources(machine, host) nix_options = [ "--show-trace", @@ -166,10 +169,9 @@ def deploy_machines(machines: list[Machine]) -> None: "", ] - target_host: Host | None = host.meta.get("target_host") - if target_host: + if build_host: become_root = False - nix_options += ["--target-host", target_host.target] + nix_options += ["--target-host", build_host.target] if target_host.user != "root": nix_options += ["--use-remote-sudo"] @@ -211,19 +213,19 @@ def deploy_machines(machines: list[Machine]) -> None: become_root=become_root, ) + +def deploy_machines(machines: list[Machine]) -> None: + """ + Deploy to all hosts in parallel + """ + with AsyncRuntime() as runtime: for machine in machines: - if machine._class_ == "darwin": - if not machine.deploy_as_root and machine.target_host.user == "root": - msg = f"'targetHost' should be set to a non-root user for deploying to nix-darwin on machine '{machine.name}'" - raise ClanError(msg) - - machine.info(f"Updating {machine.name}") runtime.async_run( AsyncOpts( tid=machine.name, async_ctx=AsyncContext(prefix=machine.name) ), - deploy, + deploy_machine, machine, ) runtime.join_all() diff --git a/pkgs/clan-cli/clan_cli/ssh/host.py b/pkgs/clan-cli/clan_cli/ssh/host.py index 97f6a619c..0b8d617e9 100644 --- a/pkgs/clan-cli/clan_cli/ssh/host.py +++ b/pkgs/clan-cli/clan_cli/ssh/host.py @@ -1,15 +1,15 @@ # Adapted from https://github.com/numtide/deploykit -import errno import logging import os import shlex import socket -import stat import subprocess +import types from dataclasses import dataclass, field from pathlib import Path from shlex import quote +from tempfile import TemporaryDirectory from typing import Any from clan_cli.cmd import CmdOut, RunOpts, run @@ -40,35 +40,34 @@ class Host: ssh_options: dict[str, str] = field(default_factory=dict) tor_socks: bool = False - def setup_control_master(self) -> None: - home = Path.home() - if not home.exists(): - return - control_path = home / ".ssh" - try: - if not stat.S_ISDIR(control_path.stat().st_mode): - return - except OSError as e: - if e.errno == errno.ENOENT: - try: - control_path.mkdir(exist_ok=True) - except OSError: - return - else: - return + _temp_dir: TemporaryDirectory | None = None + def setup_control_master(self, control_path: Path) -> None: self.ssh_options["ControlMaster"] = "auto" - # Can we make this a temporary directory? self.ssh_options["ControlPath"] = str(control_path / "clan-%h-%p-%r") - # We use a short ttl because we want to mainly re-use the connection during the cli run - self.ssh_options["ControlPersist"] = "1m" + self.ssh_options["ControlPersist"] = "30m" + + def __enter__(self) -> None: + self._temp_dir = TemporaryDirectory(prefix="clan-ssh-") + self.setup_control_master(Path(self._temp_dir.name)) + + def __exit__( + self, + exc_type: type[BaseException] | None, + exc_value: BaseException | None, + traceback: types.TracebackType | None, + ) -> None: + try: + if self._temp_dir: + self._temp_dir.cleanup() + except OSError: + pass def __post_init__(self) -> None: if not self.command_prefix: self.command_prefix = self.host if not self.user: self.user = "root" - self.setup_control_master() def __str__(self) -> str: return self.target diff --git a/pkgs/clan-cli/clan_cli/vars/_types.py b/pkgs/clan-cli/clan_cli/vars/_types.py index 6f0c82a21..798af0ac9 100644 --- a/pkgs/clan-cli/clan_cli/vars/_types.py +++ b/pkgs/clan-cli/clan_cli/vars/_types.py @@ -7,6 +7,7 @@ from typing import TYPE_CHECKING from clan_cli.errors import ClanError from clan_cli.machines import machines +from clan_cli.ssh.host import Host if TYPE_CHECKING: from .generate import Generator, Var @@ -183,5 +184,5 @@ class StoreBase(ABC): pass @abstractmethod - def upload(self, phases: list[str]) -> None: + def upload(self, host: Host, phases: list[str]) -> None: pass diff --git a/pkgs/clan-cli/clan_cli/vars/public_modules/in_repo.py b/pkgs/clan-cli/clan_cli/vars/public_modules/in_repo.py index 566d84dec..65ce06e2d 100644 --- a/pkgs/clan-cli/clan_cli/vars/public_modules/in_repo.py +++ b/pkgs/clan-cli/clan_cli/vars/public_modules/in_repo.py @@ -4,6 +4,7 @@ from pathlib import Path from clan_cli.errors import ClanError from clan_cli.machines.machines import Machine +from clan_cli.ssh.host import Host from clan_cli.vars._types import StoreBase from clan_cli.vars.generate import Generator, Var @@ -72,6 +73,6 @@ class FactStore(StoreBase): msg = "populate_dir is not implemented for public vars stores" raise NotImplementedError(msg) - def upload(self, phases: list[str]) -> None: + def upload(self, host: Host, phases: list[str]) -> None: msg = "upload is not implemented for public vars stores" raise NotImplementedError(msg) diff --git a/pkgs/clan-cli/clan_cli/vars/public_modules/vm.py b/pkgs/clan-cli/clan_cli/vars/public_modules/vm.py index a2719c8fb..01eeecd73 100644 --- a/pkgs/clan-cli/clan_cli/vars/public_modules/vm.py +++ b/pkgs/clan-cli/clan_cli/vars/public_modules/vm.py @@ -6,6 +6,7 @@ from pathlib import Path from clan_cli.dirs import vm_state_dir from clan_cli.errors import ClanError from clan_cli.machines.machines import Machine +from clan_cli.ssh.host import Host from clan_cli.vars._types import StoreBase from clan_cli.vars.generate import Generator, Var @@ -69,6 +70,6 @@ class FactStore(StoreBase): msg = "populate_dir is not implemented for public vars stores" raise NotImplementedError(msg) - def upload(self, phases: list[str]) -> None: + def upload(self, host: Host, phases: list[str]) -> None: msg = "upload is not implemented for public vars stores" raise NotImplementedError(msg) diff --git a/pkgs/clan-cli/clan_cli/vars/secret_modules/fs.py b/pkgs/clan-cli/clan_cli/vars/secret_modules/fs.py index 198c275ff..b3bcfb7f6 100644 --- a/pkgs/clan-cli/clan_cli/vars/secret_modules/fs.py +++ b/pkgs/clan-cli/clan_cli/vars/secret_modules/fs.py @@ -3,6 +3,7 @@ import tempfile from pathlib import Path from clan_cli.machines.machines import Machine +from clan_cli.ssh.host import Host from clan_cli.vars._types import StoreBase from clan_cli.vars.generate import Generator, Var @@ -45,6 +46,6 @@ class SecretStore(StoreBase): shutil.copytree(self.dir, output_dir) shutil.rmtree(self.dir) - def upload(self, phases: list[str]) -> None: + def upload(self, host: Host, phases: list[str]) -> None: msg = "Cannot upload secrets with FS backend" raise NotImplementedError(msg) diff --git a/pkgs/clan-cli/clan_cli/vars/secret_modules/password_store.py b/pkgs/clan-cli/clan_cli/vars/secret_modules/password_store.py index 67c90a0ec..1cd878942 100644 --- a/pkgs/clan-cli/clan_cli/vars/secret_modules/password_store.py +++ b/pkgs/clan-cli/clan_cli/vars/secret_modules/password_store.py @@ -10,6 +10,7 @@ from tempfile import TemporaryDirectory from clan_cli.cmd import CmdOut, Log, RunOpts, run from clan_cli.machines.machines import Machine from clan_cli.nix import nix_shell +from clan_cli.ssh.host import Host from clan_cli.ssh.upload import upload from clan_cli.vars._types import StoreBase from clan_cli.vars.generate import Generator, Var @@ -146,9 +147,9 @@ class SecretStore(StoreBase): manifest += hashes return b"\n".join(manifest) - def needs_upload(self) -> bool: + def needs_upload(self, host: Host) -> bool: local_hash = self.generate_hash() - remote_hash = self.machine.target_host.run( + remote_hash = host.run( # TODO get the path to the secrets from the machine [ "cat", @@ -224,11 +225,11 @@ class SecretStore(StoreBase): (output_dir / f".{self._store_backend}_info").write_bytes(self.generate_hash()) - def upload(self, phases: list[str]) -> None: + def upload(self, host: Host, phases: list[str]) -> None: if "partitioning" in phases: msg = "Cannot upload partitioning secrets" raise NotImplementedError(msg) - if not self.needs_upload(): + if not self.needs_upload(host): log.info("Secrets already uploaded") return with TemporaryDirectory(prefix="vars-upload-") as _tempdir: @@ -237,4 +238,4 @@ class SecretStore(StoreBase): upload_dir = Path( self.machine.deployment["password-store"]["secretLocation"] ) - upload(self.machine.target_host, pass_dir, upload_dir) + upload(host, pass_dir, upload_dir) diff --git a/pkgs/clan-cli/clan_cli/vars/secret_modules/sops.py b/pkgs/clan-cli/clan_cli/vars/secret_modules/sops.py index bd6f7a37a..4a18b9677 100644 --- a/pkgs/clan-cli/clan_cli/vars/secret_modules/sops.py +++ b/pkgs/clan-cli/clan_cli/vars/secret_modules/sops.py @@ -23,6 +23,7 @@ from clan_cli.secrets.secrets import ( groups_folder, has_secret, ) +from clan_cli.ssh.host import Host from clan_cli.ssh.upload import upload from clan_cli.vars._types import StoreBase from clan_cli.vars.generate import Generator @@ -220,14 +221,15 @@ class SecretStore(StoreBase): target_path.write_bytes(self.get(generator, file.name)) target_path.chmod(file.mode) - def upload(self, phases: list[str]) -> None: + @override + def upload(self, host: Host, phases: list[str]) -> None: if "partitioning" in phases: msg = "Cannot upload partitioning secrets" raise NotImplementedError(msg) with TemporaryDirectory(prefix="sops-upload-") as _tempdir: sops_upload_dir = Path(_tempdir).resolve() self.populate_dir(sops_upload_dir, phases) - upload(self.machine.target_host, sops_upload_dir, Path("/var/lib/sops-nix")) + upload(host, sops_upload_dir, Path("/var/lib/sops-nix")) def exists(self, generator: Generator, name: str) -> bool: secret_folder = self.secret_path(generator, name) @@ -260,7 +262,6 @@ class SecretStore(StoreBase): return keys - # } def needs_fix(self, generator: Generator, name: str) -> tuple[bool, str | None]: secret_path = self.secret_path(generator, name) current_recipients = sops.get_recipients(secret_path) diff --git a/pkgs/clan-cli/clan_cli/vars/secret_modules/vm.py b/pkgs/clan-cli/clan_cli/vars/secret_modules/vm.py index d8c1aa59d..37818fcd5 100644 --- a/pkgs/clan-cli/clan_cli/vars/secret_modules/vm.py +++ b/pkgs/clan-cli/clan_cli/vars/secret_modules/vm.py @@ -4,6 +4,7 @@ from pathlib import Path from clan_cli.dirs import vm_state_dir from clan_cli.machines.machines import Machine +from clan_cli.ssh.host import Host from clan_cli.vars._types import StoreBase from clan_cli.vars.generate import Generator, Var @@ -60,6 +61,6 @@ class SecretStore(StoreBase): shutil.rmtree(output_dir) shutil.copytree(self.dir, output_dir) - def upload(self, phases: list[str]) -> None: + def upload(self, host: Host, phases: list[str]) -> None: msg = "Cannot upload secrets to VMs" raise NotImplementedError(msg) diff --git a/pkgs/clan-cli/clan_cli/vars/upload.py b/pkgs/clan-cli/clan_cli/vars/upload.py index 02fc504e4..03009c953 100644 --- a/pkgs/clan-cli/clan_cli/vars/upload.py +++ b/pkgs/clan-cli/clan_cli/vars/upload.py @@ -4,17 +4,19 @@ from pathlib import Path from clan_cli.completions import add_dynamic_completer, complete_machines from clan_cli.machines.machines import Machine +from clan_cli.ssh.host import Host log = logging.getLogger(__name__) -def upload_secret_vars(machine: Machine, directory: Path | None = None) -> None: - if directory: - machine.secret_vars_store.populate_dir( - directory, phases=["activation", "users", "services"] - ) - else: - machine.secret_vars_store.upload(phases=["activation", "users", "services"]) +def upload_secret_vars(machine: Machine, host: Host) -> None: + machine.secret_vars_store.upload(host, phases=["activation", "users", "services"]) + + +def populate_secret_vars(machine: Machine, directory: Path) -> None: + machine.secret_vars_store.populate_dir( + directory, phases=["activation", "users", "services"] + ) def upload_command(args: argparse.Namespace) -> None: @@ -22,7 +24,11 @@ def upload_command(args: argparse.Namespace) -> None: directory = None if args.directory: directory = Path(args.directory) - upload_secret_vars(machine, directory) + populate_secret_vars(machine, directory) + return + + with machine.target_host() as host: + upload_secret_vars(machine, host) def register_upload_parser(parser: argparse.ArgumentParser) -> None: diff --git a/pkgs/clan-cli/clan_cli/vms/run.py b/pkgs/clan-cli/clan_cli/vms/run.py index 9ed661059..a65886737 100644 --- a/pkgs/clan-cli/clan_cli/vms/run.py +++ b/pkgs/clan-cli/clan_cli/vms/run.py @@ -22,7 +22,7 @@ from clan_cli.nix import nix_shell from clan_cli.qemu.qga import QgaSession from clan_cli.qemu.qmp import QEMUMonitorProtocol from clan_cli.vars.generate import generate_vars -from clan_cli.vars.upload import upload_secret_vars +from clan_cli.vars.upload import populate_secret_vars from .inspect import VmConfig, inspect_vm from .qemu import qemu_command @@ -84,7 +84,7 @@ def get_secrets( generate_vars([machine]) machine.secret_facts_store.upload(secrets_dir) - upload_secret_vars(machine, secrets_dir) + populate_secret_vars(machine, secrets_dir) return secrets_dir diff --git a/pkgs/webview-ui/app/src/components/MachineListItem.tsx b/pkgs/webview-ui/app/src/components/MachineListItem.tsx index 947e7368b..9415dc907 100644 --- a/pkgs/webview-ui/app/src/components/MachineListItem.tsx +++ b/pkgs/webview-ui/app/src/components/MachineListItem.tsx @@ -55,9 +55,9 @@ export const MachineListItem = (props: MachineListItemProps) => { flake: { identifier: active_clan, }, + override_target_host: info?.deploy.targetHost, }, no_reboot: true, - target_host: info?.deploy.targetHost, debug: true, nix_options: [], password: null, diff --git a/pkgs/webview-ui/app/src/routes/machines/details.tsx b/pkgs/webview-ui/app/src/routes/machines/details.tsx index bbd6ef1df..fbbf104e6 100644 --- a/pkgs/webview-ui/app/src/routes/machines/details.tsx +++ b/pkgs/webview-ui/app/src/routes/machines/details.tsx @@ -142,8 +142,8 @@ const InstallMachine = (props: InstallMachineProps) => { flake: { identifier: curr_uri, }, + override_target_host: target, }, - target_host: target, password: "", }, });