diff --git a/pkgs/clan-cli/clan_cli/machines/machines.py b/pkgs/clan-cli/clan_cli/machines/machines.py index 8bbdd3cef..63e93dca3 100644 --- a/pkgs/clan-cli/clan_cli/machines/machines.py +++ b/pkgs/clan-cli/clan_cli/machines/machines.py @@ -1,11 +1,11 @@ import json import logging +from collections.abc import Generator +from contextlib import contextmanager from os import path from pathlib import Path -from tempfile import NamedTemporaryFile -from time import sleep +from tempfile import NamedTemporaryFile, TemporaryDirectory -from clan_cli.dirs import vm_state_dir from qemu.qmp import QEMUMonitorProtocol from ..cmd import run @@ -16,6 +16,39 @@ from ..ssh import Host, parse_deployment_address log = logging.getLogger(__name__) +class VMAttr: + def __init__(self, machine_name: str) -> None: + self.temp_dir = TemporaryDirectory(prefix="clan_vm-", suffix=f"-{machine_name}") + self._qmp_socket: Path = Path(self.temp_dir.name) / "qmp.sock" + self._qga_socket: Path = Path(self.temp_dir.name) / "qga.sock" + self._qmp: QEMUMonitorProtocol | None = None + + @contextmanager + def qmp(self) -> Generator[QEMUMonitorProtocol, None, None]: + if self._qmp is None: + log.debug(f"qmp_socket: {self._qmp_socket}") + self._qmp = QEMUMonitorProtocol(path.realpath(self._qmp_socket)) + self._qmp.connect() + try: + yield self._qmp + finally: + self._qmp.close() + + @property + def qmp_socket(self) -> Path: + if self._qmp is None: + log.debug(f"qmp_socket: {self._qmp_socket}") + self._qmp = QEMUMonitorProtocol(path.realpath(self._qmp_socket)) + return self._qmp_socket + + @property + def qga_socket(self) -> Path: + if self._qmp is None: + log.debug(f"qmp_socket: {self.qga_socket}") + self._qmp = QEMUMonitorProtocol(path.realpath(self._qmp_socket)) + return self._qga_socket + + class Machine: def __init__( self, @@ -36,14 +69,8 @@ class Machine: self.build_cache: dict[str, Path] = {} self._deployment_info: None | dict[str, str] = deployment_info - state_dir = vm_state_dir(flake_url=str(self.flake), vm_name=self.name) - self.qmp_socket: Path = state_dir / "qmp.sock" - self.qga_socket: Path = state_dir / "qga.sock" - - log.debug(f"qmp_socket: {self.qmp_socket}") - self._qmp = QEMUMonitorProtocol(path.realpath(self.qmp_socket)) - self._qmp_connected = False + self.vm: VMAttr = VMAttr(name) def __str__(self) -> str: return f"Machine(name={self.name}, flake={self.flake})" @@ -60,28 +87,6 @@ class Machine: ) return self._deployment_info - def qmp_connect(self) -> None: - if not self._qmp_connected: - tries = 100 - for num in range(tries): - try: - # the socket file link might be outdated, therefore re-init the qmp object - self._qmp = QEMUMonitorProtocol(path.realpath(self.qmp_socket)) - self._qmp.connect() - self._qmp_connected = True - log.debug("QMP Connected") - return - except FileNotFoundError: - if num < 99: - sleep(0.1) - continue - else: - raise - - def qmp_command(self, command: str) -> dict: - self.qmp_connect() - return self._qmp.command(command) - @property def target_host_address(self) -> str: # deploymentAddress is deprecated. diff --git a/pkgs/clan-cli/clan_cli/vms/run.py b/pkgs/clan-cli/clan_cli/vms/run.py index 40a3454e6..ea112e2eb 100644 --- a/pkgs/clan-cli/clan_cli/vms/run.py +++ b/pkgs/clan-cli/clan_cli/vms/run.py @@ -146,7 +146,9 @@ def qemu_command( # TODO move this to the Machines class -def build_vm(machine: Machine, vm: VmConfig, nix_options: list[str]) -> dict[str, str]: +def build_vm( + machine: Machine, vm: VmConfig, nix_options: list[str] = [] +) -> dict[str, str]: config = nix_config() system = config["system"] diff --git a/pkgs/clan-cli/tests/test_vms_cli.py b/pkgs/clan-cli/tests/test_vms_cli.py index 4345ca89d..ab39b6e55 100644 --- a/pkgs/clan-cli/tests/test_vms_cli.py +++ b/pkgs/clan-cli/tests/test_vms_cli.py @@ -1,7 +1,10 @@ import os import sys +import tempfile import threading import traceback +from collections.abc import Generator +from contextlib import contextmanager from pathlib import Path from time import sleep from typing import TYPE_CHECKING @@ -21,6 +24,34 @@ if TYPE_CHECKING: no_kvm = not os.path.exists("/dev/kvm") +@contextmanager +def monkeypatch_tempdir_with_custom_path( + *, monkeypatch: pytest.MonkeyPatch, custom_path: str, prefix_condition: str +) -> Generator[None, None, None]: + # Custom wrapper function that checks the prefix and either modifies the behavior or falls back to the original + class CustomTemporaryDirectory(tempfile.TemporaryDirectory): + def __init__( + self, + suffix: str | None = None, + prefix: str | None = None, + dir: str | None = None, # noqa: A002 + ) -> None: + if prefix == prefix_condition: + self.name = custom_path # Use the custom path + self._finalizer = None # Prevent cleanup attempts on the custom path by the original finalizer + else: + super().__init__(suffix=suffix, prefix=prefix, dir=dir) + + # Use ExitStack to ensure unpatching + try: + # Patch the TemporaryDirectory with our custom class + monkeypatch.setattr(tempfile, "TemporaryDirectory", CustomTemporaryDirectory) + yield # This allows the code within the 'with' block of this context manager to run + finally: + # Unpatch the TemporaryDirectory + monkeypatch.undo() + + def run_vm_in_thread(machine_name: str) -> None: # runs machine and prints exceptions def run() -> None: @@ -125,22 +156,26 @@ def test_vm_qmp( # 'clan vms run' must be executed from within the flake monkeypatch.chdir(flake.path) - # the state dir is a point of reference for qemu interactions as it links to the qga/qmp sockets - state_dir = vm_state_dir(str(flake.path), "my_machine") + with monkeypatch_tempdir_with_custom_path( + monkeypatch=monkeypatch, + custom_path=str(temporary_home / "vm-tmp"), + prefix_condition="clan_vm-", + ): + # the state dir is a point of reference for qemu interactions as it links to the qga/qmp sockets + state_dir = vm_state_dir(str(flake.path), "my_machine") + # start the VM + run_vm_in_thread("my_machine") - # start the VM - run_vm_in_thread("my_machine") + # connect with qmp + qmp = qmp_connect(state_dir) - # connect with qmp - qmp = qmp_connect(state_dir) + # verify that issuing a command works + # result = qmp.cmd_obj({"execute": "query-status"}) + result = qmp.command("query-status") + assert result["status"] == "running", result - # verify that issuing a command works - # result = qmp.cmd_obj({"execute": "query-status"}) - result = qmp.command("query-status") - assert result["status"] == "running", result - - # shutdown machine (prevent zombie qemu processes) - qmp.command("system_powerdown") + # shutdown machine (prevent zombie qemu processes) + qmp.command("system_powerdown") @pytest.mark.skipif(no_kvm, reason="Requires KVM") diff --git a/pkgs/clan-vm-manager/clan_vm_manager/assets/style.css b/pkgs/clan-vm-manager/clan_vm_manager/assets/style.css index c08c53baa..1284730e2 100644 --- a/pkgs/clan-vm-manager/clan_vm_manager/assets/style.css +++ b/pkgs/clan-vm-manager/clan_vm_manager/assets/style.css @@ -12,8 +12,8 @@ avatar { } .trust { - padding-top: 25px; - padding-bottom: 25px; + padding-top: 25px; + padding-bottom: 25px; } .join-list { @@ -22,11 +22,16 @@ avatar { } +.progress-bar { + margin-right: 25px; + min-width: 200px; +} + .group-list { background-color: inherit; } .group-list > .activatable:hover { - background-color: unset; + background-color: unset; } .group-list > row { diff --git a/pkgs/clan-vm-manager/clan_vm_manager/models/use_vms.py b/pkgs/clan-vm-manager/clan_vm_manager/models/use_vms.py index 4320c9614..e86cc548e 100644 --- a/pkgs/clan-vm-manager/clan_vm_manager/models/use_vms.py +++ b/pkgs/clan-vm-manager/clan_vm_manager/models/use_vms.py @@ -115,6 +115,8 @@ class VM(GObject.Object): self._logs_id: int = 0 self._log_file: IO[str] | None = None self.progress_bar: Gtk.ProgressBar = Gtk.ProgressBar() + self.progress_bar.hide() + self.progress_bar.set_hexpand(True) # Horizontally expand self.prog_bar_id: int = 0 self.log_dir = tempfile.TemporaryDirectory( prefix="clan_vm-", suffix=f"-{self.data.flake.flake_attr}" @@ -144,10 +146,12 @@ class VM(GObject.Object): def build_vm(self, vm: "VM", _vm: "VM", building: bool) -> None: if building: log.info("Building VM") + self.progress_bar.show() self.prog_bar_id = GLib.timeout_add(100, self._pulse_progress_bar) if self.prog_bar_id == 0: raise ClanError("Couldn't spawn a progess bar task") else: + self.progress_bar.hide() if not GLib.Source.remove(self.prog_bar_id): log.error("Failed to remove progress bar task") log.info("VM built") @@ -157,7 +161,14 @@ class VM(GObject.Object): vm = vms.run.inspect_vm(self.machine) GLib.idle_add(self.emit, "build_vm", self, True) - vms.run.build_vm(self.machine, vm, []) + self.process = spawn( + on_except=None, + log_dir=Path(str(self.log_dir.name)), + func=vms.run.build_vm, + machine=self.machine, + vm=vm, + ) + self.process.proc.join() GLib.idle_add(self.emit, "build_vm", self, False) self.process = spawn( diff --git a/pkgs/clan-vm-manager/clan_vm_manager/views/list.py b/pkgs/clan-vm-manager/clan_vm_manager/views/list.py index c011b3337..b28f14ce4 100644 --- a/pkgs/clan-vm-manager/clan_vm_manager/views/list.py +++ b/pkgs/clan-vm-manager/clan_vm_manager/views/list.py @@ -164,7 +164,8 @@ class ClanList(Gtk.Box): box = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=5) box.set_valign(Gtk.Align.CENTER) box.append(vm.progress_bar) - row.add_suffix(box) + box.set_homogeneous(False) + row.add_suffix(box) # This allows children to have different sizes # ==== Action buttons ==== switch = Gtk.Switch()