Initial Commit

This commit is contained in:
2026-09-15 15:24:03 -04:00
commit 4dc7086e7e
605 changed files with 101380 additions and 0 deletions
+949
View File
@@ -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))