Initial Commit
This commit is contained in:
Executable
+949
@@ -0,0 +1,949 @@
|
||||
#!/usr/bin/env python3
|
||||
"""The operator's tool for changing a frozen base image.
|
||||
|
||||
WHY THIS EXISTS AND THE AGENT DOES NOT GET ONE
|
||||
|
||||
Every VM session starts from a read-only base image, so nothing an agent
|
||||
installs survives it. That is the guarantee, and it is the right one -
|
||||
but it means there has to be some way to put software into the base, and
|
||||
it has to be a way the agent cannot reach.
|
||||
|
||||
This is it. It starts units that run as a different user and are
|
||||
authorised by polkit against an active session of the operator:
|
||||
owuivm-maint@<image> to boot one, owuivm-thaw@<image> and
|
||||
owuivm-freeze@<image> to change whether it is writable. Those two are
|
||||
the only things on this host that touch the write bit on a base image. The agent's `request_unfreeze` produces a sentence in a report
|
||||
that a person then reads; it does not produce a writable disk.
|
||||
|
||||
Frozen is literally the owner write bit, so the window can just show
|
||||
`ls -l` and be telling the truth.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import gi
|
||||
|
||||
gi.require_version("Gtk", "4.0")
|
||||
from gi.repository import Gio, GLib, Gtk # noqa: E402
|
||||
|
||||
import fnmatch # noqa: E402
|
||||
import json # noqa: E402
|
||||
import os # noqa: E402
|
||||
import shutil # noqa: E402
|
||||
import subprocess # noqa: E402
|
||||
import threading # noqa: E402
|
||||
import time # noqa: E402
|
||||
import tomllib # noqa: E402
|
||||
|
||||
BASE = os.environ.get("OWUIVM_BASE_DIR", "/var/lib/owuivm/base")
|
||||
ISOS = os.environ.get("OWUIVM_ISO_DIR", "/var/lib/owuivm/iso")
|
||||
WARM = os.environ.get("OWUIVM_WARM_DIR", "/var/lib/owuivm/warm")
|
||||
# Where a disc waits between the operator choosing it and the unit
|
||||
# checking it. The operator OWNS this directory, which is the whole
|
||||
# trick: the copy out of ~/Downloads needs no privilege, and the
|
||||
# privileged unit never reads the operator's home. See the header of
|
||||
# owuivm-insert-media.
|
||||
INCOMING = os.environ.get("OWUIVM_INCOMING_DIR", "/var/lib/owuivm/incoming")
|
||||
REGISTRY = os.environ.get("OWUIVM_REGISTRY", "/var/lib/owuivm/registry/vms.toml")
|
||||
NAME_OVERRIDES = os.environ.get("OWUIVM_NAME_OVERRIDES",
|
||||
"/var/lib/owuivm/registry/vm-names.toml")
|
||||
VNC_SOCKET = os.environ.get("OWUIVM_VNC_SOCKET", "/run/owuivm-maint/vnc.sock")
|
||||
VNC = os.environ.get("OWUIVM_VNC", "127.0.0.1:5999")
|
||||
# No DISK_SIZE and no OVMF_VARS here. Both were constants this tool used
|
||||
# when it created disks itself, and a copy of a value that really lives in
|
||||
# owuivm-maint-prepare is a copy that will disagree with it one day.
|
||||
|
||||
# The shipped registry, used only when /etc/owuivm/vms.toml cannot be
|
||||
# read. This window is how an operator finds out their machines are in a
|
||||
# bad way, so it has to open and say so rather than fail to start.
|
||||
_FALLBACK = [{"id": "ubuntu", "name": "Ubuntu", "iso": "ubuntu*.iso"},
|
||||
{"id": "windows", "name": "Windows", "iso": "[Ww]in*.iso"}]
|
||||
|
||||
|
||||
def machines() -> list[dict]:
|
||||
"""The machines, in registry order, with the operator's renames applied.
|
||||
|
||||
`id` is identity - the disk filename, the unit instance, the value
|
||||
the assistant passes as `os` - and is never touched here. `name` is a
|
||||
label and is the only thing Rename changes; it lives in a separate
|
||||
override file so that renaming a machine cannot rewrite the registry
|
||||
and lose the comments that are most of its value.
|
||||
"""
|
||||
try:
|
||||
with open(REGISTRY, "rb") as handle:
|
||||
entries = tomllib.load(handle).get("machine", []) or _FALLBACK
|
||||
except (OSError, tomllib.TOMLDecodeError):
|
||||
entries = _FALLBACK
|
||||
try:
|
||||
with open(NAME_OVERRIDES, "rb") as handle:
|
||||
renamed = tomllib.load(handle).get("names", {})
|
||||
except (OSError, tomllib.TOMLDecodeError):
|
||||
renamed = {}
|
||||
|
||||
found = []
|
||||
for entry in entries:
|
||||
machine_id = str(entry.get("id") or "").strip()
|
||||
if not machine_id:
|
||||
continue
|
||||
found.append({
|
||||
"id": machine_id,
|
||||
"name": str(renamed.get(machine_id) or entry.get("name") or machine_id),
|
||||
"iso": str(entry.get("iso") or f"{machine_id}*.iso"),
|
||||
})
|
||||
return found or _FALLBACK
|
||||
|
||||
|
||||
def ids() -> list[str]:
|
||||
return [m["id"] for m in machines()]
|
||||
|
||||
|
||||
def unit_for(name: str) -> str:
|
||||
return f"owuivm-maint@{name}.service"
|
||||
|
||||
|
||||
def run(*args: str, timeout: float = 20.0) -> tuple[int, str]:
|
||||
"""Blocking. Only for things that finish in well under a second."""
|
||||
try:
|
||||
result = subprocess.run(list(args), capture_output=True, text=True,
|
||||
timeout=timeout, check=False)
|
||||
return result.returncode, (result.stdout + result.stderr).strip()
|
||||
except Exception as exc: # noqa: BLE001
|
||||
return 1, str(exc)
|
||||
|
||||
|
||||
def run_async(args: list[str], timeout: float, on_done) -> None:
|
||||
"""Run something slow off the main thread and report back on it.
|
||||
|
||||
GTK repaints from the main loop, so a `subprocess.run` there freezes
|
||||
the window for as long as it takes - and stopping a machine now takes
|
||||
as long as the guest needs to shut down, up to the unit's
|
||||
TimeoutStopSec. Pressing Shut down locked the interface for over a
|
||||
minute, which looks exactly like a crash.
|
||||
|
||||
The worker touches nothing but its own locals; the result comes back
|
||||
through GLib.idle_add, which runs the callback on the main loop where
|
||||
it is safe to touch widgets.
|
||||
"""
|
||||
def worker() -> None:
|
||||
code, out = run(*args, timeout=timeout)
|
||||
GLib.idle_add(on_done, code, out)
|
||||
|
||||
threading.Thread(target=worker, daemon=True).start()
|
||||
|
||||
|
||||
def image_state(name: str) -> dict:
|
||||
path = os.path.join(BASE, f"{name}.qcow2")
|
||||
running = run("systemctl", "is-active", "--quiet", unit_for(name))[0] == 0
|
||||
installing = os.path.exists(os.path.join(BASE, f"{name}.install"))
|
||||
if not os.path.exists(path):
|
||||
return {"exists": False, "frozen": True, "size": 0,
|
||||
"running": running, "installing": installing}
|
||||
mode = os.stat(path).st_mode
|
||||
return {
|
||||
"exists": True,
|
||||
# 0200 is the owner write bit. Nothing else is consulted, because
|
||||
# nothing else is what the freeze consists of.
|
||||
"frozen": not bool(mode & 0o200),
|
||||
"size": os.path.getsize(path),
|
||||
"running": running,
|
||||
"installing": installing,
|
||||
}
|
||||
|
||||
|
||||
def snapshot_state(name: str) -> str:
|
||||
"""Whether this image has a usable fast-start snapshot, in a word.
|
||||
|
||||
Read from the files rather than asked of vmd: this tool starts units
|
||||
and reads `ls -l`, and adding a socket client to it would make the
|
||||
window depend on a daemon being up to tell the operator what is on
|
||||
their own disk.
|
||||
"""
|
||||
warm = os.path.join(WARM, name)
|
||||
manifest = os.path.join(warm, "manifest.json")
|
||||
if not os.path.isfile(manifest):
|
||||
return "none"
|
||||
try:
|
||||
with open(manifest, encoding="utf-8") as handle:
|
||||
recorded = str(json.load(handle).get("base_sha256") or "")
|
||||
except (OSError, ValueError):
|
||||
return "unreadable"
|
||||
current = ""
|
||||
try:
|
||||
with open(os.path.join(BASE, f"{name}.sha256"), encoding="utf-8") as handle:
|
||||
for line in handle:
|
||||
digest, _, path = line.partition(" ")
|
||||
if path.strip().endswith(f"{name}.qcow2"):
|
||||
current = digest.strip()
|
||||
except OSError:
|
||||
pass
|
||||
if not current or not recorded:
|
||||
return "unreadable"
|
||||
return "usable" if current == recorded else "stale"
|
||||
|
||||
|
||||
def media_for(name: str) -> str:
|
||||
"""The installation media the unit will pick for this image, or "".
|
||||
|
||||
THE SAME RULE AS owuivm-maint-prepare, ON PURPOSE.
|
||||
|
||||
This tool does not choose the media any more - it reports what the
|
||||
unit is going to do. The rule is the newest ISO matching the image's
|
||||
own glob, and that glob is `[Ww]in*.iso` for Windows rather than
|
||||
`*.iso` because the newest ISO in that directory, with an Ubuntu one
|
||||
sitting next to it, is how a Windows install would quietly boot the
|
||||
Ubuntu installer.
|
||||
|
||||
The glob now comes from the registry rather than from a prefix test
|
||||
here, so there is one statement of it - and a machine added to
|
||||
configs/vms.toml brings its own.
|
||||
"""
|
||||
pattern = next((m["iso"] for m in machines() if m["id"] == name), f"{name}*.iso")
|
||||
try:
|
||||
candidates = [f for f in os.listdir(ISOS) if fnmatch.fnmatch(f, pattern)]
|
||||
except OSError:
|
||||
return ""
|
||||
if not candidates:
|
||||
return ""
|
||||
return max(candidates, key=lambda f: os.path.getmtime(os.path.join(ISOS, f)))
|
||||
|
||||
|
||||
# THERE IS NO prepare_install() HERE ANY MORE, AND THAT IS THE FIX.
|
||||
#
|
||||
# This tool used to create the blank disk, copy the firmware variables
|
||||
# and write the install marker itself, as the operator. It failed with a
|
||||
# bare permission error the first time it was used for Windows, and the
|
||||
# reason is the trap written at the top of owuivm-maint-prepare: `operator`
|
||||
# IS a member of owuivmadm in /etc/group, but a unix group only reaches a
|
||||
# process at its NEXT login, and the desktop session running this tool
|
||||
# predates the usermod that added it. So the group is there, `id` agrees,
|
||||
# and no running process has it.
|
||||
#
|
||||
# It was also quietly wrong for Windows in a second way: it copied the
|
||||
# plain OVMF variables for both images, where Windows needs the store
|
||||
# with Microsoft's keys already enrolled (OVMF_VARS_4M.ms.fd) or Secure
|
||||
# Boot is on with nothing it trusts and setup refuses the machine - a
|
||||
# failure that would have shown up much later and looked like hardware.
|
||||
#
|
||||
# The unit's ExecStartPre does all of it, as owuivmadm, and has done it
|
||||
# correctly the whole time. This tool starts units and touches nothing.
|
||||
|
||||
|
||||
class Window(Gtk.ApplicationWindow):
|
||||
def __init__(self, app: Gtk.Application) -> None:
|
||||
super().__init__(application=app, title="Tool Server VM Maintenance")
|
||||
self.set_default_size(680, 360)
|
||||
|
||||
outer = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=12)
|
||||
outer.set_margin_top(16); outer.set_margin_bottom(16)
|
||||
outer.set_margin_start(16); outer.set_margin_end(16)
|
||||
self.set_child(outer)
|
||||
|
||||
intro = Gtk.Label(xalign=0, wrap=True)
|
||||
intro.set_markup(
|
||||
"<b>Base images for the VM sub-agent.</b>\n"
|
||||
"Every session runs from a read-only copy of these, so nothing the agent "
|
||||
"installs survives. <b>Start</b> boots a frozen image the same way, on a "
|
||||
"throwaway copy — use it to check that an image still comes up. "
|
||||
"<b>Unfreeze</b> when you actually mean to change one, and freeze it again "
|
||||
"afterwards: no session can start while an image is writable."
|
||||
)
|
||||
outer.append(intro)
|
||||
|
||||
# The console bridge, started on demand and stopped with the VM.
|
||||
self._socat: subprocess.Popen | None = None
|
||||
# Set while a start or stop is in flight. The periodic refresh
|
||||
# must not re-enable buttons underneath an operation that has not
|
||||
# finished, and nothing may be pressed twice.
|
||||
self._busy = False
|
||||
self.rows: dict[str, dict] = {}
|
||||
for machine in machines():
|
||||
outer.append(self._build_row(machine))
|
||||
|
||||
self.status = Gtk.Label(xalign=0, wrap=True)
|
||||
self.status.add_css_class("dim-label")
|
||||
outer.append(self.status)
|
||||
|
||||
self.refresh()
|
||||
GLib.timeout_add_seconds(3, self._tick)
|
||||
|
||||
def _build_row(self, machine: dict) -> Gtk.Widget:
|
||||
name = machine["id"]
|
||||
frame = Gtk.Frame()
|
||||
box = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=12)
|
||||
box.set_margin_top(10); box.set_margin_bottom(10)
|
||||
box.set_margin_start(12); box.set_margin_end(12)
|
||||
frame.set_child(box)
|
||||
|
||||
labels = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=2, hexpand=True)
|
||||
title = Gtk.Label(xalign=0)
|
||||
title.set_markup(f"<b>{GLib.markup_escape_text(machine['name'])}</b>")
|
||||
detail = Gtk.Label(xalign=0)
|
||||
detail.add_css_class("dim-label")
|
||||
labels.append(title); labels.append(detail)
|
||||
box.append(labels)
|
||||
|
||||
# The label is the operator's, the id is not. Renaming writes an
|
||||
# override file through a unit; nothing about the disk, the unit
|
||||
# instance or the `os` the assistant passes ever changes, which is
|
||||
# why this is safe to offer at all.
|
||||
rename = Gtk.Button(label="Rename")
|
||||
rename.connect("clicked", self.on_rename, name)
|
||||
box.append(rename)
|
||||
|
||||
# FREEZING IS NOT A SIDE EFFECT OF STARTING.
|
||||
#
|
||||
# It used to be: ExecStartPre unfroze and ExecStopPost re-froze,
|
||||
# so the only way to look at an image was to make it writable
|
||||
# first, and there was no unfreeze button because there was
|
||||
# nothing for it to do. That is backwards for the most useful
|
||||
# thing here - booting the image exactly as the agent will get
|
||||
# it, to see that it comes up, with no way to change it by being
|
||||
# there.
|
||||
#
|
||||
# So this button owns the write bit and nothing else does. Start
|
||||
# then boots whatever mode the disk is in: frozen boots a
|
||||
# throwaway copy, unfrozen boots the image itself.
|
||||
mode = Gtk.Button(label="Unfreeze")
|
||||
mode.connect("clicked", self.on_mode, name)
|
||||
box.append(mode)
|
||||
|
||||
# Buttons that mean one thing each, rather than one button whose
|
||||
# meaning depends on state. Shutting a machine down and starting
|
||||
# one are different enough that the operator should not have to
|
||||
# read the label to find out which they are about to do.
|
||||
start = Gtk.Button(label="Start")
|
||||
start.connect("clicked", self.on_start, name)
|
||||
box.append(start)
|
||||
|
||||
stop = Gtk.Button(label="Shut down")
|
||||
stop.add_css_class("destructive-action")
|
||||
stop.connect("clicked", self.on_stop, name)
|
||||
box.append(stop)
|
||||
|
||||
stop.set_tooltip_text("Asks the guest to power off and waits for it.")
|
||||
|
||||
# SAVING A MACHINE IS AN OPERATOR DECISION, AND IT HAS TO BE VISIBLE.
|
||||
#
|
||||
# A saved machine is tied to the image it was taken from by a
|
||||
# checksum, so changing the image silently retires it - which is
|
||||
# correct, and would otherwise mean every session quietly went
|
||||
# back to booting from cold with nobody knowing why. The row says
|
||||
# which state it is in, and this is how it gets back to `usable`.
|
||||
snap = Gtk.Button(label="Save fast start")
|
||||
snap.connect("clicked", self.on_snapshot, name)
|
||||
box.append(snap)
|
||||
|
||||
console = Gtk.Button(label="Console")
|
||||
console.connect("clicked", self.on_console, name)
|
||||
box.append(console)
|
||||
|
||||
# Only ever visible while an install is in progress, which is the
|
||||
# only time the question it answers can be asked. Pressing it is
|
||||
# the operator saying "this image is installed now" - nothing on
|
||||
# this host can work that out for itself, and the one time
|
||||
# something tried to (a disk over 512 MB was taken for a finished
|
||||
# install) it declared a half-finished Ubuntu done and left the
|
||||
# machine at an EFI shell with no way back to the installer.
|
||||
detach = Gtk.Button(label="Installed — remove disc")
|
||||
detach.set_visible(False)
|
||||
detach.connect("clicked", self.on_detach, name)
|
||||
box.append(detach)
|
||||
|
||||
# Only while there is no disk yet, because that is the only time
|
||||
# it answers a question. Before this existed, a machine with no
|
||||
# media said "not installed, and no ubuntu ISO in
|
||||
# /var/lib/owuivm/iso" with a dead Start button and no way
|
||||
# forward that did not involve a terminal.
|
||||
insert = Gtk.Button(label="Insert disc…")
|
||||
insert.set_visible(False)
|
||||
insert.connect("clicked", self.on_insert, name)
|
||||
box.append(insert)
|
||||
|
||||
self.rows[name] = {"title": title, "detail": detail, "mode": mode, "start": start,
|
||||
"stop": stop, "console": console, "rename": rename,
|
||||
"detach": detach, "snap": snap, "insert": insert}
|
||||
return frame
|
||||
|
||||
# -- state -----------------------------------------------------------
|
||||
def _tick(self) -> bool:
|
||||
self.refresh()
|
||||
return True
|
||||
|
||||
def refresh(self) -> None:
|
||||
for machine in machines():
|
||||
name = machine["id"]
|
||||
state = image_state(name)
|
||||
row = self.rows[name]
|
||||
fresh = not state["exists"] and not state["running"]
|
||||
# Re-read every tick so a rename shows up without a restart,
|
||||
# and so does one made by editing the override file by hand.
|
||||
row["title"].set_markup(f"<b>{GLib.markup_escape_text(machine['name'])}</b>")
|
||||
row["rename"].set_sensitive(not self._busy)
|
||||
row["start"].set_sensitive(not state["running"] and not self._busy)
|
||||
row["stop"].set_sensitive(state["running"] and not self._busy)
|
||||
row["console"].set_sensitive(state["running"] and not self._busy)
|
||||
|
||||
# One button, two verbs, and the label is the state: if it
|
||||
# says Unfreeze, the image is frozen. Dead while the machine
|
||||
# is up, because qemu already holds the disk open - a chmod
|
||||
# then would change what the mode SAYS and nothing else.
|
||||
row["mode"].set_label("Unfreeze" if state["frozen"] else "Freeze")
|
||||
row["mode"].set_sensitive(
|
||||
state["exists"] and not state["running"] and not self._busy)
|
||||
row["mode"].set_tooltip_text(
|
||||
"Shut the machine down first." if state["running"]
|
||||
else "Make the base image writable, so changes are kept."
|
||||
if state["frozen"]
|
||||
else "Make the base image read-only again. No session can start until it is.")
|
||||
if state["frozen"]:
|
||||
row["mode"].remove_css_class("destructive-action")
|
||||
else:
|
||||
row["mode"].add_css_class("destructive-action")
|
||||
# Deliberately dead while the machine is running: marking an
|
||||
# install finished is a claim about a machine that has been
|
||||
# shut down and frozen, and the disc is what a half-installed
|
||||
# one has to boot to be finished.
|
||||
snap = snapshot_state(name)
|
||||
row["snap"].set_sensitive(
|
||||
state["exists"] and state["frozen"] and not state["running"]
|
||||
and not state["installing"] and not self._busy)
|
||||
row["snap"].set_label({"usable": "Re-save fast start",
|
||||
"stale": "Fast start is STALE",
|
||||
"none": "Save fast start"}.get(snap, "Save fast start"))
|
||||
row["snap"].set_tooltip_text(
|
||||
"Shut the machine down first." if state["running"] else
|
||||
"Freeze the image first - a saved machine is tied to the image it "
|
||||
"was taken from." if not state["frozen"] else
|
||||
"Boot this image once and save it, so sessions resume in seconds "
|
||||
"instead of booting. Takes a few minutes.")
|
||||
if snap == "stale":
|
||||
row["snap"].add_css_class("destructive-action")
|
||||
else:
|
||||
row["snap"].remove_css_class("destructive-action")
|
||||
|
||||
row["detach"].set_visible(state["installing"])
|
||||
row["detach"].set_sensitive(
|
||||
state["installing"] and not state["running"] and not self._busy)
|
||||
row["detach"].set_tooltip_text(
|
||||
"Shut the machine down first." if state["running"]
|
||||
else "Stop booting the installer disc. The machine boots itself from now on.")
|
||||
|
||||
# Offering a disc is only meaningful while there is no disk:
|
||||
# once a machine is installed, media is chosen by the unit and
|
||||
# changing it would mean reinstalling.
|
||||
row["insert"].set_visible(fresh)
|
||||
row["insert"].set_sensitive(fresh and not self._busy)
|
||||
|
||||
if fresh:
|
||||
iso = media_for(name)
|
||||
row["detail"].set_text(
|
||||
f"not installed — will install from {iso}" if iso
|
||||
else f"not installed. Choose an installation disc with "
|
||||
f"Insert disc…, or put one in {ISOS}.")
|
||||
row["insert"].set_tooltip_text(
|
||||
f"Replace the disc this will install from ({iso})." if iso
|
||||
else "Choose an .iso to install this machine from.")
|
||||
row["start"].set_label("Install from ISO")
|
||||
row["start"].set_sensitive(bool(iso) and not self._busy)
|
||||
continue
|
||||
|
||||
row["start"].set_label("Start")
|
||||
size = f"{state['size'] / 1e9:.1f} GB" if state["exists"] else "installing"
|
||||
if state["running"] and state["installing"]:
|
||||
row["detail"].set_markup(
|
||||
f"{size} — <b>INSTALLING.</b> Drive the installer in the console. "
|
||||
"When it has finished, press Shut down.")
|
||||
elif state["running"] and state["frozen"]:
|
||||
row["detail"].set_markup(
|
||||
f"{size} — <b>running from a throwaway copy.</b> This is what the "
|
||||
"agent gets. Nothing you do in here is kept.")
|
||||
elif state["running"]:
|
||||
row["detail"].set_markup(
|
||||
f"{size} — <b>running UNFROZEN.</b> Changes are kept. Shut down, "
|
||||
"then press Freeze.")
|
||||
elif state["installing"] and state["frozen"]:
|
||||
# An install into a throwaway overlay is never what anyone
|
||||
# meant. It happened: a test left this image frozen, the
|
||||
# operator pressed Start, and a 16 GB Windows install went
|
||||
# into a disk that was going to be deleted - on the disk
|
||||
# array, which is four spinning drives, so it was also
|
||||
# much slower than it should have been.
|
||||
row["detail"].set_markup(
|
||||
f"{size} — <b>frozen, with an installer disc attached.</b> Starting it "
|
||||
"now would install into a throwaway copy and lose it. Press "
|
||||
"<b>Unfreeze</b> first.")
|
||||
elif state["installing"]:
|
||||
row["detail"].set_markup(
|
||||
f"{size} — unfrozen, with the installer disc attached. If the install "
|
||||
"is finished, press <b>Installed — remove disc</b>.")
|
||||
elif state["frozen"]:
|
||||
fast = {"usable": "Sessions resume from a saved machine in seconds.",
|
||||
"stale": "<b>The saved machine is out of date</b> - the image has "
|
||||
"changed since. Sessions are booting from cold until it "
|
||||
"is saved again.",
|
||||
"none": "Sessions boot this from cold."}.get(snap, "")
|
||||
row["detail"].set_markup(
|
||||
f"{size} - frozen. <b>Start</b> boots a throwaway copy of it, exactly "
|
||||
f"as the agent would; <b>Unfreeze</b> first to make changes that stick. "
|
||||
f"{fast}")
|
||||
else:
|
||||
# Loud on purpose. Nothing re-freezes an image any more,
|
||||
# and a session refuses to start while one is writable -
|
||||
# so this row is the difference between a working agent
|
||||
# and a confusing refusal an hour from now.
|
||||
row["detail"].set_markup(
|
||||
f"{size} — <b>UNFROZEN.</b> No agent session can start until this is "
|
||||
"frozen again.")
|
||||
|
||||
def say(self, text: str) -> None:
|
||||
self.status.set_text(text)
|
||||
|
||||
# -- actions ---------------------------------------------------------
|
||||
def on_stop(self, _button: Gtk.Button, name: str) -> None:
|
||||
"""Press the power button and wait for the guest.
|
||||
|
||||
`systemctl stop` is the whole of it: the unit's ExecStop sends an
|
||||
ACPI power-down over QMP and systemd waits for qemu to exit on
|
||||
its own, falling back to a kill after TimeoutStopSec. Before that
|
||||
existed, stopping the unit yanked power on the disk the operator
|
||||
was editing.
|
||||
"""
|
||||
if not image_state(name)["running"]:
|
||||
self.say(f"{name} is not running.")
|
||||
return
|
||||
self._stop_relay()
|
||||
self._busy = True
|
||||
self.refresh()
|
||||
self.say(f"Shutting {name} down — the guest is being asked to power off, which can "
|
||||
"take a minute. The window stays usable.")
|
||||
|
||||
def done(code: int, out: str) -> bool:
|
||||
self._busy = False
|
||||
self.say(f"{name} is shut down." if code == 0
|
||||
else f"Could not stop it cleanly: {out}")
|
||||
self.refresh()
|
||||
return False
|
||||
|
||||
run_async(["systemctl", "stop", unit_for(name)], 600, done)
|
||||
|
||||
def on_mode(self, _button: Gtk.Button, name: str) -> None:
|
||||
"""Freeze or unfreeze, whichever the image is not.
|
||||
|
||||
Both are one chmod in a directory this account does not own, so
|
||||
each is a polkit-gated unit - the same gate that starts a
|
||||
maintenance boot, and the same reason: a unix group would only
|
||||
take effect at the operator's next login.
|
||||
|
||||
Refused while the machine is running, and the button is dead
|
||||
then too. qemu holds the disk open from the moment it starts, so
|
||||
a chmod in the middle changes what `ls -l` says without changing
|
||||
anything about what is running - the worst kind of control.
|
||||
"""
|
||||
state = image_state(name)
|
||||
if not state["exists"]:
|
||||
self.say(f"There is no {name} image yet.")
|
||||
return
|
||||
if state["running"]:
|
||||
self.say(f"Shut {name} down first — its disk is open.")
|
||||
return
|
||||
verb = "thaw" if state["frozen"] else "freeze"
|
||||
self._busy = True
|
||||
self.refresh()
|
||||
|
||||
def done(code: int, out: str) -> bool:
|
||||
self._busy = False
|
||||
if code != 0:
|
||||
self.say(f"Could not {verb} {name}: {out}")
|
||||
elif verb == "thaw":
|
||||
self.say(f"{name} is unfrozen. Changes you make from now on are kept — "
|
||||
"and no agent session can start until you freeze it again.")
|
||||
else:
|
||||
self.say(f"{name} is frozen. Sessions can run, and starting it here now "
|
||||
"boots a throwaway copy.")
|
||||
self.refresh()
|
||||
return False
|
||||
|
||||
run_async(["systemctl", "start", f"owuivm-{verb}@{name}.service"], 120, done)
|
||||
|
||||
def on_snapshot(self, _button: Gtk.Button, name: str) -> None:
|
||||
"""Boot the image once and save it, so sessions resume in seconds.
|
||||
|
||||
Runs through vmd rather than here: it needs a real session, the
|
||||
machine it saves must be one an agent would be handed, and the
|
||||
state has to be written by the user that owns the saved-machine
|
||||
directory. This tool starts things and reports; it does not own
|
||||
any of that.
|
||||
"""
|
||||
state = image_state(name)
|
||||
if state["running"]:
|
||||
self.say(f"Shut {name} down first.")
|
||||
return
|
||||
if not state["frozen"]:
|
||||
self.say(f"Freeze {name} first - a saved machine is tied to the image it "
|
||||
"was taken from, and an unfrozen image is one that can still change.")
|
||||
return
|
||||
self._busy = True
|
||||
self.refresh()
|
||||
self.say(f"Booting {name} once and saving it. This takes a few minutes, and the "
|
||||
"window stays usable.")
|
||||
|
||||
def done(code: int, out: str) -> bool:
|
||||
self._busy = False
|
||||
self.say(f"{name} can now start from a saved machine." if code == 0
|
||||
else f"Could not save {name}: {out[-300:]}")
|
||||
self.refresh()
|
||||
return False
|
||||
|
||||
run_async(["sudo", "-n", "-u", "owuivmd", "/usr/local/bin/vmctl",
|
||||
"snapshot", "--os", name], 1800, done)
|
||||
|
||||
def on_detach(self, _button: Gtk.Button, name: str) -> None:
|
||||
"""Say that the install is finished, so the disc stops being attached.
|
||||
|
||||
The marker file lives in a directory this account does not own,
|
||||
so this goes through owuivm-detach@.service and the same polkit
|
||||
check that starts a maintenance boot. Doing it by hand here would
|
||||
need the owuivmadm group, and a unix group only takes effect at
|
||||
the operator's NEXT login - a trap this project has already
|
||||
walked into once, and one that fails confusingly rather than
|
||||
loudly.
|
||||
|
||||
Nothing is lost if it is pressed too early: the disc is still in
|
||||
/var/lib/owuivm/iso and a wrong call is undone by deleting the
|
||||
disk and installing again.
|
||||
"""
|
||||
state = image_state(name)
|
||||
if state["running"]:
|
||||
self.say(f"Shut {name} down first — then the disc can be removed.")
|
||||
return
|
||||
if not state["installing"]:
|
||||
self.say(f"{name} is not set to boot an installer.")
|
||||
return
|
||||
self._busy = True
|
||||
self.refresh()
|
||||
|
||||
def done(code: int, out: str) -> bool:
|
||||
self._busy = False
|
||||
self.say(f"{name} is installed. It boots itself from now on; the disc is "
|
||||
"no longer attached." if code == 0
|
||||
else f"Could not remove the disc: {out}")
|
||||
self.refresh()
|
||||
return False
|
||||
|
||||
run_async(["systemctl", "start", f"owuivm-detach@{name}.service"], 60, done)
|
||||
|
||||
# -- naming and media ------------------------------------------------
|
||||
def on_rename(self, _button: Gtk.Button, name: str) -> None:
|
||||
"""Change what a machine is called. Only what it is called.
|
||||
|
||||
The id stays: it is the disk filename, the unit instance, the
|
||||
saved-machine directory and the value the assistant passes as
|
||||
`os`, and it appears in regex allowlists inside the privileged
|
||||
launch scripts. A rename that moved it would not rename a
|
||||
machine, it would describe one that does not exist.
|
||||
"""
|
||||
current = next((m["name"] for m in machines() if m["id"] == name), name)
|
||||
entry = Gtk.Entry(text=current, activates_default=True, max_length=40)
|
||||
dialog = Gtk.Window(transient_for=self, modal=True, title=f"Rename {current}")
|
||||
box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=10)
|
||||
box.set_margin_top(16); box.set_margin_bottom(16)
|
||||
box.set_margin_start(16); box.set_margin_end(16)
|
||||
label = Gtk.Label(xalign=0, wrap=True)
|
||||
label.set_markup(f"What should this machine be called?\n"
|
||||
f"<small>Its id stays <tt>{GLib.markup_escape_text(name)}</tt> — "
|
||||
"that is what the disk, the unit and the assistant use.</small>")
|
||||
box.append(label); box.append(entry)
|
||||
buttons = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=8, halign=Gtk.Align.END)
|
||||
cancel = Gtk.Button(label="Cancel")
|
||||
save = Gtk.Button(label="Rename")
|
||||
save.add_css_class("suggested-action")
|
||||
buttons.append(cancel); buttons.append(save)
|
||||
box.append(buttons)
|
||||
dialog.set_child(box)
|
||||
|
||||
cancel.connect("clicked", lambda *_: dialog.destroy())
|
||||
|
||||
def commit(*_args) -> None:
|
||||
wanted = entry.get_text().strip()
|
||||
dialog.destroy()
|
||||
if not wanted or wanted == current:
|
||||
return
|
||||
self._rename(name, wanted)
|
||||
|
||||
save.connect("clicked", commit)
|
||||
entry.connect("activate", commit)
|
||||
dialog.present()
|
||||
|
||||
def _rename(self, name: str, wanted: str) -> None:
|
||||
"""Stage the name, then let the unit record it.
|
||||
|
||||
Same shape as inserting a disc, and for the same reason: a systemd
|
||||
unit instance carries one string and that string has to be the
|
||||
machine id, so the name travels through the staging directory the
|
||||
operator owns.
|
||||
"""
|
||||
try:
|
||||
os.makedirs(INCOMING, exist_ok=True)
|
||||
with open(os.path.join(INCOMING, f"{name}.name"), "w", encoding="utf-8") as handle:
|
||||
handle.write(wanted)
|
||||
except OSError as exc:
|
||||
self.say(f"Could not stage the new name: {exc}")
|
||||
return
|
||||
|
||||
self._busy = True
|
||||
self.refresh()
|
||||
|
||||
def done(code: int, out: str) -> bool:
|
||||
self._busy = False
|
||||
self.say(f"Renamed to {wanted}." if code == 0
|
||||
else f"Could not rename it: {out}")
|
||||
self.refresh()
|
||||
return False
|
||||
|
||||
run_async(["systemctl", "start", f"owuivm-rename@{name}.service"], 60, done)
|
||||
|
||||
def on_insert(self, _button: Gtk.Button, name: str) -> None:
|
||||
"""Choose an installation disc and put it in the library."""
|
||||
dialog = Gtk.FileDialog(title=f"Installation disc for {name}")
|
||||
media = Gtk.FileFilter()
|
||||
media.set_name("Disc images")
|
||||
media.add_pattern("*.iso")
|
||||
media.add_pattern("*.ISO")
|
||||
filters = Gio.ListStore.new(Gtk.FileFilter)
|
||||
filters.append(media)
|
||||
dialog.set_filters(filters)
|
||||
dialog.set_default_filter(media)
|
||||
|
||||
def chosen(source, result) -> None:
|
||||
try:
|
||||
picked = source.open_finish(result)
|
||||
except GLib.Error:
|
||||
return # the operator pressed Cancel
|
||||
path = picked.get_path() if picked else None
|
||||
if not path:
|
||||
self.say("That disc is not a local file, so it cannot be copied in.")
|
||||
return
|
||||
self._insert(name, path)
|
||||
|
||||
dialog.open(self, None, chosen)
|
||||
|
||||
def _insert(self, name: str, path: str) -> None:
|
||||
"""Copy the disc in as the operator, then have the unit check it.
|
||||
|
||||
THE COPY HAPPENS HERE, NOT IN THE UNIT, AND THAT IS DELIBERATE.
|
||||
|
||||
The maintenance units run with ProtectHome=yes and as owuivmadm,
|
||||
which is not in the operator's group - so a privileged unit can
|
||||
neither see nor read ~/Downloads. Running the copy as root
|
||||
instead would turn this button into "copy any file root can read
|
||||
into a world-readable directory". Copying it here needs no
|
||||
privilege at all: it is the operator reading their own file.
|
||||
|
||||
It is 6-8 GB, so it runs off the main thread and the row reports
|
||||
progress from the size of the destination.
|
||||
"""
|
||||
try:
|
||||
size = os.path.getsize(path)
|
||||
except OSError as exc:
|
||||
self.say(f"Could not read that file: {exc}")
|
||||
return
|
||||
try:
|
||||
os.makedirs(INCOMING, exist_ok=True)
|
||||
except OSError as exc:
|
||||
self.say(f"{INCOMING} is not writable ({exc}). Re-run vm/host/install.sh.")
|
||||
return
|
||||
|
||||
# The staged name carries the machine id so the unit knows which
|
||||
# machine it is for, and keeps the original filename so the glob
|
||||
# in the registry still matches it.
|
||||
staged = os.path.join(INCOMING, f"{name}.{os.path.basename(path)}")
|
||||
if not staged.lower().endswith(".iso"):
|
||||
staged += ".iso"
|
||||
|
||||
self._busy = True
|
||||
self.refresh()
|
||||
self.say(f"Copying {os.path.basename(path)} ({size / 1e9:.1f} GB)…")
|
||||
|
||||
def progress() -> bool:
|
||||
if not self._busy:
|
||||
return False
|
||||
try:
|
||||
done_bytes = os.path.getsize(staged)
|
||||
except OSError:
|
||||
return True
|
||||
share = (done_bytes / size * 100) if size else 0
|
||||
self.say(f"Copying {os.path.basename(path)} — {share:.0f}% "
|
||||
f"({done_bytes / 1e9:.1f} of {size / 1e9:.1f} GB)")
|
||||
return True
|
||||
|
||||
GLib.timeout_add_seconds(1, progress)
|
||||
|
||||
def copy_then_insert() -> None:
|
||||
try:
|
||||
with open(path, "rb") as src, open(staged, "wb") as dst:
|
||||
shutil.copyfileobj(src, dst, 8 * 1024 * 1024)
|
||||
except OSError as exc:
|
||||
GLib.idle_add(self._insert_failed, f"the copy failed: {exc}", staged)
|
||||
return
|
||||
code, out = run("systemctl", "start", f"owuivm-insert@{name}.service", timeout=180)
|
||||
GLib.idle_add(self._insert_done, name, code, out, staged)
|
||||
|
||||
threading.Thread(target=copy_then_insert, daemon=True).start()
|
||||
|
||||
def _insert_failed(self, why: str, staged: str) -> bool:
|
||||
self._busy = False
|
||||
try:
|
||||
os.unlink(staged)
|
||||
except OSError:
|
||||
pass
|
||||
self.say(f"Could not insert the disc: {why}")
|
||||
self.refresh()
|
||||
return False
|
||||
|
||||
def _insert_done(self, name: str, code: int, out: str, staged: str) -> bool:
|
||||
self._busy = False
|
||||
if code == 0:
|
||||
iso = media_for(name)
|
||||
self.say(f"{iso or 'The disc'} is in the library. Press Install from ISO to "
|
||||
"create the machine and boot it.")
|
||||
else:
|
||||
# The unit deletes what it rejects, but a failure before that
|
||||
# leaves a part-copied file that would look like usable media.
|
||||
try:
|
||||
os.unlink(staged)
|
||||
except OSError:
|
||||
pass
|
||||
self.say(f"The disc was not accepted: {out}")
|
||||
self.refresh()
|
||||
return False
|
||||
|
||||
def on_start(self, _button: Gtk.Button, name: str) -> None:
|
||||
state = image_state(name)
|
||||
if state["running"]:
|
||||
self.say(f"{name} is already running.")
|
||||
return
|
||||
if not state["exists"]:
|
||||
iso = media_for(name)
|
||||
if not iso:
|
||||
self.say(f"There is no {name} ISO in {ISOS}.")
|
||||
return
|
||||
# The unit makes the disk, the firmware variables and the
|
||||
# install marker, as the user that owns them. This tool used
|
||||
# to do it here and could not - see the note above media_for.
|
||||
busy = [o for o in ids() if o != name and image_state(o)["running"]]
|
||||
if busy:
|
||||
self.say(f"{busy[0].capitalize()} is open. Shut that down first — "
|
||||
"only one machine runs at a time.")
|
||||
return
|
||||
self.say(f"Creating a blank disk and booting {iso}… "
|
||||
"(you may be asked to authorise)")
|
||||
self._busy = True
|
||||
self.refresh()
|
||||
|
||||
def installed(code: int, out: str) -> bool:
|
||||
self._busy = False
|
||||
if code != 0:
|
||||
self.say(f"Could not start the installer: {out}")
|
||||
else:
|
||||
self.say("The installer is booting. Opening the console…")
|
||||
GLib.timeout_add_seconds(4, lambda: (self.on_console(None, name), False)[1])
|
||||
self.refresh()
|
||||
return False
|
||||
|
||||
run_async(["systemctl", "start", unit_for(name)], 300, installed)
|
||||
return
|
||||
# Two machines at once would be two qemu processes with a base
|
||||
# disk each, splitting the host's memory between them.
|
||||
# The unit's Conflicts= catches a session overlapping maintenance;
|
||||
# the prepare step catches the other image; this is the friendly
|
||||
# version of the same refusal, before anything is attempted.
|
||||
busy = [other for other in ids() if other != name and image_state(other)["running"]]
|
||||
if busy:
|
||||
self.say(f"{busy[0].capitalize()} is already open. Shut that down first.")
|
||||
return
|
||||
self.say(f"Starting {name}… (you may be asked to authorise)")
|
||||
self._busy = True
|
||||
self.refresh()
|
||||
|
||||
def started(code: int, out: str) -> bool:
|
||||
self._busy = False
|
||||
if code != 0:
|
||||
self.say(f"Could not start it: {out}")
|
||||
else:
|
||||
self.say(f"{name} is running. Opening the console…")
|
||||
GLib.timeout_add_seconds(3, lambda: (self.on_console(None, name), False)[1])
|
||||
self.refresh()
|
||||
return False
|
||||
|
||||
run_async(["systemctl", "start", unit_for(name)], 300, started)
|
||||
|
||||
def _relay(self) -> bool:
|
||||
"""Bridge the console's unix socket to a loopback port for the viewer.
|
||||
|
||||
qemu serves the console on a unix socket rather than a TCP port,
|
||||
because this unit denies 127.0.0.0/8 and systemd's filter checks
|
||||
the SOURCE address on the way IN as well as the destination on
|
||||
the way out - so a loopback VNC port is dropped by the same rule
|
||||
that stops the guest reaching the host. See owuivm-launch.
|
||||
|
||||
socat runs here, as the operator, outside qemu's cgroup, so the
|
||||
filter does not apply to it. VNC clients speak TCP and that is
|
||||
that.
|
||||
"""
|
||||
if self._socat and self._socat.poll() is None:
|
||||
return True
|
||||
if not os.path.exists(VNC_SOCKET):
|
||||
self.say("The console socket is not there yet; give it a moment.")
|
||||
return False
|
||||
if not shutil.which("socat"):
|
||||
self.say("socat is not installed, so the console cannot be bridged.")
|
||||
return False
|
||||
try:
|
||||
self._socat = subprocess.Popen(
|
||||
["socat", f"TCP-LISTEN:{VNC.split(':')[1]},bind=127.0.0.1,reuseaddr,fork",
|
||||
f"UNIX-CONNECT:{VNC_SOCKET}"],
|
||||
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
|
||||
except OSError as exc:
|
||||
self.say(f"Could not bridge the console: {exc}")
|
||||
return False
|
||||
time.sleep(0.4)
|
||||
return True
|
||||
|
||||
def _stop_relay(self) -> None:
|
||||
if self._socat and self._socat.poll() is None:
|
||||
self._socat.terminate()
|
||||
self._socat = None
|
||||
|
||||
def on_console(self, _button: Gtk.Button | None, name: str) -> None:
|
||||
if not image_state(name)["running"]:
|
||||
self.say(f"{name} is not running.")
|
||||
return
|
||||
if not self._relay():
|
||||
return
|
||||
# remmina is what is installed here; the others are tried in case
|
||||
# it is ever removed. The console is loopback-only, so a viewer
|
||||
# running anywhere but this desktop cannot reach it at all.
|
||||
for viewer, args in (
|
||||
("remmina", ["-c", f"vnc://{VNC}"]),
|
||||
("vncviewer", [VNC]),
|
||||
("gvncviewer", [VNC]),
|
||||
("virt-viewer", ["--connect", f"vnc://{VNC}"]),
|
||||
):
|
||||
if shutil.which(viewer):
|
||||
subprocess.Popen([viewer, *args],
|
||||
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
|
||||
self.say(f"Opened {viewer} on {VNC}.")
|
||||
return
|
||||
self.say(f"No VNC viewer found. Connect one to {VNC} yourself.")
|
||||
|
||||
|
||||
class App(Gtk.Application):
|
||||
def __init__(self) -> None:
|
||||
super().__init__(application_id="org.owuitools.VmMaintenance")
|
||||
|
||||
def do_activate(self) -> None:
|
||||
Window(self).present()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(App().run(None))
|
||||
Reference in New Issue
Block a user