mcqemu/tests/test_lifecycle.py
Ryan Malloy f053762c4e Harden failure paths found in the reliability review
The success paths were fine; several failure paths drew a confident
conclusion without checking the thing they waited for.

Registry (was: any parse error killed the whole server, since load() runs
in the lifespan):
- quarantine an unreadable file and start empty instead of raising, so the
  tools that stop runaway VMs keep working when bookkeeping is damaged
- skip malformed or invalidly-named records rather than failing the load;
  report both through list_vms as registry_warnings
- read-modify-write under an exclusive flock so a second instance merges
  instead of clobbering, with a PID-unique temp file
- drop the lifespan shutdown save, which could resurrect deleted records
- version the schema and round-trip unknown record fields

sandbox_destroy (the only tool that deletes files):
- verify the process actually died, escalating to SIGKILL, and refuse to
  delete an overlay QEMU still holds open
- assert the target is inside the VM state tree before rmtree
- report cleanup errors instead of swallowing them; destroyed now reflects
  what happened

Launch races:
- reserve the name before the first await so two concurrent launches cannot
  race over one set of sockets
- refuse to unlink a QMP socket that is still accepting connections
- register the VM with a warning rather than orphaning it when the pidfile
  is unreadable but QEMU is up

Guest agent and QMP:
- bound every guest-agent call, not just the handshake; cap max_bytes and
  stop guest_file_read spinning on a zero-progress agent
- serialize QMP sessions per VM (the monitor is single-client) and say
  "another operation holds it" instead of "the VM has likely exited"
- poll liveness while waiting for SHUTDOWN so a crashed VM is reported as
  exited rather than as a guest ignoring ACPI
- default command timeout, with a longer bound for savevm/loadvm
- stricter snapshot tags; log destructive operations to stderr

Adds tests/test_reliability.py covering the conditions above.
2026-08-17 15:53:09 -06:00

165 lines
6.8 KiB
Python

