UI: Added process executor. Display vm status correctly in list. | CLI: Added get_qemu_version(), fixed virtio audio bug.
checks-impure / test (pull_request) Successful in 1m16s Details
checks / test (pull_request) Failing after 2m46s Details

This commit is contained in:
Luis Hebendanz 2023-12-26 18:02:43 +01:00
parent 4d8c20f284
commit ca265b0c59
11 changed files with 219 additions and 105 deletions

View File

@ -16,8 +16,9 @@ def url_ok(url: str) -> None:
try:
# Open the URL and get the response object
res = urllib.request.urlopen(req)
# Return True if the status code is 200 (OK)
if not res.status_code == 200:
if not res.getcode() == 200:
raise ClanError(f"URL has status code: {res.status_code}")
except urllib.error.URLError as ex:
raise ClanError(f"URL error: {ex}")

View File

@ -38,7 +38,7 @@ def inspect_flake(flake_url: str | Path, flake_attr: str) -> FlakeConfig:
]
)
proc = subprocess.run(cmd, text=True, capture_output=True)
proc = subprocess.run(cmd, text=True, stdout=subprocess.PIPE)
assert proc.stdout is not None
if proc.returncode != 0:
raise ClanError(
@ -47,8 +47,6 @@ command: {shlex.join(cmd)}
exit code: {proc.returncode}
stdout:
{proc.stdout}
stderr:
{proc.stderr}
"""
)
res = proc.stdout.strip()

View File

@ -22,7 +22,7 @@ def list_machines(flake_url: Path | str) -> list[str]:
"--json",
]
)
proc = subprocess.run(cmd, text=True, capture_output=True)
proc = subprocess.run(cmd, text=True, stdout=subprocess.PIPE)
assert proc.stdout is not None
if proc.returncode != 0:
raise ClanError(
@ -31,8 +31,6 @@ command: {shlex.join(cmd)}
exit code: {proc.returncode}
stdout:
{proc.stdout}
stderr:
{proc.stderr}
"""
)
res = proc.stdout.strip()

View File

@ -18,8 +18,29 @@ from .inspect import VmConfig, inspect_vm
log = logging.getLogger(__name__)
def get_qemu_version() -> list[int]:
# Run the command and capture the output
output = subprocess.check_output(["qemu-kvm", "--version"])
# Decode the output from bytes to string
output_str = output.decode("utf-8")
# Split the output by newline and get the first line
first_line = output_str.split("\n")[0]
# Split the first line by space and get the third element
version = first_line.split(" ")[3]
# Split the version by dot and convert each part to integer
version_list = [int(x) for x in version.split(".")]
# Return the version as a list of integers
return version_list
def graphics_options(vm: VmConfig) -> list[str]:
common = ["-audio", "driver=pa,model=virtio"]
common: list[str] = []
# Check if the version is greater than 8.1.3 to enable virtio audio
if get_qemu_version() > [8, 1, 3]:
common = ["-audio", "driver=pa,model=virtio"]
if vm.wayland:
# fmt: off
return [

View File

@ -8,17 +8,18 @@
},
{
"path": "../clan-cli/tests"
}
},
],
"settings": {
"python.linting.mypyEnabled": true,
"files.exclude": {
"**/.direnv": true,
"**/.mypy_cache": true,
"**/.ruff_cache": true,
"**/.hypothesis": true,
"**/__pycache__": true,
"**/.reports": true
"**/.direnv": true,
"**/.hypothesis": true,
"**/.mypy_cache": true,
"**/.reports": true,
"**/.ruff_cache": true,
"**/result": true
},
"search.exclude": {
"**/.direnv": true,
@ -29,4 +30,4 @@
"**/.reports": true
}
}
}
}

View File

