Merge pull request 'bind ssh controlmaster to live time of CLI' (#3491) from ssh-refactoring into main

Reviewed-on: https://git.clan.lol/clan/clan-core/pulls/3491
This commit is contained in:
Mic92
2025-05-04 14:45:13 +00:00
25 changed files with 184 additions and 159 deletions

View File

@@ -19,21 +19,23 @@ def create_backup(machine: Machine, provider: str | None = None) -> None:
if not backup_scripts["providers"]: if not backup_scripts["providers"]:
msg = "No providers specified" msg = "No providers specified"
raise ClanError(msg) raise ClanError(msg)
for provider in backup_scripts["providers"]: with machine.target_host() as host:
proc = machine.target_host.run( for provider in backup_scripts["providers"]:
[backup_scripts["providers"][provider]["create"]], proc = host.run(
) [backup_scripts["providers"][provider]["create"]],
if proc.returncode != 0: )
msg = "failed to start backup" if proc.returncode != 0:
raise ClanError(msg) msg = "failed to start backup"
print("successfully started backup") raise ClanError(msg)
print("successfully started backup")
else: else:
if provider not in backup_scripts["providers"]: if provider not in backup_scripts["providers"]:
msg = f"provider {provider} not found" msg = f"provider {provider} not found"
raise ClanError(msg) raise ClanError(msg)
proc = machine.target_host.run( with machine.target_host() as host:
[backup_scripts["providers"][provider]["create"]], proc = host.run(
) [backup_scripts["providers"][provider]["create"]],
)
if proc.returncode != 0: if proc.returncode != 0:
msg = "failed to start backup" msg = "failed to start backup"
raise ClanError(msg) raise ClanError(msg)

View File

@@ -10,6 +10,7 @@ from clan_cli.completions import (
) )
from clan_cli.errors import ClanError from clan_cli.errors import ClanError
from clan_cli.machines.machines import Machine from clan_cli.machines.machines import Machine
from clan_cli.ssh.host import Host
@dataclass @dataclass
@@ -18,11 +19,11 @@ class Backup:
job_name: str | None = None 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 = [] results = []
backup_metadata = machine.eval_nix("config.clan.core.backups") backup_metadata = machine.eval_nix("config.clan.core.backups")
list_command = backup_metadata["providers"][provider]["list"] list_command = backup_metadata["providers"][provider]["list"]
proc = machine.target_host.run( proc = host.run(
[list_command], [list_command],
RunOpts(log=Log.NONE, check=False), 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]: def list_backups(machine: Machine, provider: str | None = None) -> list[Backup]:
backup_metadata = machine.eval_nix("config.clan.core.backups") backup_metadata = machine.eval_nix("config.clan.core.backups")
results = [] results = []
if provider is None: with machine.target_host() as host:
for _provider in backup_metadata["providers"]: if provider is None:
results += list_provider(machine, _provider) for _provider in backup_metadata["providers"]:
results += list_provider(machine, host, _provider)
else: else:
results += list_provider(machine, provider) results += list_provider(machine, host, provider)
return results return results

View File

@@ -8,9 +8,12 @@ from clan_cli.completions import (
) )
from clan_cli.errors import ClanError from clan_cli.errors import ClanError
from clan_cli.machines.machines import Machine 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_metadata = machine.eval_nix("config.clan.core.backups")
backup_folders = machine.eval_nix("config.clan.core.state") 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)) env["FOLDERS"] = ":".join(set(folders))
if pre_restore := backup_folders[service]["preRestoreCommand"]: if pre_restore := backup_folders[service]["preRestoreCommand"]:
proc = machine.target_host.run( proc = host.run(
[pre_restore], [pre_restore],
RunOpts(log=Log.STDERR), RunOpts(log=Log.STDERR),
extra_env=env, 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}" msg = f"failed to run preRestoreCommand: {pre_restore}, error was: {proc.stdout}"
raise ClanError(msg) raise ClanError(msg)
proc = machine.target_host.run( proc = host.run(
[backup_metadata["providers"][provider]["restore"]], [backup_metadata["providers"][provider]["restore"]],
RunOpts(log=Log.STDERR), RunOpts(log=Log.STDERR),
extra_env=env, extra_env=env,
@@ -44,7 +47,7 @@ def restore_service(machine: Machine, name: str, provider: str, service: str) ->
raise ClanError(msg) raise ClanError(msg)
if post_restore := backup_folders[service]["postRestoreCommand"]: if post_restore := backup_folders[service]["postRestoreCommand"]:
proc = machine.target_host.run( proc = host.run(
[post_restore], [post_restore],
RunOpts(log=Log.STDERR), RunOpts(log=Log.STDERR),
extra_env=env, extra_env=env,
@@ -61,18 +64,19 @@ def restore_backup(
service: str | None = None, service: str | None = None,
) -> None: ) -> None:
errors = [] errors = []
if service is None: with machine.target_host() as host:
backup_folders = machine.eval_nix("config.clan.core.state") if service is None:
for _service in backup_folders: 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: try:
restore_service(machine, name, provider, _service) restore_service(machine, host, name, provider, service)
except ClanError as e: except ClanError as e:
errors.append(f"{_service}: {e}") errors.append(f"{service}: {e}")
else:
try:
restore_service(machine, name, provider, service)
except ClanError as e:
errors.append(f"{service}: {e}")
if errors: if errors:
raise ClanError( raise ClanError(
"Restore failed for the following services:\n" + "\n".join(errors) "Restore failed for the following services:\n" + "\n".join(errors)

View File

@@ -4,6 +4,7 @@ from abc import ABC, abstractmethod
from pathlib import Path from pathlib import Path
import clan_cli.machines.machines as machines import clan_cli.machines.machines as machines
from clan_cli.ssh.host import Host
class SecretStoreBase(ABC): class SecretStoreBase(ABC):
@@ -25,7 +26,7 @@ class SecretStoreBase(ABC):
def exists(self, service: str, name: str) -> bool: def exists(self, service: str, name: str) -> bool:
pass pass
def needs_upload(self) -> bool: def needs_upload(self, host: Host) -> bool:
return True return True
@abstractmethod @abstractmethod

View File

@@ -6,6 +6,7 @@ from typing import override
from clan_cli.cmd import Log, RunOpts from clan_cli.cmd import Log, RunOpts
from clan_cli.machines.machines import Machine from clan_cli.machines.machines import Machine
from clan_cli.nix import nix_shell from clan_cli.nix import nix_shell
from clan_cli.ssh.host import Host
from . import SecretStoreBase from . import SecretStoreBase
@@ -93,9 +94,9 @@ class SecretStore(SecretStoreBase):
return b"\n".join(hashes) return b"\n".join(hashes)
@override @override
def needs_upload(self) -> bool: def needs_upload(self, host: Host) -> bool:
local_hash = self.generate_hash() 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 # TODO get the path to the secrets from the machine
["cat", f"{self.machine.secrets_upload_directory}/.pass_info"], ["cat", f"{self.machine.secrets_upload_directory}/.pass_info"],
RunOpts(log=Log.STDERR, check=False), RunOpts(log=Log.STDERR, check=False),

View File

@@ -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.machines import add_machine, has_machine
from clan_cli.secrets.secrets import decrypt_secret, encrypt_secret, has_secret from clan_cli.secrets.secrets import decrypt_secret, encrypt_secret, has_secret
from clan_cli.secrets.sops import generate_private_key from clan_cli.secrets.sops import generate_private_key
from clan_cli.ssh.host import Host
from . import SecretStoreBase from . import SecretStoreBase
@@ -60,7 +61,7 @@ class SecretStore(SecretStoreBase):
) )
@override @override
def needs_upload(self) -> bool: def needs_upload(self, host: Host) -> bool:
return False return False
# We rely now on the vars backend to upload the age key # We rely now on the vars backend to upload the age key

View File

@@ -1,5 +1,6 @@
import shutil import shutil
from pathlib import Path from pathlib import Path
from typing import override
from clan_cli.dirs import vm_state_dir from clan_cli.dirs import vm_state_dir
from clan_cli.machines.machines import Machine from clan_cli.machines.machines import Machine
@@ -28,6 +29,7 @@ class SecretStore(SecretStoreBase):
def exists(self, service: str, name: str) -> bool: def exists(self, service: str, name: str) -> bool:
return (self.dir / service / name).exists() return (self.dir / service / name).exists()
@override
def upload(self, output_dir: Path) -> None: def upload(self, output_dir: Path) -> None:
if output_dir.exists(): if output_dir.exists():
shutil.rmtree(output_dir) shutil.rmtree(output_dir)

View File

@@ -5,13 +5,14 @@ from tempfile import TemporaryDirectory
from clan_cli.completions import add_dynamic_completer, complete_machines from clan_cli.completions import add_dynamic_completer, complete_machines
from clan_cli.machines.machines import Machine from clan_cli.machines.machines import Machine
from clan_cli.ssh.host import Host
from clan_cli.ssh.upload import upload from clan_cli.ssh.upload import upload
log = logging.getLogger(__name__) log = logging.getLogger(__name__)
def upload_secrets(machine: Machine) -> None: def upload_secrets(machine: Machine, host: Host) -> None:
if not machine.secret_facts_store.needs_upload(): if not machine.secret_facts_store.needs_upload(host):
machine.info("Secrets already uploaded") machine.info("Secrets already uploaded")
return return
@@ -19,13 +20,13 @@ def upload_secrets(machine: Machine) -> None:
local_secret_dir = Path(_tempdir).resolve() local_secret_dir = Path(_tempdir).resolve()
machine.secret_facts_store.upload(local_secret_dir) machine.secret_facts_store.upload(local_secret_dir)
remote_secret_dir = Path(machine.secrets_upload_directory) remote_secret_dir = Path(machine.secrets_upload_directory)
upload(host, local_secret_dir, remote_secret_dir)
upload(machine.target_host, local_secret_dir, remote_secret_dir)
def upload_command(args: argparse.Namespace) -> None: def upload_command(args: argparse.Namespace) -> None:
machine = Machine(name=args.machine, flake=args.flake) 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: def register_upload_parser(parser: argparse.ArgumentParser) -> None:

View File

@@ -14,7 +14,7 @@ from clan_cli.facts.generate import generate_facts
from clan_cli.machines.machines import Machine from clan_cli.machines.machines import Machine
from clan_cli.nix import nix_shell from clan_cli.nix import nix_shell
from clan_cli.vars.generate import generate_vars 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 .automount import pause_automounting
from .list import list_possible_keymaps, list_possible_languages from .list import list_possible_keymaps, list_possible_languages
@@ -107,7 +107,7 @@ def flash_machine(
local_dir.mkdir(parents=True) local_dir.mkdir(parents=True)
machine.secret_facts_store.upload(local_dir) machine.secret_facts_store.upload(local_dir)
upload_secret_vars(machine, local_dir) populate_secret_vars(machine, local_dir)
disko_install = [] disko_install = []
if os.geteuid() != 0: if os.geteuid() != 0:

View File

@@ -139,26 +139,26 @@ def generate_machine_hardware_info(opts: HardwareGenerateOptions) -> HardwareCon
"--show-hardware-config", "--show-hardware-config",
] ]
host = machine.target_host with machine.target_host() as host:
host.ssh_options["StrictHostKeyChecking"] = "accept-new" host.ssh_options["StrictHostKeyChecking"] = "accept-new"
host.ssh_options["UserKnownHostsFile"] = "/dev/null" host.ssh_options["UserKnownHostsFile"] = "/dev/null"
if opts.password: if opts.password:
host.password = opts.password host.password = opts.password
out = host.run(config_command, become_root=True, opts=RunOpts(check=False)) out = host.run(config_command, become_root=True, opts=RunOpts(check=False))
if out.returncode != 0: if out.returncode != 0:
if "nixos-facter" in out.stderr and "not found" in out.stderr: if "nixos-facter" in out.stderr and "not found" in out.stderr:
machine.error(str(out.stderr)) machine.error(str(out.stderr))
msg = ( msg = (
"Please use our custom nixos install images from https://github.com/nix-community/nixos-images/releases/tag/nixos-unstable. " "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." "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) raise ClanError(msg)
machine.error(str(out))
msg = f"Failed to inspect {opts.machine}. Address: {host.target}"
raise ClanError(msg)
backup_file = None backup_file = None
if hw_file.exists(): if hw_file.exists():
backup_file = hw_file.with_suffix(".bak") backup_file = hw_file.with_suffix(".bak")

View File

@@ -36,7 +36,6 @@ class BuildOn(Enum):
@dataclass @dataclass
class InstallOptions: class InstallOptions:
machine: Machine machine: Machine
target_host: str
kexec: str | None = None kexec: str | None = None
debug: bool = False debug: bool = False
no_reboot: bool = False no_reboot: bool = False
@@ -52,17 +51,16 @@ class InstallOptions:
@API.register @API.register
def install_machine(opts: InstallOptions) -> None: def install_machine(opts: InstallOptions) -> None:
machine = opts.machine machine = opts.machine
machine.override_target_host = opts.target_host
machine.info(f"installing {machine.name}") machine.debug(f"installing {machine.name}")
h = machine.target_host
machine.info(f"target host: {h.target}")
generate_facts([machine]) generate_facts([machine])
generate_vars([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() base_directory = Path(_base_directory).resolve()
activation_secrets = base_directory / "activation_secrets" activation_secrets = base_directory / "activation_secrets"
upload_dir = activation_secrets / machine.secrets_upload_directory.lstrip("/") upload_dir = activation_secrets / machine.secrets_upload_directory.lstrip("/")
@@ -134,14 +132,14 @@ def install_machine(opts: InstallOptions) -> None:
if opts.build_on: if opts.build_on:
cmd += ["--build-on", opts.build_on.value] cmd += ["--build-on", opts.build_on.value]
if h.port: if host.port:
cmd += ["--ssh-port", str(h.port)] cmd += ["--ssh-port", str(host.port)]
if opts.kexec: if opts.kexec:
cmd += ["--kexec", opts.kexec] cmd += ["--kexec", opts.kexec]
if opts.debug: if opts.debug:
cmd.append("--debug") cmd.append("--debug")
cmd.append(h.target) cmd.append(host.target)
if opts.use_tor: if opts.use_tor:
# nix copy does not support tor socks proxy # nix copy does not support tor socks proxy
# cmd.append("--ssh-option") # cmd.append("--ssh-option")
@@ -178,17 +176,15 @@ def install_command(args: argparse.Namespace) -> None:
deploy_info: DeployInfo | None = ssh_command_parse(args) deploy_info: DeployInfo | None = ssh_command_parse(args)
if args.target_host: if args.target_host:
target_host = args.target_host machine.override_target_host = args.target_host
elif deploy_info: elif deploy_info:
host = find_reachable_host(deploy_info, host_key_check) host = find_reachable_host(deploy_info, host_key_check)
if host is None: if host is None:
use_tor = True use_tor = True
target_host = f"root@{deploy_info.tor}" machine.override_target_host = f"root@{deploy_info.tor}"
else: else:
target_host = host.target machine.override_target_host = host.target
password = deploy_info.pwd password = deploy_info.pwd
else:
target_host = machine.target_host.target
if args.password: if args.password:
password = args.password password = args.password
@@ -197,19 +193,16 @@ def install_command(args: argparse.Namespace) -> None:
else: else:
password = None password = None
if not target_host:
msg = "No target host provided, please provide a target host."
raise ClanError(msg)
if not args.yes: 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": if ask != "y":
return None return None
return install_machine( return install_machine(
InstallOptions( InstallOptions(
machine=machine, machine=machine,
target_host=target_host,
kexec=args.kexec, kexec=args.kexec,
phases=args.phases, phases=args.phases,
debug=args.debug, debug=args.debug,

View File

@@ -2,6 +2,8 @@ import importlib
import json import json
import logging import logging
import re import re
from collections.abc import Iterator
from contextlib import contextmanager
from dataclasses import dataclass, field from dataclasses import dataclass, field
from functools import cached_property from functools import cached_property
from pathlib import Path from pathlib import Path
@@ -145,9 +147,9 @@ class Machine:
def flake_dir(self) -> Path: def flake_dir(self) -> Path:
return self.flake.path return self.flake.path
@property @contextmanager
def target_host(self) -> Host: def target_host(self) -> Iterator[Host]:
return parse_deployment_address( yield parse_deployment_address(
self.name, self.name,
self.target_host_address, self.target_host_address,
self.host_key_check, self.host_key_check,
@@ -155,23 +157,25 @@ class Machine:
meta={"machine": self}, meta={"machine": self},
) )
@property @contextmanager
def build_host(self) -> Host: def build_host(self) -> Iterator[Host | None]:
""" """
The host where the machine is built and deployed from. The host where the machine is built and deployed from.
Can be the same as the target host. Can be the same as the target host.
""" """
build_host = self.override_build_host or self.deployment.get("buildHost") build_host = self.override_build_host or self.deployment.get("buildHost")
if build_host is None: 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 # enable ssh agent forwarding to allow the build host to access the target host
return parse_deployment_address( yield parse_deployment_address(
self.name, self.name,
build_host, build_host,
self.host_key_check, self.host_key_check,
forward_agent=True, forward_agent=True,
private_key=self.private_key, private_key=self.private_key,
meta={"machine": self, "target_host": self.target_host}, meta={"machine": self},
) )
@cached_property @cached_property

View File

@@ -5,6 +5,7 @@ import os
import re import re
import shlex import shlex
import sys import sys
from contextlib import ExitStack
from clan_lib.api import API 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: def upload_sources(machine: Machine, host: Host) -> str:
host = machine.build_host
env = host.nix_ssh_env(os.environ.copy()) env = host.nix_ssh_env(os.environ.copy())
flake_url = ( flake_url = (
@@ -126,22 +126,25 @@ def update_machines(base_path: str, machines: list[InventoryMachine]) -> None:
deploy_machines(group_machines) deploy_machines(group_machines)
def deploy_machines(machines: list[Machine]) -> None: def deploy_machine(machine: Machine) -> None:
""" with ExitStack() as stack:
Deploy to all hosts in parallel 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_facts([machine], service=None, regenerate=False)
generate_vars([machine], generator_name=None, regenerate=False) generate_vars([machine], generator_name=None, regenerate=False)
upload_secrets(machine) upload_secrets(machine, target_host)
upload_secret_vars(machine) upload_secret_vars(machine, target_host)
path = upload_sources( path = upload_sources(machine, host)
machine=machine,
)
nix_options = [ nix_options = [
"--show-trace", "--show-trace",
@@ -166,10 +169,9 @@ def deploy_machines(machines: list[Machine]) -> None:
"", "",
] ]
target_host: Host | None = host.meta.get("target_host") if build_host:
if target_host:
become_root = False become_root = False
nix_options += ["--target-host", target_host.target] nix_options += ["--target-host", build_host.target]
if target_host.user != "root": if target_host.user != "root":
nix_options += ["--use-remote-sudo"] nix_options += ["--use-remote-sudo"]
@@ -211,19 +213,19 @@ def deploy_machines(machines: list[Machine]) -> None:
become_root=become_root, become_root=become_root,
) )
def deploy_machines(machines: list[Machine]) -> None:
"""
Deploy to all hosts in parallel
"""
with AsyncRuntime() as runtime: with AsyncRuntime() as runtime:
for machine in machines: 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( runtime.async_run(
AsyncOpts( AsyncOpts(
tid=machine.name, async_ctx=AsyncContext(prefix=machine.name) tid=machine.name, async_ctx=AsyncContext(prefix=machine.name)
), ),
deploy, deploy_machine,
machine, machine,
) )
runtime.join_all() runtime.join_all()

View File

@@ -1,15 +1,15 @@
# Adapted from https://github.com/numtide/deploykit # Adapted from https://github.com/numtide/deploykit
import errno
import logging import logging
import os import os
import shlex import shlex
import socket import socket
import stat
import subprocess import subprocess
import types
from dataclasses import dataclass, field from dataclasses import dataclass, field
from pathlib import Path from pathlib import Path
from shlex import quote from shlex import quote
from tempfile import TemporaryDirectory
from typing import Any from typing import Any
from clan_cli.cmd import CmdOut, RunOpts, run from clan_cli.cmd import CmdOut, RunOpts, run
@@ -40,35 +40,34 @@ class Host:
ssh_options: dict[str, str] = field(default_factory=dict) ssh_options: dict[str, str] = field(default_factory=dict)
tor_socks: bool = False tor_socks: bool = False
def setup_control_master(self) -> None: _temp_dir: TemporaryDirectory | None = 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
def setup_control_master(self, control_path: Path) -> None:
self.ssh_options["ControlMaster"] = "auto" self.ssh_options["ControlMaster"] = "auto"
# Can we make this a temporary directory?
self.ssh_options["ControlPath"] = str(control_path / "clan-%h-%p-%r") 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"] = "30m"
self.ssh_options["ControlPersist"] = "1m"
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: def __post_init__(self) -> None:
if not self.command_prefix: if not self.command_prefix:
self.command_prefix = self.host self.command_prefix = self.host
if not self.user: if not self.user:
self.user = "root" self.user = "root"
self.setup_control_master()
def __str__(self) -> str: def __str__(self) -> str:
return self.target return self.target

View File

@@ -7,6 +7,7 @@ from typing import TYPE_CHECKING
from clan_cli.errors import ClanError from clan_cli.errors import ClanError
from clan_cli.machines import machines from clan_cli.machines import machines
from clan_cli.ssh.host import Host
if TYPE_CHECKING: if TYPE_CHECKING:
from .generate import Generator, Var from .generate import Generator, Var
@@ -183,5 +184,5 @@ class StoreBase(ABC):
pass pass
@abstractmethod @abstractmethod
def upload(self, phases: list[str]) -> None: def upload(self, host: Host, phases: list[str]) -> None:
pass pass

View File

@@ -4,6 +4,7 @@ from pathlib import Path
from clan_cli.errors import ClanError from clan_cli.errors import ClanError
from clan_cli.machines.machines import Machine 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._types import StoreBase
from clan_cli.vars.generate import Generator, Var 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" msg = "populate_dir is not implemented for public vars stores"
raise NotImplementedError(msg) 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" msg = "upload is not implemented for public vars stores"
raise NotImplementedError(msg) raise NotImplementedError(msg)

View File

@@ -6,6 +6,7 @@ from pathlib import Path
from clan_cli.dirs import vm_state_dir from clan_cli.dirs import vm_state_dir
from clan_cli.errors import ClanError from clan_cli.errors import ClanError
from clan_cli.machines.machines import Machine 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._types import StoreBase
from clan_cli.vars.generate import Generator, Var 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" msg = "populate_dir is not implemented for public vars stores"
raise NotImplementedError(msg) 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" msg = "upload is not implemented for public vars stores"
raise NotImplementedError(msg) raise NotImplementedError(msg)

View File

@@ -3,6 +3,7 @@ import tempfile
from pathlib import Path from pathlib import Path
from clan_cli.machines.machines import Machine 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._types import StoreBase
from clan_cli.vars.generate import Generator, Var from clan_cli.vars.generate import Generator, Var
@@ -45,6 +46,6 @@ class SecretStore(StoreBase):
shutil.copytree(self.dir, output_dir) shutil.copytree(self.dir, output_dir)
shutil.rmtree(self.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" msg = "Cannot upload secrets with FS backend"
raise NotImplementedError(msg) raise NotImplementedError(msg)

View File

@@ -10,6 +10,7 @@ from tempfile import TemporaryDirectory
from clan_cli.cmd import CmdOut, Log, RunOpts, run from clan_cli.cmd import CmdOut, Log, RunOpts, run
from clan_cli.machines.machines import Machine from clan_cli.machines.machines import Machine
from clan_cli.nix import nix_shell from clan_cli.nix import nix_shell
from clan_cli.ssh.host import Host
from clan_cli.ssh.upload import upload from clan_cli.ssh.upload import upload
from clan_cli.vars._types import StoreBase from clan_cli.vars._types import StoreBase
from clan_cli.vars.generate import Generator, Var from clan_cli.vars.generate import Generator, Var
@@ -146,9 +147,9 @@ class SecretStore(StoreBase):
manifest += hashes manifest += hashes
return b"\n".join(manifest) return b"\n".join(manifest)
def needs_upload(self) -> bool: def needs_upload(self, host: Host) -> bool:
local_hash = self.generate_hash() 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 # TODO get the path to the secrets from the machine
[ [
"cat", "cat",
@@ -224,11 +225,11 @@ class SecretStore(StoreBase):
(output_dir / f".{self._store_backend}_info").write_bytes(self.generate_hash()) (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: if "partitioning" in phases:
msg = "Cannot upload partitioning secrets" msg = "Cannot upload partitioning secrets"
raise NotImplementedError(msg) raise NotImplementedError(msg)
if not self.needs_upload(): if not self.needs_upload(host):
log.info("Secrets already uploaded") log.info("Secrets already uploaded")
return return
with TemporaryDirectory(prefix="vars-upload-") as _tempdir: with TemporaryDirectory(prefix="vars-upload-") as _tempdir:
@@ -237,4 +238,4 @@ class SecretStore(StoreBase):
upload_dir = Path( upload_dir = Path(
self.machine.deployment["password-store"]["secretLocation"] self.machine.deployment["password-store"]["secretLocation"]
) )
upload(self.machine.target_host, pass_dir, upload_dir) upload(host, pass_dir, upload_dir)

View File

@@ -23,6 +23,7 @@ from clan_cli.secrets.secrets import (
groups_folder, groups_folder,
has_secret, has_secret,
) )
from clan_cli.ssh.host import Host
from clan_cli.ssh.upload import upload from clan_cli.ssh.upload import upload
from clan_cli.vars._types import StoreBase from clan_cli.vars._types import StoreBase
from clan_cli.vars.generate import Generator 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.write_bytes(self.get(generator, file.name))
target_path.chmod(file.mode) 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: if "partitioning" in phases:
msg = "Cannot upload partitioning secrets" msg = "Cannot upload partitioning secrets"
raise NotImplementedError(msg) raise NotImplementedError(msg)
with TemporaryDirectory(prefix="sops-upload-") as _tempdir: with TemporaryDirectory(prefix="sops-upload-") as _tempdir:
sops_upload_dir = Path(_tempdir).resolve() sops_upload_dir = Path(_tempdir).resolve()
self.populate_dir(sops_upload_dir, phases) 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: def exists(self, generator: Generator, name: str) -> bool:
secret_folder = self.secret_path(generator, name) secret_folder = self.secret_path(generator, name)
@@ -260,7 +262,6 @@ class SecretStore(StoreBase):
return keys return keys
# }
def needs_fix(self, generator: Generator, name: str) -> tuple[bool, str | None]: def needs_fix(self, generator: Generator, name: str) -> tuple[bool, str | None]:
secret_path = self.secret_path(generator, name) secret_path = self.secret_path(generator, name)
current_recipients = sops.get_recipients(secret_path) current_recipients = sops.get_recipients(secret_path)

View File

@@ -4,6 +4,7 @@ from pathlib import Path
from clan_cli.dirs import vm_state_dir from clan_cli.dirs import vm_state_dir
from clan_cli.machines.machines import Machine 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._types import StoreBase
from clan_cli.vars.generate import Generator, Var from clan_cli.vars.generate import Generator, Var
@@ -60,6 +61,6 @@ class SecretStore(StoreBase):
shutil.rmtree(output_dir) shutil.rmtree(output_dir)
shutil.copytree(self.dir, 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" msg = "Cannot upload secrets to VMs"
raise NotImplementedError(msg) raise NotImplementedError(msg)

View File

@@ -4,17 +4,19 @@ from pathlib import Path
from clan_cli.completions import add_dynamic_completer, complete_machines from clan_cli.completions import add_dynamic_completer, complete_machines
from clan_cli.machines.machines import Machine from clan_cli.machines.machines import Machine
from clan_cli.ssh.host import Host
log = logging.getLogger(__name__) log = logging.getLogger(__name__)
def upload_secret_vars(machine: Machine, directory: Path | None = None) -> None: def upload_secret_vars(machine: Machine, host: Host) -> None:
if directory: machine.secret_vars_store.upload(host, phases=["activation", "users", "services"])
machine.secret_vars_store.populate_dir(
directory, phases=["activation", "users", "services"]
) def populate_secret_vars(machine: Machine, directory: Path) -> None:
else: machine.secret_vars_store.populate_dir(
machine.secret_vars_store.upload(phases=["activation", "users", "services"]) directory, phases=["activation", "users", "services"]
)
def upload_command(args: argparse.Namespace) -> None: def upload_command(args: argparse.Namespace) -> None:
@@ -22,7 +24,11 @@ def upload_command(args: argparse.Namespace) -> None:
directory = None directory = None
if args.directory: if args.directory:
directory = Path(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: def register_upload_parser(parser: argparse.ArgumentParser) -> None:

View File

@@ -22,7 +22,7 @@ from clan_cli.nix import nix_shell
from clan_cli.qemu.qga import QgaSession from clan_cli.qemu.qga import QgaSession
from clan_cli.qemu.qmp import QEMUMonitorProtocol from clan_cli.qemu.qmp import QEMUMonitorProtocol
from clan_cli.vars.generate import generate_vars 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 .inspect import VmConfig, inspect_vm
from .qemu import qemu_command from .qemu import qemu_command
@@ -84,7 +84,7 @@ def get_secrets(
generate_vars([machine]) generate_vars([machine])
machine.secret_facts_store.upload(secrets_dir) machine.secret_facts_store.upload(secrets_dir)
upload_secret_vars(machine, secrets_dir) populate_secret_vars(machine, secrets_dir)
return secrets_dir return secrets_dir

View File

@@ -55,9 +55,9 @@ export const MachineListItem = (props: MachineListItemProps) => {
flake: { flake: {
identifier: active_clan, identifier: active_clan,
}, },
override_target_host: info?.deploy.targetHost,
}, },
no_reboot: true, no_reboot: true,
target_host: info?.deploy.targetHost,
debug: true, debug: true,
nix_options: [], nix_options: [],
password: null, password: null,

View File

@@ -142,8 +142,8 @@ const InstallMachine = (props: InstallMachineProps) => {
flake: { flake: {
identifier: curr_uri, identifier: curr_uri,
}, },
override_target_host: target,
}, },
target_host: target,
password: "", password: "",
}, },
}); });