"""Lifecycle tools with mocked spawn / fake QMP."""
import pytest
from fastmcp import Client
from fastmcp.exceptions import ToolError
from conftest import FakeQMPClient, result_data, write_registry
from mcqemu.models import VMRecord
from mcqemu.server import mcp
def seeded_record(dirs, name="vm1", **kw) -> VMRecord:
defaults = dict(
source="spawned",
qmp_socket=str(dirs.run / name / "qmp.sock"),
qga_socket=str(dirs.run / name / "qga.sock"),
pid=4242,
arch="x86_64",
config={"disks": []},
)
defaults.update(kw)
return VMRecord(name=name, **defaults)
async def test_launch_rejects_bad_name(client):
with pytest.raises(ToolError, match="Invalid VM name"):
await client.call_tool("launch_vm", {"name": "bad name!"})
async def test_launch_rejects_missing_disk(client, tmp_path):
with pytest.raises(ToolError, match="image not found"):
await client.call_tool(
"launch_vm", {"name": "vm1", "disks": [str(tmp_path / "nope.qcow2")]}
)
async def test_launch_rejects_disk_of_running_vm(dirs, all_pids_alive, tmp_path, monkeypatch):
disk = tmp_path / "busy.qcow2"
disk.touch()
write_registry(dirs, seeded_record(dirs, "other", config={"disks": [str(disk)]}))
async with Client(mcp) as client:
with pytest.raises(ToolError, match="already attached to running VM 'other'"):
await client.call_tool("launch_vm", {"name": "vm2", "disks": [str(disk)]})
async def test_launch_rejects_duplicate_running_name(dirs, all_pids_alive):
write_registry(dirs, seeded_record(dirs, "vm1"))
async with Client(mcp) as client:
with pytest.raises(ToolError, match="already running"):
await client.call_tool("launch_vm", {"name": "vm1"})
async def test_launch_happy_path_registers(dirs, monkeypatch):
async def fake_spawn(cfg, config):
return seeded_record(dirs, cfg.name, accel="kvm", config=cfg.__dict__.copy())
monkeypatch.setattr("mcqemu.tools.lifecycle.spawn_vm", fake_spawn)
async with Client(mcp) as client:
data = result_data(await client.call_tool("launch_vm", {"name": "fresh", "no_net": True}))
assert data["status"] == "running"
assert data["accel"] == "kvm"
result = result_data(await client.call_tool("list_vms", {}))
assert [v["name"] for v in result["vms"]] == ["fresh"]
async def test_stop_force_sends_quit(dirs, fake_qmp, all_pids_dead, monkeypatch):
monkeypatch.setattr("mcqemu.tools.lifecycle.Path.exists", lambda self: True)
write_registry(dirs, seeded_record(dirs, "vm1"))
async with Client(mcp) as client:
data = result_data(await client.call_tool("stop_vm", {"name": "vm1", "force": True}))
assert data["status"] == "stopped"
assert ("quit", {}) in FakeQMPClient.calls
async def test_stop_graceful_waits_for_shutdown_event(dirs, fake_qmp, all_pids_dead, monkeypatch):
# is_process_alive is False (all_pids_dead), so make the socket "exist"
# to get past the already-stopped short-circuit.
monkeypatch.setattr("mcqemu.tools.lifecycle.Path.exists", lambda self: True)
FakeQMPClient.events_to_emit = [{"event": "SHUTDOWN"}]
write_registry(dirs, seeded_record(dirs, "vm1"))
async with Client(mcp) as client:
data = result_data(await client.call_tool("stop_vm", {"name": "vm1", "timeout": 2}))
assert data["method"] == "graceful ACPI shutdown"
assert ("system_powerdown", {}) in FakeQMPClient.calls
async def test_stop_graceful_timeout_advises_force(dirs, fake_qmp, all_pids_alive, monkeypatch):
# Guest ignores ACPI: no SHUTDOWN event AND the process stays alive.
monkeypatch.setattr("mcqemu.tools.lifecycle.Path.exists", lambda self: True)
FakeQMPClient.events_to_emit = []
write_registry(dirs, seeded_record(dirs, "vm1"))
async with Client(mcp) as client:
with pytest.raises(ToolError, match="force=True"):
await client.call_tool("stop_vm", {"name": "vm1", "timeout": 1})
async def test_stop_graceful_reports_process_exit(dirs, fake_qmp, all_pids_dead, monkeypatch):
"""A VM that dies mid-shutdown must not be misreported as ignoring ACPI."""
monkeypatch.setattr("mcqemu.tools.lifecycle.Path.exists", lambda self: True)
FakeQMPClient.events_to_emit = [] # crashed before emitting SHUTDOWN
write_registry(dirs, seeded_record(dirs, "vm1"))
async with Client(mcp) as client:
data = result_data(await client.call_tool("stop_vm", {"name": "vm1", "timeout": 30}))
assert data["status"] == "stopped"
assert "exited" in data["method"]
async def test_stop_already_stopped(dirs, all_pids_dead):
write_registry(dirs, seeded_record(dirs, "vm1"))
async with Client(mcp) as client:
data = result_data(await client.call_tool("stop_vm", {"name": "vm1"}))
assert data["status"] == "stopped"
assert "already" in data["note"]
async def test_pause_resume(dirs, fake_qmp, all_pids_alive):
write_registry(dirs, seeded_record(dirs, "vm1"))
async with Client(mcp) as client:
assert (
result_data(await client.call_tool("pause_vm", {"name": "vm1"}))["status"] == "paused"
)
assert (
result_data(await client.call_tool("resume_vm", {"name": "vm1"}))["status"] == "running"
)
assert ("stop", {}) in FakeQMPClient.calls
assert ("cont", {}) in FakeQMPClient.calls
async def test_unknown_vm_lists_known(dirs, fake_qmp):
write_registry(dirs, seeded_record(dirs, "alpha"))
async with Client(mcp) as client:
with pytest.raises(ToolError, match="alpha"):
await client.call_tool("pause_vm", {"name": "nope"})
async def test_attach_requires_existing_socket(client, tmp_path):
with pytest.raises(ToolError, match="No socket"):
await client.call_tool(
"attach_vm", {"name": "ext", "qmp_socket": str(tmp_path / "no.sock")}
)
async def test_attach_and_forget(dirs, fake_qmp, tmp_path):
sock = tmp_path / "ext.sock"
sock.touch()
FakeQMPClient.responses["query-status"] = {"status": "running"}
async with Client(mcp) as client:
data = result_data(
await client.call_tool("attach_vm", {"name": "ext", "qmp_socket": str(sock)})
)
assert data["status"] == "running"
data = result_data(await client.call_tool("forget_vm", {"name": "ext"}))
assert data["forgotten"] is True
result = result_data(await client.call_tool("list_vms", {}))
assert result["vms"] == []
async def test_forget_refuses_running_spawned_vm(dirs, all_pids_alive):
write_registry(dirs, seeded_record(dirs, "vm1"))
async with Client(mcp) as client:
with pytest.raises(ToolError, match="still running"):
await client.call_tool("forget_vm", {"name": "vm1"})
data = result_data(await client.call_tool("forget_vm", {"name": "vm1", "force": True}))
assert data["forgotten"] is True