diff --git a/pkgs/clan-app/ui-2d/src/components/machine-list-item/index.tsx b/pkgs/clan-app/ui-2d/src/components/machine-list-item/index.tsx index e6c7010af..ae126a11d 100644 --- a/pkgs/clan-app/ui-2d/src/components/machine-list-item/index.tsx +++ b/pkgs/clan-app/ui-2d/src/components/machine-list-item/index.tsx @@ -75,7 +75,7 @@ export const MachineListItem = (props: MachineListItemProps) => { } setInstalling(true); - await callApi("install_machine", { + await callApi("run_machine_install", { opts: { machine: { name: name, @@ -163,7 +163,7 @@ export const MachineListItem = (props: MachineListItemProps) => { } await callApi( - "deploy_machine", + "run_machine_deploy", { machine: { name: name, diff --git a/pkgs/clan-app/ui-2d/src/routes/clans/details.tsx b/pkgs/clan-app/ui-2d/src/routes/clans/details.tsx index 7ef28306c..94e444e48 100644 --- a/pkgs/clan-app/ui-2d/src/routes/clans/details.tsx +++ b/pkgs/clan-app/ui-2d/src/routes/clans/details.tsx @@ -23,7 +23,7 @@ const EditClanForm = (props: EditClanFormProps) => { const handleSubmit: SubmitHandler = async (values, event) => { await toast.promise( (async () => { - await callApi("update_clan_meta", { + await callApi("set_clan_details", { options: { flake: { identifier: props.directory }, meta: values, diff --git a/pkgs/clan-app/ui-2d/src/routes/flash/view.tsx b/pkgs/clan-app/ui-2d/src/routes/flash/view.tsx index e99b5ce93..465e78e3e 100644 --- a/pkgs/clan-app/ui-2d/src/routes/flash/view.tsx +++ b/pkgs/clan-app/ui-2d/src/routes/flash/view.tsx @@ -157,7 +157,7 @@ export const Flash = () => { console.log("Confirmed flash:", values); try { await toast.promise( - callApi("flash_machine", { + callApi("run_machine_flash", { machine: { name: values.machine.devicePath, flake: { diff --git a/pkgs/clan-app/ui-2d/src/routes/machines/components/InstallMachine.tsx b/pkgs/clan-app/ui-2d/src/routes/machines/components/InstallMachine.tsx index 609fa1805..39fed8099 100644 --- a/pkgs/clan-app/ui-2d/src/routes/machines/components/InstallMachine.tsx +++ b/pkgs/clan-app/ui-2d/src/routes/machines/components/InstallMachine.tsx @@ -120,7 +120,7 @@ export function InstallMachine(props: InstallMachineProps) { throw new Error("No target host found for the machine"); } - const installPromise = callApi("install_machine", { + const installPromise = callApi("run_machine_install", { opts: { machine: { name: props.name, diff --git a/pkgs/clan-app/ui-2d/src/routes/machines/components/MachineForm.tsx b/pkgs/clan-app/ui-2d/src/routes/machines/components/MachineForm.tsx index 407321c82..291a2551f 100644 --- a/pkgs/clan-app/ui-2d/src/routes/machines/components/MachineForm.tsx +++ b/pkgs/clan-app/ui-2d/src/routes/machines/components/MachineForm.tsx @@ -149,7 +149,7 @@ export function MachineForm(props: MachineFormProps) { setIsUpdating(true); const r = await callApi( - "deploy_machine", + "run_machine_deploy", { machine: { name: machine, diff --git a/pkgs/clan-app/ui/src/routes/clans/details.tsx b/pkgs/clan-app/ui/src/routes/clans/details.tsx index 7ef28306c..94e444e48 100644 --- a/pkgs/clan-app/ui/src/routes/clans/details.tsx +++ b/pkgs/clan-app/ui/src/routes/clans/details.tsx @@ -23,7 +23,7 @@ const EditClanForm = (props: EditClanFormProps) => { const handleSubmit: SubmitHandler = async (values, event) => { await toast.promise( (async () => { - await callApi("update_clan_meta", { + await callApi("set_clan_details", { options: { flake: { identifier: props.directory }, meta: values, diff --git a/pkgs/clan-cli/clan_cli/flash/flash.py b/pkgs/clan-cli/clan_cli/flash/flash.py index be2eb683e..9f2989ba3 100644 --- a/pkgs/clan-cli/clan_cli/flash/flash.py +++ b/pkgs/clan-cli/clan_cli/flash/flash.py @@ -37,7 +37,7 @@ class Disk: # TODO: unify this with machine install @API.register -def flash_machine( +def run_machine_flash( machine: Machine, *, mode: str, diff --git a/pkgs/clan-cli/clan_cli/flash/flash_cmd.py b/pkgs/clan-cli/clan_cli/flash/flash_cmd.py index 5aba210dc..b0ac95f69 100644 --- a/pkgs/clan-cli/clan_cli/flash/flash_cmd.py +++ b/pkgs/clan-cli/clan_cli/flash/flash_cmd.py @@ -11,7 +11,7 @@ from clan_lib.machines.machines import Machine from clan_cli.completions import add_dynamic_completer, complete_machines -from .flash import Disk, SystemConfig, flash_machine +from .flash import Disk, SystemConfig, run_machine_flash log = logging.getLogger(__name__) @@ -84,7 +84,7 @@ def flash_command(args: argparse.Namespace) -> None: if ask != "y": return - flash_machine( + run_machine_flash( machine, mode=opts.mode, disks=opts.disks, diff --git a/pkgs/clan-cli/clan_cli/machines/install.py b/pkgs/clan-cli/clan_cli/machines/install.py index b93a7331d..d2f04529f 100644 --- a/pkgs/clan-cli/clan_cli/machines/install.py +++ b/pkgs/clan-cli/clan_cli/machines/install.py @@ -4,7 +4,7 @@ import sys from pathlib import Path from clan_lib.errors import ClanError -from clan_lib.machines.install import BuildOn, InstallOptions, install_machine +from clan_lib.machines.install import BuildOn, InstallOptions, run_machine_install from clan_lib.machines.machines import Machine from clan_lib.ssh.remote import Remote @@ -65,7 +65,7 @@ def install_command(args: argparse.Namespace) -> None: if ask != "y": return None - return install_machine( + return run_machine_install( InstallOptions( machine=machine, kexec=args.kexec, diff --git a/pkgs/clan-cli/clan_cli/machines/update.py b/pkgs/clan-cli/clan_cli/machines/update.py index 75c543c8c..5fdaa1f4d 100644 --- a/pkgs/clan-cli/clan_cli/machines/update.py +++ b/pkgs/clan-cli/clan_cli/machines/update.py @@ -9,7 +9,7 @@ from clan_lib.machines.actions import list_machines from clan_lib.machines.list import instantiate_inventory_to_machines from clan_lib.machines.machines import Machine from clan_lib.machines.suggestions import validate_machine_names -from clan_lib.machines.update import deploy_machine +from clan_lib.machines.update import run_machine_deploy from clan_lib.nix import nix_config from clan_lib.ssh.remote import Remote @@ -144,7 +144,7 @@ def update_command(args: argparse.Namespace) -> None: tid=machine.name, async_ctx=AsyncContext(prefix=machine.name), ), - deploy_machine, + run_machine_deploy, machine=machine, target_host=target_host, build_host=machine.build_host(), diff --git a/pkgs/clan-cli/clan_cli/secrets/sops.py b/pkgs/clan-cli/clan_cli/secrets/sops.py index c87775d61..10d49a3d4 100644 --- a/pkgs/clan-cli/clan_cli/secrets/sops.py +++ b/pkgs/clan-cli/clan_cli/secrets/sops.py @@ -13,7 +13,6 @@ from pathlib import Path from tempfile import NamedTemporaryFile from typing import IO, Any -from clan_lib.api import API from clan_lib.cmd import Log, RunOpts, run from clan_lib.dirs import user_config_dir from clan_lib.errors import ClanError @@ -398,7 +397,6 @@ def default_admin_private_key_path() -> Path: return user_config_dir() / "sops" / "age" / "keys.txt" -@API.register def maybe_get_admin_public_keys() -> list[SopsKey] | None: keyring = SopsKey.collect_public_keys() diff --git a/pkgs/clan-cli/clan_cli/ssh/deploy_info.py b/pkgs/clan-cli/clan_cli/ssh/deploy_info.py index 9ba609c95..5bf05d798 100644 --- a/pkgs/clan-cli/clan_cli/ssh/deploy_info.py +++ b/pkgs/clan-cli/clan_cli/ssh/deploy_info.py @@ -98,7 +98,7 @@ def find_reachable_host(deploy_info: DeployInfo) -> Remote | None: return deploy_info.addrs[0] for addr in deploy_info.addrs: - if addr.is_ssh_reachable(): + if addr.check_machine_ssh_reachable(): return addr return None diff --git a/pkgs/clan-cli/clan_cli/vars/keygen.py b/pkgs/clan-cli/clan_cli/vars/keygen.py index 9475a771f..509099eab 100644 --- a/pkgs/clan-cli/clan_cli/vars/keygen.py +++ b/pkgs/clan-cli/clan_cli/vars/keygen.py @@ -12,6 +12,7 @@ from clan_lib.errors import ClanError log = logging.getLogger(__name__) +# TODO: Unify with "create clan" should be done automatically @API.register def create_secrets_user( flake_dir: Path, user: str | None = None, force: bool = False diff --git a/pkgs/clan-cli/clan_lib/clan/update.py b/pkgs/clan-cli/clan_lib/clan/update.py index 8cf32f1bc..7e735ba4f 100644 --- a/pkgs/clan-cli/clan_lib/clan/update.py +++ b/pkgs/clan-cli/clan_lib/clan/update.py @@ -14,7 +14,7 @@ class UpdateOptions: @API.register -def update_clan_meta(options: UpdateOptions) -> InventorySnapshot: +def set_clan_details(options: UpdateOptions) -> InventorySnapshot: inventory_store = InventoryStore(options.flake) inventory = inventory_store.read() set_value_by_path(inventory, "meta", options.meta) diff --git a/pkgs/clan-cli/clan_lib/log_manager/example_usage.py b/pkgs/clan-cli/clan_lib/log_manager/example_usage.py index 5460486ca..03c85f727 100755 --- a/pkgs/clan-cli/clan_lib/log_manager/example_usage.py +++ b/pkgs/clan-cli/clan_lib/log_manager/example_usage.py @@ -17,7 +17,7 @@ def example_function() -> None: """Example function for creating logs.""" -def deploy_machine() -> None: +def run_machine_deploy() -> None: """Function for deploying machines.""" @@ -41,7 +41,7 @@ def main() -> None: for repo in repos: for machine in machines: log_manager.create_log_file( - deploy_machine, + run_machine_deploy, f"deploy_{machine}", ["clans", repo, "machines", machine], ) diff --git a/pkgs/clan-cli/clan_lib/log_manager/test_log_manager.py b/pkgs/clan-cli/clan_lib/log_manager/test_log_manager.py index bdd5daa50..49f1c88f8 100644 --- a/pkgs/clan-cli/clan_lib/log_manager/test_log_manager.py +++ b/pkgs/clan-cli/clan_lib/log_manager/test_log_manager.py @@ -17,7 +17,7 @@ from clan_lib.log_manager import ( # Test functions for log creation -def deploy_machine() -> None: +def run_machine_deploy() -> None: """Test function for deploying machines.""" @@ -194,13 +194,13 @@ class TestLogFileCreation: for repo in repos: for machine in machines: log_file = configured_log_manager.create_log_file( - deploy_machine, + run_machine_deploy, f"deploy_{machine}", ["clans", repo, "machines", machine], ) assert log_file.op_key == f"deploy_{machine}" - assert log_file.func_name == "deploy_machine" + assert log_file.func_name == "run_machine_deploy" assert log_file.get_file_path().exists() # Check the group structure includes URL encoding for dynamic parts @@ -241,7 +241,7 @@ class TestFilterFunction: """Test that empty filter returns top-level groups.""" # Create some log files first configured_log_manager.create_log_file( - deploy_machine, "test_op", ["clans", "repo1", "machines", "machine1"] + run_machine_deploy, "test_op", ["clans", "repo1", "machines", "machine1"] ) top_level = configured_log_manager.filter([]) @@ -258,7 +258,7 @@ class TestFilterFunction: for repo in repos: for machine in machines: configured_log_manager.create_log_file( - deploy_machine, + run_machine_deploy, f"deploy_{machine}", ["clans", repo, "machines", machine], ) @@ -281,7 +281,7 @@ class TestFilterFunction: """Test filtering with specific date.""" # Create log file log_file = configured_log_manager.create_log_file( - deploy_machine, "test_op", ["clans", "repo1", "machines", "machine1"] + run_machine_deploy, "test_op", ["clans", "repo1", "machines", "machine1"] ) # Filter with the specific date @@ -308,14 +308,16 @@ class TestGetLogFile: """Test getting log file by operation key.""" # Create log file configured_log_manager.create_log_file( - deploy_machine, "deploy_wintux", ["clans", "repo1", "machines", "wintux"] + run_machine_deploy, + "deploy_wintux", + ["clans", "repo1", "machines", "wintux"], ) # Find it by op_key found_log_file = configured_log_manager.get_log_file("deploy_wintux") assert found_log_file is not None assert found_log_file.op_key == "deploy_wintux" - assert found_log_file.func_name == "deploy_machine" + assert found_log_file.func_name == "run_machine_deploy" def test_get_log_file_with_selector( self, configured_log_manager: LogManager @@ -323,10 +325,14 @@ class TestGetLogFile: """Test getting log file with specific selector like example_usage.py.""" # Create log files in different locations configured_log_manager.create_log_file( - deploy_machine, "deploy_wintux", ["clans", "repo1", "machines", "wintux"] + run_machine_deploy, + "deploy_wintux", + ["clans", "repo1", "machines", "wintux"], ) configured_log_manager.create_log_file( - deploy_machine, "deploy_wintux", ["clans", "repo2", "machines", "wintux"] + run_machine_deploy, + "deploy_wintux", + ["clans", "repo2", "machines", "wintux"], ) # Find specific one using selector @@ -341,7 +347,7 @@ class TestGetLogFile: """Test getting log file with specific date.""" # Create log file log_file = configured_log_manager.create_log_file( - deploy_machine, "deploy_demo", ["clans", "repo1", "machines", "demo"] + run_machine_deploy, "deploy_demo", ["clans", "repo1", "machines", "demo"] ) # Find it by op_key and date @@ -378,10 +384,10 @@ class TestListLogDays: """Test listing log days when logs exist.""" # Create log files configured_log_manager.create_log_file( - deploy_machine, "op1", ["clans", "repo1", "machines", "machine1"] + run_machine_deploy, "op1", ["clans", "repo1", "machines", "machine1"] ) configured_log_manager.create_log_file( - deploy_machine, "op2", ["clans", "repo2", "machines", "machine2"] + run_machine_deploy, "op2", ["clans", "repo2", "machines", "machine2"] ) days = configured_log_manager.list_log_days() @@ -406,7 +412,7 @@ class TestApiCompatibility: for repo in repos: for machine in machines: configured_log_manager.create_log_file( - deploy_machine, + run_machine_deploy, f"deploy_{machine}", ["clans", repo, "machines", machine], ) @@ -741,19 +747,19 @@ class TestLogFileSorting: # This simulates the realistic scenario where the same operation runs on different machines configured_log_manager.create_log_file( - deploy_machine, + run_machine_deploy, "deploy_operation", ["clans", "repo1", "machines", "machine1"], ) configured_log_manager.create_log_file( - deploy_machine, + run_machine_deploy, "deploy_operation", ["clans", "repo1", "machines", "machine2"], ) configured_log_manager.create_log_file( - deploy_machine, + run_machine_deploy, "deploy_operation", ["clans", "repo2", "machines", "machine1"], ) @@ -819,7 +825,7 @@ class TestURLEncoding: # Create log file with special characters log_file = configured_log_manager.create_log_file( - deploy_machine, + run_machine_deploy, "deploy_special", ["clans", special_repo, "machines", special_machine], ) diff --git a/pkgs/clan-cli/clan_lib/machines/install.py b/pkgs/clan-cli/clan_lib/machines/install.py index 66ccf3e0a..ef33e6222 100644 --- a/pkgs/clan-cli/clan_lib/machines/install.py +++ b/pkgs/clan-cli/clan_lib/machines/install.py @@ -39,7 +39,7 @@ class InstallOptions: @API.register -def install_machine(opts: InstallOptions, target_host: Remote) -> None: +def run_machine_install(opts: InstallOptions, target_host: Remote) -> None: machine = opts.machine machine.debug(f"installing {machine.name}") diff --git a/pkgs/clan-cli/clan_lib/machines/update.py b/pkgs/clan-cli/clan_lib/machines/update.py index 9e477562c..62cd98c75 100644 --- a/pkgs/clan-cli/clan_lib/machines/update.py +++ b/pkgs/clan-cli/clan_lib/machines/update.py @@ -103,7 +103,7 @@ def upload_sources(machine: Machine, ssh: Remote) -> str: @API.register -def deploy_machine( +def run_machine_deploy( machine: Machine, target_host: Remote, build_host: Remote | None ) -> None: with ExitStack() as stack: diff --git a/pkgs/clan-cli/clan_lib/ssh/remote.py b/pkgs/clan-cli/clan_lib/ssh/remote.py index 8f4d4f8af..ec8c4e5d0 100644 --- a/pkgs/clan-cli/clan_lib/ssh/remote.py +++ b/pkgs/clan-cli/clan_lib/ssh/remote.py @@ -12,7 +12,6 @@ from dataclasses import dataclass, field from pathlib import Path from shlex import quote from tempfile import TemporaryDirectory -from typing import Literal from clan_lib.api import API from clan_lib.cmd import ClanCmdError, ClanCmdTimeoutError, CmdOut, RunOpts, run @@ -74,9 +73,9 @@ class Remote: private_key=private_key if private_key is not None else self.private_key, password=password if password is not None else self.password, forward_agent=self.forward_agent, - host_key_check=host_key_check - if host_key_check is not None - else self.host_key_check, + host_key_check=( + host_key_check if host_key_check is not None else self.host_key_check + ), verbose_ssh=self.verbose_ssh, ssh_options=self.ssh_options, tor_socks=tor_socks if tor_socks is not None else self.tor_socks, @@ -425,8 +424,8 @@ class Remote: self.check_sshpass_errorcode(res) - def is_ssh_reachable(self) -> bool: - return is_ssh_reachable(self) + def check_machine_ssh_reachable(self) -> bool: + return check_machine_ssh_reachable(self).ok @dataclass(frozen=True) @@ -435,10 +434,16 @@ class ConnectionOptions: retries: int = 10 +@dataclass +class CheckResult: + ok: bool + reason: str | None = None + + @API.register -def can_ssh_login( +def check_machine_ssh_login( remote: Remote, opts: ConnectionOptions | None = None -) -> Literal["Online", "Offline"]: +) -> CheckResult: if opts is None: opts = ConnectionOptions() @@ -449,7 +454,7 @@ def can_ssh_login( ["true"], RunOpts(timeout=opts.timeout, needs_user_terminal=True), ) - return "Online" + return CheckResult(True) except ClanCmdTimeoutError: pass except ClanCmdError as e: @@ -458,11 +463,13 @@ def can_ssh_login( else: time.sleep(opts.timeout) - return "Offline" + return CheckResult(False, f"failed after {opts.retries} attempts") @API.register -def is_ssh_reachable(remote: Remote, opts: ConnectionOptions | None = None) -> bool: +def check_machine_ssh_reachable( + remote: Remote, opts: ConnectionOptions | None = None +) -> CheckResult: if opts is None: opts = ConnectionOptions() @@ -472,10 +479,10 @@ def is_ssh_reachable(remote: Remote, opts: ConnectionOptions | None = None) -> b sock.settimeout(opts.timeout) try: sock.connect((remote.address, remote.port or 22)) - return True + return CheckResult(True) except (TimeoutError, OSError): pass else: time.sleep(opts.timeout) - return False + return CheckResult(False, f"failed after {opts.retries} attempts") diff --git a/pkgs/clan-cli/clan_lib/tests/test_create.py b/pkgs/clan-cli/clan_lib/tests/test_create.py index ac2758374..cae4d5590 100644 --- a/pkgs/clan-cli/clan_lib/tests/test_create.py +++ b/pkgs/clan-cli/clan_lib/tests/test_create.py @@ -33,7 +33,7 @@ from clan_lib.nix_models.clan import ( ) from clan_lib.nix_models.clan import InventoryMachineDeploy as MachineDeploy from clan_lib.persist.util import set_value_by_path -from clan_lib.ssh.remote import Remote, can_ssh_login +from clan_lib.ssh.remote import Remote, check_machine_ssh_login log = logging.getLogger(__name__) @@ -190,8 +190,9 @@ def test_clan_create_api( target_host = machine.target_host().override( private_key=private_key, host_key_check="none" ) - result = can_ssh_login(target_host) - assert result == "Online", f"Machine {machine.name} is not online" + assert check_machine_ssh_login(target_host).ok, ( + f"Machine {machine.name} is not online" + ) ssh_keys = [ SSHKeyPair(