Merge pull request 'init machine from inventory' (#3862) from Qubasa/clan-core:refactor_machinev2 into main

Reviewed-on: https://git.clan.lol/clan/clan-core/pulls/3862
This commit is contained in:
Luis Hebendanz
2025-06-12 15:05:44 +00:00
5 changed files with 116 additions and 39 deletions

View File

@@ -15,6 +15,7 @@ def list_command(args: argparse.Namespace) -> None:
if args.flake is None: if args.flake is None:
msg = "Could not find clan flake toplevel directory" msg = "Could not find clan flake toplevel directory"
raise ClanError(msg) raise ClanError(msg)
machine = Machine(name=args.machine, flake=args.flake) machine = Machine(name=args.machine, flake=args.flake)
backups = list_backups(machine=machine, provider=args.provider) backups = list_backups(machine=machine, provider=args.provider)
for backup in backups: for backup in backups:

View File

@@ -194,7 +194,7 @@ def install_command(args: argparse.Namespace) -> None:
if not args.yes: if not args.yes:
ask = input( ask = input(
f"Install {args.machine} to {machine.target_host_address}? [y/N] " f"Install {args.machine} to {machine.target_host().target}? [y/N] "
) )
if ask != "y": if ask != "y":
return None return None

View File

@@ -6,7 +6,7 @@ from typing import Literal
from clan_lib.api import API from clan_lib.api import API
from clan_lib.cmd import RunOpts from clan_lib.cmd import RunOpts
from clan_lib.errors import ClanError from clan_lib.errors import ClanError
from clan_lib.machines.machines import Machine from clan_lib.ssh.remote import Remote
log = logging.getLogger(__name__) log = logging.getLogger(__name__)
@@ -19,18 +19,12 @@ class ConnectionOptions:
@API.register @API.register
def check_machine_online( def check_machine_online(
machine: Machine, opts: ConnectionOptions | None = None remote: Remote, opts: ConnectionOptions | None = None
) -> Literal["Online", "Offline"]: ) -> Literal["Online", "Offline"]:
hostname = machine.target_host_address
if not hostname:
msg = f"Machine {machine.name} does not specify a targetHost"
raise ClanError(msg)
timeout = opts.timeout if opts and opts.timeout else 2 timeout = opts.timeout if opts and opts.timeout else 2
for _ in range(opts.retries if opts and opts.retries else 10): for _ in range(opts.retries if opts and opts.retries else 10):
host = machine.target_host() with remote.ssh_control_master() as ssh:
with host.ssh_control_master() as ssh:
res = ssh.run( res = ssh.run(
["true"], ["true"],
RunOpts(timeout=timeout, check=False, needs_user_terminal=True), RunOpts(timeout=timeout, check=False, needs_user_terminal=True),
@@ -38,6 +32,10 @@ def check_machine_online(
if res.returncode == 0: if res.returncode == 0:
return "Online" return "Online"
if "Host key verification failed." in res.stderr:
raise ClanError(res.stderr.strip())
time.sleep(timeout) time.sleep(timeout)
return "Offline" return "Offline"

View File

@@ -5,16 +5,19 @@ import re
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
from typing import TYPE_CHECKING, Any from typing import TYPE_CHECKING, Any, Literal
from clan_cli.facts import public_modules as facts_public_modules from clan_cli.facts import public_modules as facts_public_modules
from clan_cli.facts import secret_modules as facts_secret_modules from clan_cli.facts import secret_modules as facts_secret_modules
from clan_cli.ssh.host_key import HostKeyCheck from clan_cli.ssh.host_key import HostKeyCheck
from clan_cli.vars._types import StoreBase from clan_cli.vars._types import StoreBase
from clan_lib.api import API
from clan_lib.errors import ClanCmdError, ClanError from clan_lib.errors import ClanCmdError, ClanError
from clan_lib.flake import Flake from clan_lib.flake import Flake
from clan_lib.machines.actions import get_machine
from clan_lib.nix import nix_config, nix_test_store from clan_lib.nix import nix_config, nix_test_store
from clan_lib.nix_models.clan import InventoryMachine
from clan_lib.ssh.remote import Remote from clan_lib.ssh.remote import Remote
log = logging.getLogger(__name__) log = logging.getLogger(__name__)
@@ -34,6 +37,9 @@ class Machine:
private_key: Path | None = None private_key: Path | None = None
host_key_check: HostKeyCheck = HostKeyCheck.STRICT host_key_check: HostKeyCheck = HostKeyCheck.STRICT
def get_inv_machine(self) -> "InventoryMachine":
return get_machine(self.flake, self.name)
def get_id(self) -> str: def get_id(self) -> str:
return f"{self.flake}#{self.name}" return f"{self.flake}#{self.name}"
@@ -54,6 +60,10 @@ class Machine:
kwargs.update({"extra": {"command_prefix": self.name}}) kwargs.update({"extra": {"command_prefix": self.name}})
log.info(msg, *args, **kwargs) log.info(msg, *args, **kwargs)
def warn(self, msg: str, *args: Any, **kwargs: Any) -> None:
kwargs.update({"extra": {"command_prefix": self.name}})
log.warning(msg, *args, **kwargs)
def error(self, msg: str, *args: Any, **kwargs: Any) -> None: def error(self, msg: str, *args: Any, **kwargs: Any) -> None:
kwargs.update({"extra": {"command_prefix": self.name}}) kwargs.update({"extra": {"command_prefix": self.name}})
log.error(msg, *args, **kwargs) log.error(msg, *args, **kwargs)
@@ -83,17 +93,6 @@ class Machine:
) )
return deployment return deployment
@property
def target_host_address(self) -> str:
val = self.override_target_host or self.deployment.get("targetHost")
if val is None:
msg = f"'targetHost' is not set for machine '{self.name}'"
raise ClanError(
msg,
description="See https://docs.clan.lol/guides/getting-started/deploy/#setting-the-target-host for more information.",
)
return val
@cached_property @cached_property
def secret_facts_store(self) -> facts_secret_modules.SecretStoreBase: def secret_facts_store(self) -> facts_secret_modules.SecretStoreBase:
module = importlib.import_module(self.deployment["facts"]["secretModule"]) module = importlib.import_module(self.deployment["facts"]["secretModule"])
@@ -144,11 +143,34 @@ class Machine:
return self.flake.path return self.flake.path
def target_host(self) -> Remote: def target_host(self) -> Remote:
return Remote.from_deployment_address( if self.override_target_host:
machine_name=self.name, return Remote.from_deployment_address(
address=self.target_host_address, machine_name=self.name,
host_key_check=self.host_key_check, address=self.override_target_host,
host_key_check=self.host_key_check,
private_key=self.private_key,
)
remote = get_host(self.name, self.flake, field="targetHost")
if remote is None:
msg = f"'targetHost' is not set for machine '{self.name}'"
raise ClanError(
msg,
description="See https://docs.clan.lol/guides/getting-started/deploy/#setting-the-target-host for more information.",
)
data = remote.data
return Remote(
address=data.address,
user=data.user,
command_prefix=data.command_prefix,
port=data.port,
private_key=self.private_key, private_key=self.private_key,
password=data.password,
forward_agent=data.forward_agent,
host_key_check=self.host_key_check,
verbose_ssh=data.verbose_ssh,
ssh_options=data.ssh_options,
tor_socks=data.tor_socks,
) )
def build_host(self) -> Remote | None: def build_host(self) -> Remote | None:
@@ -156,18 +178,34 @@ class Machine:
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.
""" """
address = self.override_build_host or self.deployment.get("buildHost")
if address is None: if self.override_build_host:
return None return Remote.from_deployment_address(
# enable ssh agent forwarding to allow the build host to access the target host machine_name=self.name,
host = Remote.from_deployment_address( address=self.override_build_host,
machine_name=self.name, host_key_check=self.host_key_check,
address=address, private_key=self.private_key,
host_key_check=self.host_key_check, )
forward_agent=True,
private_key=self.private_key, remote = get_host(self.name, self.flake, field="buildHost")
)
return host if remote:
data = remote.data
return Remote(
address=data.address,
user=data.user,
command_prefix=data.command_prefix,
port=data.port,
private_key=self.private_key,
password=data.password,
forward_agent=data.forward_agent,
host_key_check=self.host_key_check,
verbose_ssh=data.verbose_ssh,
ssh_options=data.ssh_options,
tor_socks=data.tor_socks,
)
return None
def nix( def nix(
self, self,
@@ -234,3 +272,43 @@ class Machine:
return output return output
msg = "build_nix returned not a Path" msg = "build_nix returned not a Path"
raise ClanError(msg) raise ClanError(msg)
@dataclass(frozen=True)
class RemoteSource:
data: Remote
source: Literal["inventory", "nix_machine"]
@API.register
def get_host(
name: str, flake: Flake, field: Literal["targetHost", "buildHost"]
) -> RemoteSource | None:
"""
Get the build host for a machine.
"""
machine = Machine(name=name, flake=flake)
inv_machine = machine.get_inv_machine()
source: Literal["inventory", "nix_machine"] = "inventory"
target_host_str = inv_machine.get("deploy", {}).get(field)
if target_host_str is None:
machine.info(
f"'{field}' is not set in inventory, falling back to slow Nix config"
)
target_host_str = machine.eval_nix(f'config.clan.core.networking."{field}"')
source = "nix_machine"
if not target_host_str:
return None
return RemoteSource(
data=Remote.from_deployment_address(
machine_name=machine.name,
address=target_host_str,
host_key_check=machine.host_key_check,
private_key=machine.private_key,
),
source=source,
)

View File

@@ -203,7 +203,7 @@ def test_clan_create_api(
# Invalidate cache because of new machine creation # Invalidate cache because of new machine creation
clan_dir_flake.invalidate_cache() clan_dir_flake.invalidate_cache()
result = check_machine_online(machine) result = check_machine_online(machine.target_host())
assert result == "Online", f"Machine {machine.name} is not online" assert result == "Online", f"Machine {machine.name} is not online"
ssh_keys = [ ssh_keys = [