@ -1,14 +1,18 @@
#!/usr/bin/env python3
import argparse
from dataclasses import dataclass
from pathlib import Path
import gi
from clan_cli import vms
gi.require_version("Gtk", "3.0")
from clan_cli.clan_uri import ClanURI
from gi.repository import Gio, Gtk
from .constants import constants
from .executor import ProcessManager, spawn
from .interfaces import Callbacks, InitialJoinValues
from .windows.join import JoinWindow
from .windows.overview import OverviewWindow
@ -33,8 +37,15 @@ class Application(Gtk.Application):
)
self.init_style()
self.windows = windows
self.proc_manager = ProcessManager()
initial = windows.__dict__[config.initial_window]
self.cbs = Callbacks(show_list=self.show_list, show_join=self.show_join)
self.cbs = Callbacks(
show_list=self.show_list,
show_join=self.show_join,
spawn_vm=self.spawn_vm,
stop_vm=self.stop_vm,
running_vms=self.running_vms,
)
if issubclass(initial, JoinWindow):
# see JoinWindow constructor
self.window = initial(
@ -46,6 +57,37 @@ class Application(Gtk.Application):
# see OverviewWindow constructor
self.window = initial(cbs=self.cbs)
# Connect to the shutdown signal
self.connect("shutdown", self.on_shutdown)
def on_shutdown(self, app: Gtk.Application) -> None:
print("Shutting down")
self.proc_manager.kill_all()
def spawn_vm(self, url: str, attr: str) -> None:
print(f"spawn_vm {url}")
# TODO: We should use VMConfig from the history file
vm = vms.run.inspect_vm(flake_url=url, flake_attr=attr)
log_path = Path(".")
# TODO: We only use the url as the ident. This is not unique as the flake_attr is missing.
# when we migrate everything to use the ClanURI class we can use the full url as the ident
self.proc_manager.spawn(
ident=url,
wait_stdin_con=False,
log_path=log_path,
func=vms.run.run_vm,
vm=vm,
)
def stop_vm(self, url: str, attr: str) -> None:
print(f"stop_vm {url}")
self.proc_manager.kill(url)
def running_vms(self) -> list[str]:
return list(self.proc_manager.procs.keys())
def show_list(self) -> None:
prev = self.window
self.window = self.windows.__dict__["overview"](cbs=self.cbs)
@ -125,10 +167,6 @@ def dummy_f(msg: str) -> None:
def show_run_vm(parser: argparse.ArgumentParser) -> None:
from pathlib import Path
from .executor import spawn
log_path = Path(".").resolve()
proc = spawn(wait_stdin_con=True, log_path=log_path, func=dummy_f, msg="Hello")
input("Press enter to kill process: ")

View File

@ -2,34 +2,44 @@ import os
import signal
import sys
import traceback
import weakref
from pathlib import Path
from typing import Any
import gi
from clan_cli.errors import ClanError
gi.require_version("GdkPixbuf", "2.0")
import dataclasses
import multiprocessing as mp
from collections.abc import Callable
OUT_FILE: Path | None = None
IN_FILE: Path | None = None
# Kill the new process and all its children by sending a SIGTERM signal to the process group
def _kill_group(proc: mp.Process) -> None:
pid = proc.pid
assert pid is not None
if proc.is_alive():
print(
f"Killing process group pid={pid}",
file=sys.stderr,
)
os.killpg(pid, signal.SIGTERM)
else:
print(f"Process {proc.name} with pid {pid} is already dead", file=sys.stderr)
@dataclasses.dataclass(frozen=True)
class MPProcess:
def __init__(
self, *, name: str, proc: mp.Process, out_file: Path, in_file: Path
) -> None:
self.name = name
self.proc = proc
self.out_file = out_file
self.in_file = in_file
name: str
proc: mp.Process
out_file: Path
in_file: Path
# Kill the new process and all its children by sending a SIGTERM signal to the process group
def kill_group(self) -> None:
pid = self.proc.pid
assert pid is not None
os.killpg(pid, signal.SIGTERM)
_kill_group(proc=self.proc)
def _set_proc_name(name: str) -> None:
@ -51,23 +61,6 @@ def _set_proc_name(name: str) -> None:
prctl(15, name.encode(), 0, 0, 0)
def _signal_handler(signum: int, frame: Any) -> None:
signame = signal.strsignal(signum)
print("Signal received:", signame)
# Delete files
if OUT_FILE is not None:
OUT_FILE.unlink()
if IN_FILE is not None:
IN_FILE.unlink()
# Restore the default handler
signal.signal(signal.SIGTERM, signal.SIG_DFL)
# Re-raise the signal
os.kill(os.getpid(), signum)
def _init_proc(
func: Callable,
out_file: Path,
@ -76,39 +69,29 @@ def _init_proc(
proc_name: str,
**kwargs: Any,
) -> None:
# Set the global variables
global OUT_FILE, IN_FILE
OUT_FILE = out_file
IN_FILE = in_file
# Create a new process group
os.setsid()
# Open stdout and stderr
out_fd = os.open(str(out_file), flags=os.O_RDWR | os.O_CREAT | os.O_TRUNC)
os.dup2(out_fd, sys.stdout.fileno())
os.dup2(out_fd, sys.stderr.fileno())
with open(out_file, "w") as out_fd:
os.dup2(out_fd.fileno(), sys.stdout.fileno())
os.dup2(out_fd.fileno(), sys.stderr.fileno())
# Print some information
pid = os.getpid()
gpid = os.getpgid(pid=pid)
print(f"Started new process pid={pid} gpid={gpid}")
# Register the signal handler for SIGINT
signal.signal(signal.SIGTERM, _signal_handler)
print(f"Started new process pid={pid} gpid={gpid}", file=sys.stderr)
# Set the process name
_set_proc_name(proc_name)
# Open stdin
flags = None
if wait_stdin_connect:
print(f"Waiting for stdin connection on file {in_file}", file=sys.stderr)
flags = os.O_RDONLY
with open(in_file) as in_fd:
os.dup2(in_fd.fileno(), sys.stdin.fileno())
else:
flags = os.O_RDONLY | os.O_NONBLOCK
in_fd = os.open(str(in_file), flags=flags)
os.dup2(in_fd, sys.stdin.fileno())
sys.stdin.close()
# Execute the main function
print(f"Executing function {func.__name__} now", file=sys.stderr)
@ -116,9 +99,10 @@ def _init_proc(
func(**kwargs)
except Exception:
traceback.print_exc()
finally:
pid = os.getpid()
gpid = os.getpgid(pid=pid)
print(f"Killing process group pid={pid} gpid={gpid}")
print(f"Killing process group pid={pid} gpid={gpid}", file=sys.stderr)
os.killpg(gpid, signal.SIGTERM)
@ -127,7 +111,13 @@ def spawn(
) -> MPProcess:
# Decouple the process from the parent
if mp.get_start_method(allow_none=True) is None:
mp.set_start_method(method="spawn")
mp.set_start_method(method="forkserver")
print("Set mp start method to forkserver", file=sys.stderr)
if not log_path.is_dir():
raise ClanError(f"Log path {log_path} is not a directory")
if not log_path.exists():
log_path.mkdir(parents=True)
# Set names
proc_name = f"MPExec:{func.__name__}"
@ -152,6 +142,7 @@ def spawn(
assert proc.pid is not None
print(f"Started process '{proc_name}'")
print(f"Arguments: {kwargs}")
if wait_stdin_con:
cmd = f"cat - > {in_file}"
print(f"Connect to stdin with : {cmd}")
@ -165,4 +156,41 @@ def spawn(
out_file=out_file,
in_file=in_file,
)
return mp_proc
# Processes are killed when the ProcessManager is garbage collected
class ProcessManager:
def __init__(self) -> None:
self.procs: dict[str, MPProcess] = dict()
self._finalizer = weakref.finalize(self, self.kill_all)
def spawn(
self,
*,
ident: str,
wait_stdin_con: bool,
log_path: Path,
func: Callable,
**kwargs: Any,
) -> MPProcess:
proc = spawn(
wait_stdin_con=wait_stdin_con, log_path=log_path, func=func, **kwargs
)
if ident in self.procs:
raise ClanError(f"Process with id {ident} already exists")
self.procs[ident] = proc
return proc
def kill_all(self) -> None:
print("Killing all processes", file=sys.stderr)
for proc in self.procs.values():
proc.kill_group()
def kill(self, ident: str) -> None:
if ident not in self.procs:
raise ClanError(f"Process with id {ident} does not exist")
proc = self.procs[ident]
proc.kill_group()
del self.procs[ident]

View File

@ -13,3 +13,6 @@ class InitialJoinValues:
class Callbacks:
show_list: Callable[[], None]
show_join: Callable[[], None]
spawn_vm: Callable[[str, str], None]
stop_vm: Callable[[str, str], None]
running_vms: Callable[[], list[str]]

View File

@ -19,6 +19,7 @@ class VMBase:
name: str
url: str
status: bool
_flake_attr: str
@staticmethod
def name_to_type_map() -> OrderedDict[str, type]:
@ -28,6 +29,7 @@ class VMBase:
"Name": str,
"URL": str,
"Online": bool,
"_FlakeAttr": str,
}
)
@ -42,13 +44,10 @@ class VMBase:
"Name": self.name,
"URL": self.url,
"Online": self.status,
"_FlakeAttr": self._flake_attr,
}
)
def run(self) -> None:
print(f"Running VM {self.name}")
# vm = vms.run.inspect_vm(flake_url=self.url, flake_attr="defaultVM")
@dataclass(frozen=True)
class VM:
@ -60,7 +59,9 @@ class VM:
# start/end indexes can be used optionally for pagination
def get_initial_vms(start: int = 0, end: int | None = None) -> list[VM]:
def get_initial_vms(
running_vms: list[str], start: int = 0, end: int | None = None
) -> list[VM]:
vm_list = []
# Execute `clan flakes add <path>` to democlan for this to work
@ -69,11 +70,16 @@ def get_initial_vms(start: int = 0, end: int | None = None) -> list[VM]:
if entry.flake.icon is not None:
icon = entry.flake.icon
status = False
if entry.flake.flake_url in running_vms:
status = True
base = VMBase(
icon=icon,
name=entry.flake.clan_name,
url=entry.flake.flake_url,
status=False,
status=status,
_flake_attr=entry.flake.flake_attr,
)
vm_list.append(VM(base=base))

