Initial Commit
This commit is contained in:
@@ -0,0 +1,932 @@
|
||||
"""One VM session: create it, use it, destroy it.
|
||||
|
||||
WHAT A SESSION IS
|
||||
|
||||
The span of one vm_agent tool call. It starts from the frozen base image
|
||||
every single time and leaves nothing behind except what was written to
|
||||
the project share. There is no "resume a machine" path and there is not
|
||||
meant to be: a machine that carries state between sessions is a machine
|
||||
where last week's half-finished experiment silently changes this week's
|
||||
answer.
|
||||
|
||||
WHY systemd OWNS THE PROCESS AND THIS MODULE DOES NOT
|
||||
|
||||
"Only one VM at a time" is enforced by owuivm.service being a single unit
|
||||
rather than a template - starting an already-running unit is a no-op, so
|
||||
the guarantee holds even if this process crashes between checking and
|
||||
starting. Everything else follows from the same decision: the hardening
|
||||
is declarative and auditable in one file, ExecStopPost= tears down even
|
||||
when qemu is SIGKILLed, and cancellation is `systemctl stop`, which does
|
||||
not care what any Python loop is doing.
|
||||
|
||||
That last point is the specific mistake being avoided. The archived
|
||||
sub-agent stack's cancel verb recorded an intention and killed nothing,
|
||||
so a cancelled run carried on to completion.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import secrets
|
||||
import shutil
|
||||
import subprocess
|
||||
import time
|
||||
from dataclasses import dataclass
|
||||
|
||||
import project
|
||||
from guest import Guest, GuestError, GuestTimeout
|
||||
from qmp import Qmp, QmpError
|
||||
|
||||
RUN_ROOT = "/run/owuivm"
|
||||
# See project.py for why the pool is a setting. Same value, same source.
|
||||
POOL_ROOT = (os.environ.get("OWUIVM_POOL_ROOT") or "/Pool").rstrip("/") or "/Pool"
|
||||
OVERLAY_ROOT = f"{POOL_ROOT}/vmrun"
|
||||
BASE_ROOT = "/var/lib/owuivm/base"
|
||||
CTL = "/usr/local/sbin/owuivm-ctl"
|
||||
|
||||
# How long to wait for the guest agent after the unit starts. A cold boot
|
||||
# of the Ubuntu image is a few seconds; this is generous enough to cover a
|
||||
# host under load and short enough that a machine which is never coming up
|
||||
# is reported as such rather than eating the caller's whole budget.
|
||||
BOOT_TIMEOUT_S = 180.0
|
||||
|
||||
SUPPORTED_OS = ("ubuntu", "windows")
|
||||
|
||||
|
||||
class SessionError(RuntimeError):
|
||||
"""Phrased for the model to read."""
|
||||
|
||||
|
||||
@dataclass
|
||||
class Session:
|
||||
id: str
|
||||
os_name: str
|
||||
project_name: str | None
|
||||
run_dir: str
|
||||
overlay: str
|
||||
vars_file: str
|
||||
share: str | None
|
||||
started_at: float
|
||||
# Resumed from a saved machine rather than booted. What follows from
|
||||
# it: the guest starts paused and has to be told to run, and the
|
||||
# project share has to be plugged in afterwards because a saved state
|
||||
# cannot contain one.
|
||||
warm: bool = False
|
||||
|
||||
# -- paths ---------------------------------------------------------
|
||||
@property
|
||||
def qmp_path(self) -> str:
|
||||
return os.path.join(self.run_dir, "qmp.sock")
|
||||
|
||||
@property
|
||||
def ga_path(self) -> str:
|
||||
return os.path.join(self.run_dir, "ga.sock")
|
||||
|
||||
@property
|
||||
def cancel_path(self) -> str:
|
||||
return os.path.join(self.run_dir, "CANCEL")
|
||||
|
||||
# -- cancellation --------------------------------------------------
|
||||
def cancelled(self) -> bool:
|
||||
"""A file, not a flag in memory.
|
||||
|
||||
Anything that can write the run directory can cancel a session,
|
||||
including a process that is not this one - which is the point.
|
||||
The agent loop checks it between steps, and `systemctl stop`
|
||||
happens regardless of whether anybody checked.
|
||||
"""
|
||||
return os.path.exists(self.cancel_path)
|
||||
|
||||
def cancel(self) -> None:
|
||||
try:
|
||||
with open(self.cancel_path, "w") as handle:
|
||||
handle.write(str(time.time()))
|
||||
except OSError:
|
||||
pass
|
||||
_ctl("vm-stop")
|
||||
|
||||
# -- the two channels ----------------------------------------------
|
||||
def qmp(self) -> Qmp:
|
||||
return Qmp(self.qmp_path)
|
||||
|
||||
def guest(self, timeout: float = 15.0) -> Guest:
|
||||
return Guest(self.ga_path, timeout=timeout, os_name=self.os_name)
|
||||
|
||||
def alive(self) -> bool:
|
||||
return _ctl("vm-status", check=False).stdout.strip() == "active"
|
||||
|
||||
|
||||
def _ctl(*args: str, check: bool = True) -> subprocess.CompletedProcess:
|
||||
result = subprocess.run(
|
||||
["/usr/bin/sudo", "-n", CTL, *args],
|
||||
capture_output=True, text=True, timeout=120, check=False,
|
||||
)
|
||||
if check and result.returncode != 0:
|
||||
raise SessionError(
|
||||
f"privileged helper refused '{args[0]}': "
|
||||
f"{(result.stderr or result.stdout).strip()}"
|
||||
)
|
||||
return result
|
||||
|
||||
|
||||
# NOT in the image directory. That belongs to owuivmadm and vmd cannot
|
||||
# write there, which is the right arrangement - vmd must never be able to
|
||||
# touch a base image. A saved machine is vmd's own artefact: it creates
|
||||
# it, it validates it, and it is the only thing that reads it back.
|
||||
WARM_ROOT = "/var/lib/owuivm/warm"
|
||||
BASE_ROOT = "/var/lib/owuivm/base"
|
||||
|
||||
|
||||
def warm_set(os_name: str) -> dict | None:
|
||||
"""The saved machine for this image, if there is a valid one.
|
||||
|
||||
VALID MEANS "TAKEN FROM THE DISK THAT IS THERE NOW".
|
||||
|
||||
A saved state describes memory that matches a particular disk, down
|
||||
to the page cache. Resume it against a base image that has been
|
||||
changed since and nothing reports an error - the guest simply has a
|
||||
filesystem in memory that disagrees with the one on disk, which is
|
||||
how a disk gets corrupted rather than how it fails.
|
||||
|
||||
The freeze already records a checksum of the image, so the manifest
|
||||
records which checksum the state was taken from and this compares
|
||||
them. Any mismatch, missing file, or unreadable manifest and the
|
||||
answer is None, which means a cold boot: slower, and always correct.
|
||||
"""
|
||||
if os_name not in SUPPORTED_OS:
|
||||
return None
|
||||
warm = os.path.join(WARM_ROOT, os_name)
|
||||
disk = os.path.join(warm, f"{os_name}.qcow2")
|
||||
state = os.path.join(warm, "state")
|
||||
varsf = os.path.join(warm, "OVMF_VARS.fd")
|
||||
manifest = os.path.join(warm, "manifest.json")
|
||||
if not all(os.path.isfile(p) for p in (disk, state, varsf, manifest)):
|
||||
return None
|
||||
try:
|
||||
with open(manifest, encoding="utf-8") as handle:
|
||||
meta = json.load(handle)
|
||||
except (OSError, ValueError):
|
||||
return None
|
||||
recorded = str(meta.get("base_sha256") or "")
|
||||
if not recorded or recorded != _base_checksum(os_name):
|
||||
return None
|
||||
return {"disk": disk, "state": state, "vars": varsf, "meta": meta}
|
||||
|
||||
|
||||
def _base_checksum(os_name: str) -> str:
|
||||
"""What the freeze recorded for this image, not a checksum taken now.
|
||||
|
||||
Hashing forty gigabytes at the start of every session would cost more
|
||||
than the boot this is meant to save. The manifest file written when
|
||||
the image was frozen is the record, and it is written by the only
|
||||
thing that can change the image.
|
||||
"""
|
||||
path = os.path.join(BASE_ROOT, f"{os_name}.sha256")
|
||||
try:
|
||||
with open(path, encoding="utf-8") as handle:
|
||||
for line in handle:
|
||||
digest, _, name = line.partition(" ")
|
||||
if name.strip().endswith(f"{os_name}.qcow2"):
|
||||
return digest.strip()
|
||||
except OSError:
|
||||
pass
|
||||
return ""
|
||||
|
||||
|
||||
def _base_paths(os_name: str) -> tuple[str, str]:
|
||||
if os_name not in SUPPORTED_OS:
|
||||
raise SessionError(
|
||||
f"'{os_name}' is not a machine this runs. Choose 'ubuntu' or 'windows'."
|
||||
)
|
||||
disk = os.path.join(BASE_ROOT, f"{os_name}.qcow2")
|
||||
vars_file = os.path.join(BASE_ROOT, f"{os_name}-OVMF_VARS.fd")
|
||||
if not os.path.exists(disk):
|
||||
raise SessionError(
|
||||
f"the {os_name} base image has not been built yet, so there is no "
|
||||
f"{os_name} machine to start. Nothing is wrong with the request."
|
||||
)
|
||||
return disk, vars_file
|
||||
|
||||
|
||||
def reap(verbose: bool = False) -> list[str]:
|
||||
"""Clear anything a previous run left behind.
|
||||
|
||||
Same reasoning as octave/run.sh's `rm -rf "${JOBS:?}/run"/*` at start:
|
||||
a crash, an OOM kill or a reboot leaves a stale unit, a mounted
|
||||
virtiofsd and an orphan overlay, and every one of those wedges the
|
||||
next caller until it times out - which looks exactly like the new
|
||||
request being broken.
|
||||
"""
|
||||
done: list[str] = []
|
||||
if _ctl("vm-status", check=False).stdout.strip() == "active":
|
||||
_ctl("vm-stop", check=False)
|
||||
done.append("stopped a VM that was still running")
|
||||
_ctl("vm-reset", check=False)
|
||||
_ctl("vfs-stop", check=False)
|
||||
|
||||
for name in os.listdir(RUN_ROOT) if os.path.isdir(RUN_ROOT) else []:
|
||||
path = os.path.join(RUN_ROOT, name)
|
||||
if os.path.isdir(path) and len(name) == 32:
|
||||
shutil.rmtree(path, ignore_errors=True)
|
||||
done.append(f"removed stale session directory {name}")
|
||||
|
||||
if os.path.isdir(OVERLAY_ROOT):
|
||||
for name in os.listdir(OVERLAY_ROOT):
|
||||
# `.state` as well as `.qcow2`: a machine state is four
|
||||
# gigabytes, and an interrupted save used to leave one behind
|
||||
# with nothing to notice it. Three of them were found holding
|
||||
# 11.6 GB after an afternoon of building this.
|
||||
if name.endswith(".qcow2") or name.endswith(".state"):
|
||||
try:
|
||||
os.unlink(os.path.join(OVERLAY_ROOT, name))
|
||||
done.append(f"removed orphan {name}")
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
# A saved machine that was half-written when something interrupted it.
|
||||
# It can never be used - the swap into place is the last step - so it
|
||||
# is only taking up room.
|
||||
if os.path.isdir(WARM_ROOT):
|
||||
for name in os.listdir(WARM_ROOT):
|
||||
if name.endswith(".new"):
|
||||
shutil.rmtree(os.path.join(WARM_ROOT, name), ignore_errors=True)
|
||||
done.append(f"removed a half-written saved machine ({name})")
|
||||
|
||||
env = os.path.join(RUN_ROOT, "session.env")
|
||||
if os.path.exists(env):
|
||||
try:
|
||||
os.unlink(env)
|
||||
except OSError:
|
||||
pass
|
||||
if verbose:
|
||||
for line in done:
|
||||
print(f" reaped: {line}")
|
||||
return done
|
||||
|
||||
|
||||
def create(os_name: str, project_name: str | None = None,
|
||||
mem_mb: int = 8192, cpus: int = 8, wait: bool = True,
|
||||
allow_warm: bool = True) -> Session:
|
||||
"""Start a machine; optionally wait until it can be talked to.
|
||||
|
||||
`wait=False` exists so the caller can do something useful with the
|
||||
twenty-odd seconds a machine takes to boot. The agent spends them
|
||||
writing its task list, which also has the effect of getting the model
|
||||
loaded and its prompt prefilled before there is anything to do - so
|
||||
neither side is idle waiting for the other.
|
||||
"""
|
||||
base_disk, base_vars = _base_paths(os_name)
|
||||
|
||||
# THE BASE MUST BE FROZEN, AND THIS IS WHERE THAT IS ENFORCED.
|
||||
#
|
||||
# It used to be enforced by the maintenance unit re-freezing on the
|
||||
# way out, which is the wrong place twice over: it made looking at an
|
||||
# image mean unfreezing it, and it put the guarantee in the hands of
|
||||
# a step that has already been seen to fail silently (a broken `du`
|
||||
# aborted it for a while, and every clean shutdown left the image
|
||||
# writable with only a status=1 in the log).
|
||||
#
|
||||
# So the freeze is now the operator's own button, and the guarantee
|
||||
# is checked here, at the moment a session would otherwise be handed
|
||||
# a writable base. A refusal with a sentence in it, rather than a
|
||||
# chmod nobody saw.
|
||||
if os.stat(base_disk).st_mode & 0o222:
|
||||
raise SessionError(
|
||||
f"the {os_name} base image is unfrozen, so no session can start. "
|
||||
"It is open for maintenance, or was left that way: freeze it in "
|
||||
"Tool Server VM Maintenance and try again.")
|
||||
|
||||
# Nothing from a previous session is allowed to still be around. This
|
||||
# is not defensive tidying - a leftover unit means `vm-start` is a
|
||||
# silent no-op and the caller talks to the wrong machine.
|
||||
reap()
|
||||
|
||||
session_id = secrets.token_hex(16)
|
||||
run_dir = os.path.join(RUN_ROOT, session_id)
|
||||
overlay = os.path.join(OVERLAY_ROOT, f"{session_id}.qcow2")
|
||||
vars_file = os.path.join(run_dir, "OVMF_VARS.fd")
|
||||
|
||||
# 0007 so qemu, which shares the group, can write the sockets and the
|
||||
# overlay that this process is about to create for it.
|
||||
os.umask(0o007)
|
||||
os.makedirs(run_dir, mode=0o2770, exist_ok=True)
|
||||
try:
|
||||
os.chown(run_dir, -1, _gid("owuivm"))
|
||||
os.chmod(run_dir, 0o2770)
|
||||
except (OSError, KeyError):
|
||||
pass
|
||||
|
||||
share = None
|
||||
if project_name:
|
||||
project.validate(project_name)
|
||||
share = _ctl("project-ensure", project_name).stdout.strip()
|
||||
if not share:
|
||||
raise SessionError(f"could not prepare storage for project '{project_name}'.")
|
||||
|
||||
# WARM OR COLD, AND WHAT CHANGES.
|
||||
#
|
||||
# A warm start branches the overlay from the SAVED disk rather than
|
||||
# the base - the saved memory describes that disk - and boots into
|
||||
# the saved state instead of the firmware. The base is still under
|
||||
# both of them, still frozen, still the thing nothing can write.
|
||||
warm = warm_set(os_name) if allow_warm else None
|
||||
backing = warm["disk"] if warm else base_disk
|
||||
vars_source = warm["vars"] if warm else base_vars
|
||||
|
||||
# The overlay is the immutability guarantee, and it is structural: the
|
||||
# base is opened as a read-only backing file, and nothing this session
|
||||
# writes can reach it. The base is also mode 0444 owned by a user this
|
||||
# process is not, so there are two independent reasons it cannot change.
|
||||
result = subprocess.run(
|
||||
["/usr/bin/qemu-img", "create", "-f", "qcow2",
|
||||
"-b", backing, "-F", "qcow2", overlay],
|
||||
capture_output=True, text=True, timeout=60, check=False,
|
||||
)
|
||||
if result.returncode != 0:
|
||||
raise SessionError(f"could not create the session disk: {result.stderr.strip()}")
|
||||
|
||||
# Explicitly 0660, and umask cannot do this for us. qemu-img asks the
|
||||
# kernel for 0644, so a umask of 0007 clears a group-write bit that
|
||||
# was never requested in the first place and leaves 0640 - readable
|
||||
# by the qemu user, writable by nobody but vmd. qemu then fails with
|
||||
# a bare "Could not open ...: Permission denied" on a file it can
|
||||
# plainly see, which is a memorable half hour.
|
||||
os.chmod(overlay, 0o660)
|
||||
|
||||
shutil.copyfile(vars_source, vars_file)
|
||||
os.chmod(vars_file, 0o660)
|
||||
|
||||
with open(os.path.join(RUN_ROOT, "session.env"), "w") as handle:
|
||||
handle.write(
|
||||
f"OWUIVM_MODE=session\n"
|
||||
f"OWUIVM_SESSION={session_id}\n"
|
||||
f"OWUIVM_DISK={overlay}\n"
|
||||
f"OWUIVM_VARS={vars_file}\n"
|
||||
# Deliberately empty on a warm start even when there IS a
|
||||
# share: the device is hot-plugged after the state has
|
||||
# loaded, into the port the argv always carries. virtiofsd is
|
||||
# still started below, so its socket is waiting.
|
||||
f"OWUIVM_SHARE={'' if warm else (share or '')}\n"
|
||||
f"OWUIVM_MEM_MB={int(mem_mb)}\n"
|
||||
f"OWUIVM_CPUS={int(cpus)}\n"
|
||||
+ (f"OWUIVM_INCOMING={warm['state']}\n" if warm else "")
|
||||
)
|
||||
|
||||
session = Session(
|
||||
id=session_id, os_name=os_name, project_name=project_name,
|
||||
run_dir=run_dir, overlay=overlay, vars_file=vars_file,
|
||||
share=share, started_at=time.monotonic(), warm=bool(warm),
|
||||
)
|
||||
|
||||
if share:
|
||||
# virtiofsd first: qemu's vhost-user chardev connects to its
|
||||
# socket at startup and fails outright if it is not there yet.
|
||||
_ctl("vfs-start", project_name or "", session_id)
|
||||
_wait_for(lambda: os.path.exists(os.path.join(run_dir, "vfs.sock")), 20.0,
|
||||
"the project share did not come up")
|
||||
|
||||
_ctl("vm-start")
|
||||
if wait:
|
||||
try:
|
||||
await_ready(session)
|
||||
except SessionError:
|
||||
destroy(session)
|
||||
raise
|
||||
return session
|
||||
|
||||
|
||||
def await_ready(session: Session, timeout: float = BOOT_TIMEOUT_S,
|
||||
desktop: bool = False) -> dict:
|
||||
"""Block until the machine can be talked to, and say what it waited for."""
|
||||
started = time.monotonic()
|
||||
if session.warm:
|
||||
_resume(session)
|
||||
_wait_for_guest(session, timeout)
|
||||
clock = _sync_clock(session)
|
||||
if session.warm:
|
||||
_attach_share(session)
|
||||
else:
|
||||
_warm_the_shell(session)
|
||||
_mount_share(session)
|
||||
result = {"seconds": round(time.monotonic() - started, 1), "desktop": False,
|
||||
"clock": clock, "facts": _machine_facts(session)}
|
||||
if desktop:
|
||||
result["desktop"] = _wait_for_desktop(session, timeout - result["seconds"])
|
||||
result["seconds"] = round(time.monotonic() - started, 1)
|
||||
return result
|
||||
|
||||
|
||||
def _machine_facts(session: Session) -> str:
|
||||
"""One command, so the agent does not spend three turns asking.
|
||||
|
||||
THIS IS TOOL OUTPUT, NOT PROMPT TEXT, AND THAT IS THE POINT.
|
||||
|
||||
Three times now this stack has watched a model treat a system prompt
|
||||
as advice and tool output as fact. So what is actually installed in
|
||||
this machine is discovered here and handed over with the machine,
|
||||
rather than written into a prompt that will drift from the image the
|
||||
first time somebody adds a package.
|
||||
|
||||
Best-effort by design: a machine that answers `run` but not this is
|
||||
still a machine worth handing over, so a failure here costs a line of
|
||||
text and nothing else.
|
||||
"""
|
||||
if session.os_name == "windows":
|
||||
return _windows_facts(session)
|
||||
probe = (
|
||||
"printf 'os: '; (. /etc/os-release 2>/dev/null && echo \"$PRETTY_NAME\") "
|
||||
"|| uname -sr\n"
|
||||
"printf 'python: '; (python3 -V 2>&1 | head -1) || echo none\n"
|
||||
"printf 'browsers: '; for b in chromium chromium-browser google-chrome "
|
||||
"brave-browser firefox; do command -v $b >/dev/null 2>&1 && printf '%s ' $b; done; echo\n"
|
||||
"printf 'desktop user: '; id -nu 1000 2>/dev/null || echo none\n"
|
||||
"printf 'project folder: '; if mountpoint -q /mnt/project 2>/dev/null; then "
|
||||
"df -h /mnt/project | awk 'NR==2{print \"/mnt/project, \" $4 \" free of \" $2}'; "
|
||||
"else echo 'not mounted - nothing you write will be kept'; fi\n"
|
||||
)
|
||||
try:
|
||||
with session.guest(timeout=20.0) as agent:
|
||||
result = agent.shell(probe, timeout_s=25.0)
|
||||
except (GuestError, OSError):
|
||||
return ""
|
||||
return (result.get("stdout") or "").strip()[:1200]
|
||||
|
||||
|
||||
def _resume(session: Session) -> None:
|
||||
"""Let a machine that loaded from a saved state start running.
|
||||
|
||||
qemu holds an incoming migration paused until it is told otherwise,
|
||||
which is the right default - it is how a real migration hands over
|
||||
without two copies running at once - and here it just means one
|
||||
command. Measured on this host: the state loads and the machine is
|
||||
running 7.2 seconds after the unit starts, against 90 for a boot.
|
||||
"""
|
||||
deadline = time.monotonic() + 120.0
|
||||
last = ""
|
||||
while time.monotonic() < deadline:
|
||||
try:
|
||||
with Qmp(session.qmp_path, timeout=20.0) as qmp:
|
||||
state = qmp.resume()
|
||||
if state == "running":
|
||||
return
|
||||
last = state
|
||||
except (QmpError, OSError) as exc:
|
||||
last = str(exc)
|
||||
time.sleep(0.5)
|
||||
raise SessionError(
|
||||
f"the saved machine did not start running (last state: {last or 'unknown'})."
|
||||
)
|
||||
|
||||
|
||||
def _attach_share(session: Session) -> None:
|
||||
"""Plug the project share into a machine that resumed without one.
|
||||
|
||||
A saved state cannot contain the share - virtiofsd refuses migration
|
||||
- so it is added afterwards, into the root port the argv always
|
||||
carries, at the same address a cold boot uses. Then the guest is told
|
||||
to look again: Linux mounts it the same way it always does, and
|
||||
Windows has a service that has to be restarted to notice a device
|
||||
that appeared after it started.
|
||||
"""
|
||||
if not session.share:
|
||||
return
|
||||
socket_path = os.path.join(session.run_dir, "vfs.sock")
|
||||
try:
|
||||
with Qmp(session.qmp_path, timeout=30.0) as qmp:
|
||||
qmp.attach_share(socket_path)
|
||||
except (QmpError, OSError) as exc:
|
||||
raise SessionError(
|
||||
f"the machine resumed but its project folder could not be attached: {exc}"
|
||||
) from exc
|
||||
|
||||
if session.os_name == "windows":
|
||||
# The VirtIO-FS service enumerates at start; a device that turns
|
||||
# up later is not noticed until it does that again.
|
||||
try:
|
||||
with session.guest(timeout=45.0) as agent:
|
||||
agent.shell(
|
||||
"Restart-Service VirtioFsSvc -Force; "
|
||||
# Wait for the drive rather than for a number of
|
||||
# seconds: it is usually there in one and the guess
|
||||
# was costing three on every warm session.
|
||||
"for ($i=0; $i -lt 30; $i++) { "
|
||||
" if (Get-PSDrive -Name Z -EA SilentlyContinue) { exit 0 }; "
|
||||
" Start-Sleep -Milliseconds 500 } ; exit 1",
|
||||
timeout_s=120)
|
||||
except (GuestError, GuestTimeout, OSError) as exc:
|
||||
raise SessionError(
|
||||
f"the share was attached but the machine did not pick it up: {exc}"
|
||||
) from exc
|
||||
|
||||
|
||||
def _sync_clock(session: Session) -> str:
|
||||
"""Set the guest's clock from the host's, and CHECK that it took.
|
||||
|
||||
A resumed machine believes it is whenever its state was saved.
|
||||
Measured on the first warm Windows session: twenty-six seconds
|
||||
behind, and a snapshot left over a weekend would be days out. Windows
|
||||
minds most - a wrong clock fails TLS, so every download breaks in a
|
||||
way that reads as a network fault.
|
||||
|
||||
The first version set the time and swallowed any error, which is how
|
||||
it came to be twenty-six seconds behind while reporting nothing: the
|
||||
share service had just been restarted, the guest agent was busy for a
|
||||
moment, the call failed and nobody was told. So this one sets the
|
||||
time, READS IT BACK, and tries again if it did not take - and says
|
||||
what happened rather than returning silence either way.
|
||||
"""
|
||||
tolerance = 2.0
|
||||
last = "not attempted"
|
||||
for attempt in range(4):
|
||||
try:
|
||||
with session.guest(timeout=30.0) as agent:
|
||||
agent.set_time(int(time.time() * 1_000_000_000))
|
||||
# guest-get-time answers in nanoseconds, UTC, from the
|
||||
# guest's own clock - which is the thing being checked.
|
||||
reported = int(agent.info_time())
|
||||
off = abs(time.time() - reported / 1_000_000_000)
|
||||
if off <= tolerance:
|
||||
return f"set, within {off:.1f}s of the host"
|
||||
last = f"still {off:.1f}s out"
|
||||
except (GuestError, GuestTimeout, OSError) as exc:
|
||||
last = str(exc)
|
||||
time.sleep(2.0 * (attempt + 1))
|
||||
return f"could not be set ({last})"
|
||||
|
||||
|
||||
def _warm_the_shell(session: Session) -> None:
|
||||
"""Pay for PowerShell's cold start while nobody is waiting on it.
|
||||
|
||||
MEASURED on this Windows image: the FIRST PowerShell in a freshly
|
||||
booted guest took over ninety seconds - Defender inspecting it and
|
||||
.NET warming up - and every one after that took five to nine. The
|
||||
machine-facts probe, which is the first thing to run, timed out and
|
||||
handed the agent nothing at all about the machine it was given.
|
||||
|
||||
The agent is writing its task list through all of this, so the cost
|
||||
is already being paid by somebody else's clock. Deliberately
|
||||
best-effort and deliberately silent: a machine that cannot warm up is
|
||||
a machine the next call will report on properly.
|
||||
"""
|
||||
if session.os_name != "windows":
|
||||
return
|
||||
try:
|
||||
with session.guest(timeout=30.0) as agent:
|
||||
agent.exec(["powershell", "-NoProfile", "-NonInteractive", "-Command", "exit"],
|
||||
timeout_s=240.0)
|
||||
except (GuestError, GuestTimeout, OSError):
|
||||
pass
|
||||
|
||||
|
||||
def _windows_facts(session: Session) -> str:
|
||||
"""The same question asked of Windows, including the one that matters.
|
||||
|
||||
Whether the project folder is there. virtiofs on Windows needs WinFsp
|
||||
and the VirtIO-FS service, neither of which the guest agent implies -
|
||||
so a Windows session can come up perfectly and have nowhere to put
|
||||
anything that survives it. The agent is told, because the alternative
|
||||
is that it works for an hour and the report links to an empty repo.
|
||||
"""
|
||||
# Written as a script rather than one long -Command line, and with
|
||||
# single quotes throughout: the first version nested double quotes
|
||||
# inside a Python string inside PowerShell and produced nothing at
|
||||
# all, silently, which is the one outcome a fact-gathering probe
|
||||
# must not have.
|
||||
probe = r"""
|
||||
$ErrorActionPreference = 'SilentlyContinue'
|
||||
'os: ' + (Get-CimInstance Win32_OperatingSystem).Caption
|
||||
'powershell: ' + $PSVersionTable.PSVersion.ToString()
|
||||
$py = (Get-Command python.exe).Source
|
||||
'python: ' + $(if ($py) { (& $py -V 2>&1) } else { 'not on PATH for this account' })
|
||||
# By path, not by PATH: these are installed per-machine and the guest
|
||||
# agent runs as SYSTEM, whose PATH is not the logged-in user's.
|
||||
$known = @(
|
||||
"$env:ProgramFiles\BraveSoftware\Brave-Browser\Application\brave.exe",
|
||||
"${env:ProgramFiles(x86)}\BraveSoftware\Brave-Browser\Application\brave.exe",
|
||||
"$env:ProgramFiles\Google\Chrome\Application\chrome.exe",
|
||||
"$env:ProgramFiles\Mozilla Firefox\firefox.exe"
|
||||
) | Where-Object { Test-Path $_ } | ForEach-Object { Split-Path $_ -Leaf }
|
||||
'browsers: ' + $(if ($known) { $known -join ' ' } else { 'none found' })
|
||||
'running as: ' + [Environment]::UserName + ' (the guest agent is LocalSystem, not the desktop user)'
|
||||
# Measured on the first real Windows session: the agent wrapped its
|
||||
# commands in `powershell.exe -Command "..."` although it was already in
|
||||
# PowerShell, and used `&&`, which 5.1 does not have. Two wasted turns
|
||||
# each time, and both are facts about this machine rather than advice.
|
||||
'shell: your commands already run in PowerShell - do not wrap them in powershell.exe'
|
||||
'note: this is PowerShell 5.1, which has no && or ||. Use ; or separate calls.'
|
||||
$z = Get-PSDrive -Name Z
|
||||
'project folder: ' + $(if ($z) { 'Z:\, ' + [math]::Round($z.Free/1GB,1) + ' GB free' } else { 'NOT MOUNTED - nothing you write will be kept. Say so rather than working around it.' })
|
||||
"""
|
||||
# MEASURED: PowerShell's first start under LocalSystem on a freshly
|
||||
# booted Windows guest took 17 seconds, and the whole probe more than
|
||||
# the 40 it was given - so the facts came back empty, silently, on
|
||||
# exactly the machine that most needed them. Windows gets a minute
|
||||
# and a half; it is once per session and it is the difference between
|
||||
# the agent knowing there is no project folder and finding out.
|
||||
try:
|
||||
with session.guest(timeout=45.0) as agent:
|
||||
result = agent.shell(probe, timeout_s=90.0)
|
||||
except (GuestError, OSError) as exc:
|
||||
return f"(could not read this machine's details: {exc})"
|
||||
return (result.get("stdout") or "").strip()[:1200]
|
||||
|
||||
|
||||
def _mount_share(session: Session) -> None:
|
||||
"""Put the project folder at /mnt/project, whatever the image does.
|
||||
|
||||
THE SESSION MOUNTS IT, NOT THE IMAGE.
|
||||
|
||||
The share is one virtiofs tag, `project`, and the base image built by
|
||||
the autoinstall carries an fstab line for it. An image installed by
|
||||
hand does not, and the failure that produces is a bad one: everything
|
||||
works, the agent writes its files, `git` on the host commits an empty
|
||||
directory, and the only sign is that the Gitea repository has nothing
|
||||
in it. Nothing in the guest and nothing on the host reports an error.
|
||||
|
||||
So the mount is made here, every session, from the side that knows
|
||||
whether there is a share at all. It is idempotent - an image that
|
||||
does carry the fstab line is already mounted and this is one
|
||||
`mountpoint` check - and it means a hand-built image needs nothing
|
||||
but qemu-guest-agent to be a working base.
|
||||
"""
|
||||
if not session.share:
|
||||
return
|
||||
if session.os_name != "ubuntu":
|
||||
# Windows mounts virtiofs through WinFsp as a drive letter, set
|
||||
# up inside the image; there is no equivalent command to run
|
||||
# here. See vm/images/windows/build.md.
|
||||
return
|
||||
script = (
|
||||
"set -e\n"
|
||||
"mkdir -p /mnt/project\n"
|
||||
"mountpoint -q /mnt/project || mount -t virtiofs project /mnt/project\n"
|
||||
"mountpoint -q /mnt/project\n"
|
||||
)
|
||||
try:
|
||||
with session.guest(timeout=30.0) as agent:
|
||||
result = agent.shell(script, timeout_s=30.0)
|
||||
except (GuestError, OSError) as exc:
|
||||
raise SessionError(
|
||||
f"the machine is up but its project folder could not be mounted: {exc}"
|
||||
) from exc
|
||||
if result["exit_code"] != 0:
|
||||
detail = (result["stderr"] or result["stdout"]).strip()[:300]
|
||||
raise SessionError(
|
||||
"the machine is up but the project folder did not mount, so nothing "
|
||||
"written in this session would be kept. "
|
||||
f"`mount -t virtiofs project /mnt/project` said: {detail or 'nothing'}"
|
||||
)
|
||||
|
||||
|
||||
def _wait_for_guest(session: Session, timeout: float = BOOT_TIMEOUT_S) -> None:
|
||||
"""Boot supervision: report honestly rather than pretend."""
|
||||
deadline = time.monotonic() + timeout
|
||||
_wait_for(lambda: os.path.exists(session.ga_path), 30.0,
|
||||
"qemu did not create its guest-agent socket")
|
||||
last: str = ""
|
||||
while time.monotonic() < deadline:
|
||||
if not session.alive():
|
||||
raise SessionError(
|
||||
"the machine stopped while it was booting. "
|
||||
f"Check: journalctl -u owuivm.service -n 50"
|
||||
)
|
||||
try:
|
||||
with session.guest(timeout=5.0) as agent:
|
||||
if agent.ping(timeout=5.0):
|
||||
return
|
||||
except (GuestError, OSError) as exc:
|
||||
last = str(exc)
|
||||
time.sleep(1.0)
|
||||
raise SessionError(
|
||||
f"the {session.os_name} machine booted but its guest agent never "
|
||||
f"answered within {timeout:.0f}s, so there is no way to run "
|
||||
f"anything on it. Last error: {last or 'none'}"
|
||||
)
|
||||
|
||||
|
||||
def await_desktop(session: Session, timeout: float = 300.0) -> bool:
|
||||
"""Block until the graphical session is usable. Raises if it never is.
|
||||
|
||||
The public form of the wait below, called lazily by vmd the first time
|
||||
a session reaches for the screen - see _require_desktop. A job that
|
||||
only runs commands never calls it and never pays for it.
|
||||
"""
|
||||
if not _wait_for_desktop(session, timeout):
|
||||
raise SessionError(
|
||||
"the desktop did not finish coming up within "
|
||||
f"{timeout:.0f}s, so there is nothing on screen to look at"
|
||||
)
|
||||
return True
|
||||
|
||||
|
||||
def _wait_for_desktop(session: Session, timeout: float) -> bool:
|
||||
"""Wait for a graphical session that is actually ON THE SCREEN.
|
||||
|
||||
THE PROBE USED TO ASK THE WRONG QUESTION, TWICE.
|
||||
|
||||
First it asked whether any seat session existed - which gdm's own
|
||||
greeter satisfies, long before the user is logged in. Then, with that
|
||||
fixed, a machine still handed over showing a boot splash: measured,
|
||||
`gnome-shell` was running, plymouth was gone, graphical.target was
|
||||
active, and the framebuffer sat on the splash for over three minutes.
|
||||
|
||||
The reason is that the display shows the FOREGROUND VIRTUAL TERMINAL,
|
||||
and the session was not on it: `fgconsole` said 1, the session's TTY
|
||||
was tty2, and `loginctl` agreed the session was `Active=no`. Plymouth
|
||||
had had tty1 and nothing switched away from it. `chvt 2` put the
|
||||
desktop on screen in under a second.
|
||||
|
||||
That is also why it looked intermittent - sometimes gdm switches and
|
||||
sometimes it does not. So this does not wait for the switch to happen
|
||||
on its own; it makes it happen, and then checks.
|
||||
"""
|
||||
if timeout <= 0:
|
||||
return False
|
||||
if session.os_name == "windows":
|
||||
return _wait_for_windows_desktop(session, timeout)
|
||||
probe = (
|
||||
# The operator's session, not gdm's greeter.
|
||||
"sid=$(loginctl list-sessions --no-legend | "
|
||||
"awk '$3!=\"gdm\" && $4==\"seat0\"{print $1}' | head -1); "
|
||||
"[ -n \"$sid\" ] || exit 1; "
|
||||
# Put it on the screen if something else still has the console.
|
||||
"vt=$(loginctl show-session \"$sid\" -p TTY --value 2>/dev/null); vt=${vt#tty}; "
|
||||
"case \"$vt\" in [0-9]*) "
|
||||
" [ \"$(fgconsole 2>/dev/null)\" = \"$vt\" ] || chvt \"$vt\" 2>/dev/null ;; "
|
||||
"esac; "
|
||||
"[ \"$(loginctl show-session \"$sid\" -p Active --value 2>/dev/null)\" = yes ] || exit 1; "
|
||||
"pgrep -x gnome-shell >/dev/null || exit 1; "
|
||||
# Plymouth still running means the splash still owns the display.
|
||||
"! pgrep -x plymouthd >/dev/null"
|
||||
)
|
||||
deadline = time.monotonic() + timeout
|
||||
while time.monotonic() < deadline:
|
||||
try:
|
||||
with session.guest(timeout=10.0) as agent:
|
||||
if agent.shell(probe, timeout_s=20)["exit_code"] == 0:
|
||||
# MEASURED: mutter takes two to three seconds to
|
||||
# repaint the whole screen after a VT switch. With a
|
||||
# one-second settle the handover screenshot came back
|
||||
# 3.4 KB - a near-black frame - and three seconds
|
||||
# later it was the full 400 KB desktop. The first
|
||||
# picture the agent sees is this one, so it waits.
|
||||
time.sleep(3.0)
|
||||
return True
|
||||
except (GuestError, OSError):
|
||||
pass
|
||||
time.sleep(2.0)
|
||||
return False
|
||||
|
||||
|
||||
def _wait_for_windows_desktop(session: Session, timeout: float) -> bool:
|
||||
"""Windows has no VT to switch and no compositor to wait for.
|
||||
|
||||
What it has is explorer.exe, which exists once somebody is logged in
|
||||
and the shell has drawn itself. Auto-logon is part of the image
|
||||
contract, so this is a wait rather than a question.
|
||||
"""
|
||||
probe = ("if (Get-Process explorer -ErrorAction SilentlyContinue) { exit 0 } else { exit 1 }")
|
||||
deadline = time.monotonic() + timeout
|
||||
while time.monotonic() < deadline:
|
||||
try:
|
||||
with session.guest(timeout=15.0) as agent:
|
||||
if agent.shell(probe, timeout_s=25)["exit_code"] == 0:
|
||||
time.sleep(2.0)
|
||||
return True
|
||||
except (GuestError, OSError):
|
||||
pass
|
||||
time.sleep(3.0)
|
||||
return False
|
||||
|
||||
|
||||
def _wait_for(predicate, timeout: float, message: str) -> None:
|
||||
deadline = time.monotonic() + timeout
|
||||
while time.monotonic() < deadline:
|
||||
if predicate():
|
||||
return
|
||||
time.sleep(0.1)
|
||||
raise SessionError(message)
|
||||
|
||||
|
||||
def snapshot(os_name: str, settle: float = 45.0) -> dict:
|
||||
"""Save a booted machine so later sessions can resume it.
|
||||
|
||||
WHAT IS SAVED, AND WHY IT IS FOUR THINGS RATHER THAN ONE.
|
||||
|
||||
A saved machine is memory plus the exact disk that memory describes,
|
||||
plus the firmware variables it booted with. Separate them and nothing
|
||||
complains: the guest simply runs with a filesystem in memory that
|
||||
disagrees with the one on disk. So all of it is written together, and
|
||||
a manifest records the checksum of the base image underneath - which
|
||||
is what `warm_set` checks before trusting any of it.
|
||||
|
||||
Taken WITHOUT a project share, because virtiofsd refuses migration
|
||||
("vhost-user backend lacks VHOST_USER_PROTOCOL_F_LOG_SHMFD"). The
|
||||
share is plugged in after the resume instead.
|
||||
"""
|
||||
if os_name not in SUPPORTED_OS:
|
||||
raise SessionError(f"'{os_name}' is not a machine this runs.")
|
||||
base_sha = _base_checksum(os_name)
|
||||
if not base_sha:
|
||||
raise SessionError(
|
||||
f"no checksum is recorded for the {os_name} image, so a saved machine "
|
||||
"could never be shown to match it. Freeze the image first.")
|
||||
|
||||
session = create(os_name, None, wait=False, allow_warm=False)
|
||||
try:
|
||||
await_ready(session, desktop=(os_name != "windows"))
|
||||
# Let the machine finish what a fresh boot does after it answers -
|
||||
# service startup, the first-run work Windows does - so the saved
|
||||
# machine is one somebody would want to be handed.
|
||||
time.sleep(settle)
|
||||
warm = os.path.join(WARM_ROOT, os_name)
|
||||
staging = warm + ".new"
|
||||
shutil.rmtree(staging, ignore_errors=True)
|
||||
os.makedirs(staging, mode=0o751, exist_ok=True)
|
||||
|
||||
# Written straight into the staging directory through a descriptor
|
||||
# vmd owns - see Qmp.save_state_to_fd. Four gigabytes that never
|
||||
# get copied, and a file qemu could not have named.
|
||||
state_path = os.path.join(staging, "state")
|
||||
fd = os.open(state_path, os.O_CREAT | os.O_WRONLY | os.O_TRUNC, 0o644)
|
||||
try:
|
||||
with Qmp(session.qmp_path, timeout=30.0) as qmp:
|
||||
qmp.save_state_to_fd(fd)
|
||||
finally:
|
||||
os.close(fd)
|
||||
# The disk first: it is the thing the memory describes.
|
||||
subprocess.run(["/usr/bin/qemu-img", "convert", "-O", "qcow2",
|
||||
"-B", _base_paths(os_name)[0], "-F", "qcow2",
|
||||
session.overlay, os.path.join(staging, f"{os_name}.qcow2")],
|
||||
capture_output=True, text=True, timeout=900, check=True)
|
||||
shutil.copyfile(session.vars_file, os.path.join(staging, "OVMF_VARS.fd"))
|
||||
with open(os.path.join(staging, "manifest.json"), "w", encoding="utf-8") as handle:
|
||||
json.dump({"os": os_name, "base_sha256": base_sha,
|
||||
"created": time.strftime("%Y-%m-%dT%H:%M:%S%z"),
|
||||
"state_bytes": os.path.getsize(state_path)}, handle, indent=2)
|
||||
# WHO MAY READ WHAT, AND WHY THEY DIFFER.
|
||||
#
|
||||
# `state` is a four-gigabyte image of the machine's memory. It is
|
||||
# readable by the qemu user, which has to load it, and by nobody
|
||||
# else - a memory dump is not a thing to leave world-readable
|
||||
# whatever is in it.
|
||||
#
|
||||
# `manifest.json` says which image the saved machine belongs to
|
||||
# and when it was taken, and the desktop tool - which runs as the
|
||||
# operator, not as either service user - shows that on the row.
|
||||
# So the directory is traversable and the manifest is readable,
|
||||
# and neither tells anyone anything that `ls -l` would not.
|
||||
for name in os.listdir(staging):
|
||||
os.chmod(os.path.join(staging, name),
|
||||
0o644 if name == "manifest.json" else 0o640)
|
||||
# In place only once every piece is written: a half-written warm
|
||||
# set that passed the checksum test would be worse than none.
|
||||
shutil.rmtree(warm, ignore_errors=True)
|
||||
os.rename(staging, warm)
|
||||
return {"os": os_name, "path": warm,
|
||||
"state_gb": round(os.path.getsize(os.path.join(warm, "state")) / 1e9, 2),
|
||||
"disk_mb": round(os.path.getsize(os.path.join(warm, f"{os_name}.qcow2")) / 1e6, 1),
|
||||
"base_sha256": base_sha}
|
||||
finally:
|
||||
destroy(session)
|
||||
|
||||
|
||||
def destroy(session: Session, graceful: bool = False) -> None:
|
||||
"""Stop the machine and remove everything it used.
|
||||
|
||||
Not graceful by default, deliberately. The overlay is deleted a few
|
||||
lines further down and the project share was committed and pushed
|
||||
before this was called, so asking the guest to shut down politely
|
||||
buys a tidier-looking exit and costs up to ninety seconds of CPU per
|
||||
session. `systemctl stop` on a unit that now kills with SIGKILL is
|
||||
the whole teardown.
|
||||
|
||||
Deliberately never raises. A teardown that fails loudly leaves the
|
||||
unit in `failed` and the overlay on disk, and the NEXT caller pays
|
||||
for it - which is the worst possible place to surface the problem.
|
||||
"""
|
||||
if graceful:
|
||||
try:
|
||||
with session.guest(timeout=5.0) as agent:
|
||||
agent.shutdown("powerdown")
|
||||
_wait_for(lambda: not session.alive(), 20.0, "")
|
||||
except Exception: # noqa: BLE001
|
||||
pass
|
||||
_ctl("vm-stop", check=False)
|
||||
_ctl("vm-reset", check=False)
|
||||
_ctl("vfs-stop", check=False)
|
||||
try:
|
||||
os.unlink(session.overlay)
|
||||
except OSError:
|
||||
pass
|
||||
shutil.rmtree(session.run_dir, ignore_errors=True)
|
||||
try:
|
||||
os.unlink(os.path.join(RUN_ROOT, "session.env"))
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
|
||||
def _gid(name: str) -> int:
|
||||
import grp
|
||||
return grp.getgrnam(name).gr_gid
|
||||
Reference in New Issue
Block a user