View File

@ -2,7 +2,8 @@ from collections.abc import Callable
from gi.repository import GdkPixbuf, Gtk
from ..models import VMBase, get_initial_vms
from ..interfaces import Callbacks
from ..models import VMBase
class ClanEditForm(Gtk.ListBox):
@ -55,11 +56,7 @@ class ClanEdit(Gtk.Box):
self.show_list = remount_list
self.selected = selected_vm
button_hooks = {
"on_save_clicked": self.on_save,
}
self.toolbar = ClanEditToolbar(**button_hooks)
self.toolbar = ClanEditToolbar(on_save_clicked=self.on_save)
self.add(self.toolbar)
self.add(ClanEditForm(selected=self.selected))
@ -88,8 +85,9 @@ class ClanList(Gtk.Box):
remount_list: Callable[[], None],
remount_edit: Callable[[], None],
set_selected: Callable[[VMBase | None], None],
show_join: Callable[[], None],
cbs: Callbacks,
selected_vm: VMBase | None,
vms: list[VMBase],
show_toolbar: bool = True,
) -> None:
super().__init__(orientation=Gtk.Orientation.VERTICAL, expand=True)
@ -98,35 +96,45 @@ class ClanList(Gtk.Box):
self.remount_list_view = remount_list
self.set_selected = set_selected
self.show_toolbar = show_toolbar
self.show_join = show_join
self.cbs = cbs
self.selected_vm: VMBase | None = selected_vm
button_hooks = {
"on_start_clicked": self.on_start_clicked,
"on_stop_clicked": self.on_stop_clicked,
"on_edit_clicked": self.on_edit_clicked,
"on_join_clicked": self.on_join_clicked,
}
if show_toolbar:
self.toolbar = ClanListToolbar(**button_hooks)
self.toolbar = ClanListToolbar(
on_start_clicked=self.on_start_clicked,
on_stop_clicked=self.on_stop_clicked,
on_edit_clicked=self.on_edit_clicked,
on_join_clicked=self.on_join_clicked,
)
self.toolbar.set_is_selected(self.selected_vm is not None)
self.add(self.toolbar)
self.list_hooks = {
"on_select_row": self.on_select_vm,
}
self.add(ClanListView(**self.list_hooks, selected_vm=selected_vm))
self.add(
ClanListView(
vms=vms,
on_select_row=self.on_select_vm,
selected_vm=selected_vm,
on_double_click=self.on_double_click,
)
)
def on_double_click(self, vm: VMBase) -> None:
print(f"on_double_click: {vm.name}")
self.on_start_clicked(self)
def on_start_clicked(self, widget: Gtk.Widget) -> None:
print("Start clicked")
if self.selected_vm:
self.selected_vm.run()
self.cbs.spawn_vm(self.selected_vm.url, self.selected_vm._flake_attr)
# Call this to reload
self.remount_list_view()
def on_stop_clicked(self, widget: Gtk.Widget) -> None:
print("Stop clicked")
if self.selected_vm:
self.cbs.stop_vm(self.selected_vm.url, self.selected_vm._flake_attr)
self.remount_list_view()
def on_join_clicked(self, widget: Gtk.Widget) -> None:
print("Join clicked")
@ -208,10 +216,13 @@ class ClanListView(Gtk.Box):
*,
on_select_row: Callable[[VMBase], None],
selected_vm: VMBase | None,
vms: list[VMBase],
on_double_click: Callable[[VMBase], None],
) -> None:
super().__init__(expand=True)
self.vms: list[VMBase] = [vm.base for vm in get_initial_vms()]
self.vms: list[VMBase] = vms
self.on_select_row = on_select_row
self.on_double_click = on_double_click
store_types = VMBase.name_to_type_map().values()
self.list_store = Gtk.ListStore(*store_types)
@ -264,7 +275,7 @@ class ClanListView(Gtk.Box):
model, row = selection.get_selected()
if row is not None:
vm = VMBase(*model[row])
vm.run()
self.on_double_click(vm)
def setColRenderers(tree_view: Gtk.TreeView) -> None:

View File

@ -2,7 +2,7 @@ from typing import Any
import gi
from ..models import VMBase
from ..models import VMBase, get_initial_vms
gi.require_version("Gtk", "3.0")
@ -19,18 +19,20 @@ class OverviewWindow(Gtk.ApplicationWindow):
self.set_title("cLAN Manager")
self.connect("delete-event", self.on_quit)
self.set_default_size(800, 600)
self.cbs = cbs
vbox = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=6, expand=True)
self.add(vbox)
self.stack = Gtk.Stack()
self.list_hooks = {
"remount_list": self.remount_list_view,
"remount_edit": self.remount_edit_view,
"set_selected": self.set_selected,
"show_join": cbs.show_join,
}
clan_list = ClanList(**self.list_hooks, selected_vm=None) # type: ignore
clan_list = ClanList(
vms=[vm.base for vm in get_initial_vms(self.cbs.running_vms())],
cbs=self.cbs,
remount_list=self.remount_list_view,
remount_edit=self.remount_edit_view,
set_selected=self.set_selected,
selected_vm=None,
)
# Add named stacks
self.stack.add_titled(clan_list, "list", "List")
self.stack.add_titled(
@ -59,7 +61,14 @@ class OverviewWindow(Gtk.ApplicationWindow):
if widget:
widget.destroy()
clan_list = ClanList(**self.list_hooks, selected_vm=self.selected_vm) # type: ignore
clan_list = ClanList(
vms=[vm.base for vm in get_initial_vms(self.cbs.running_vms())],
cbs=self.cbs,
remount_list=self.remount_list_view,
remount_edit=self.remount_edit_view,
set_selected=self.set_selected,
selected_vm=self.selected_vm,
)
self.stack.add_titled(clan_list, "list", "List")
self.show_all()
self.stack.set_visible_child_name("list")