Add acceptance suite driving every tool group against real QEMU

The unit tests mock QMP and the guest agent, so they cannot catch a wrong
argument name, a QEMU option that stopped parsing, or a reply shape that
differs from the fake. This suite drives the real thing: the qemu-img
toolchain, lifecycle and see-and-drive on a diskless BIOS VM, adopting a
forgotten VM through attach_vm, the refusal to reuse a live socket, and the
full sandbox journey (guest exec, file round trip, live snapshot create /
restore / delete verified by guest state, screenshot) with a check that the
base image is never modified.

It immediately earned its keep: pid_matches_vm was rejecting attached VMs,
because an externally launched QEMU carries whatever -name its launcher
chose, so adopting one under a different name made it read as stopped. The
identity check now applies only to VMs we spawned.
This commit is contained in:
Ryan Malloy 2026-08-17 17:20:25 -06:00
parent 5a2d4703ab
commit 8c619a1674
4 changed files with 319 additions and 3 deletions

View File

@ -72,6 +72,11 @@ guest_exec(name="test", command="uname", args=["-a"])
```bash ```bash
uv run pytest # unit tests (QMP and subprocess mocked) uv run pytest # unit tests (QMP and subprocess mocked)
uv run pytest -m integration # boots a real tiny VM (needs QEMU installed) uv run pytest -m integration # acceptance: every tool group against real QEMU
uv run ruff check . uv run ruff check .
``` ```
The acceptance suite boots real VMs. The guest-agent and snapshot journey
needs a base image with `qemu-guest-agent` installed — it looks for
`~/vms/ubuntu-agent.qcow2`, overridable with `MCQEMU_TEST_BASE_IMAGE`, and
skips cleanly when absent.

View File

@ -252,8 +252,12 @@ class VMRegistry:
A SIGKILLed QEMU leaves its socket file behind, so for attached VMs we A SIGKILLed QEMU leaves its socket file behind, so for attached VMs we
connect rather than just stat a stale file must not read as alive. connect rather than just stat a stale file must not read as alive.
""" """
if record.source == "attached" and record.pid is None: if record.source == "attached":
return socket_is_live(Path(record.qmp_socket)) # An externally launched VM was given its -name by someone else, so
# the identity check below cannot apply to it.
if record.pid is None:
return socket_is_live(Path(record.qmp_socket))
return pid_alive(record.pid)
self.refresh_pid(record) self.refresh_pid(record)
return pid_alive(record.pid) and pid_matches_vm(record.pid, record.name) return pid_alive(record.pid) and pid_matches_vm(record.pid, record.name)

292
tests/test_acceptance.py Normal file
View File

@ -0,0 +1,292 @@
"""End-to-end acceptance: every tool group against a real QEMU.
Unit tests mock QMP and the guest agent, so they cannot catch a wrong QMP
argument name, a QEMU option that no longer parses, or a guest agent reply
shape that differs from the fake. This suite drives the real thing.
Run with: uv run pytest -m integration -q
The guest-agent and display journeys need a base image with qemu-guest-agent
installed; set MCQEMU_TEST_BASE_IMAGE or drop one at ~/vms/ubuntu-agent.qcow2.
"""
import base64
import os
import shutil
from pathlib import Path
import pytest
from fastmcp import Client
from conftest import result_data
from mcqemu.server import mcp
pytestmark = pytest.mark.integration
BASE_IMAGE = Path(
os.environ.get("MCQEMU_TEST_BASE_IMAGE", str(Path.home() / "vms" / "ubuntu-agent.qcow2"))
)
needs_qemu = pytest.mark.skipif(
shutil.which("qemu-system-x86_64") is None, reason="qemu-system-x86_64 not installed"
)
needs_agent_image = pytest.mark.skipif(
not BASE_IMAGE.exists(),
reason=f"no guest-agent base image at {BASE_IMAGE} (set MCQEMU_TEST_BASE_IMAGE)",
)
async def guest_sh(client, name: str, script: str, timeout: int = 60) -> str:
result = await client.call_tool(
"guest_exec",
{"name": name, "command": "/bin/sh", "args": ["-c", script], "timeout": timeout},
)
assert result.data["exitcode"] == 0, result.data
return result.data["stdout"]
@needs_qemu
async def test_image_toolchain(dirs, tmp_path):
"""All eight qemu-img tools, including the overlay chain sandboxes rely on."""
base = tmp_path / "base.qcow2"
overlay = tmp_path / "overlay.qcow2"
raw = tmp_path / "flat.raw"
async with Client(mcp) as client:
created = result_data(
await client.call_tool("image_create", {"path": str(base), "size": "64M"})
)
assert created["virtual_size_bytes"] == 64 * 1024**2
result_data(
await client.call_tool(
"image_create",
{"path": str(overlay), "size": "64M", "backing_file": str(base)},
)
)
chain = result_data(
await client.call_tool("image_info", {"path": str(overlay), "backing_chain": True})
)
assert len(chain) == 2, "overlay must report its backing file"
result_data(await client.call_tool("image_resize", {"path": str(base), "size": "128M"}))
assert result_data(await client.call_tool("image_info", {"path": str(base)}))[
"virtual-size"
] == 128 * 1024**2
converted = result_data(
await client.call_tool(
"image_convert", {"source": str(base), "dest": str(raw), "format": "raw"}
)
)
assert converted["format"] == "raw"
await client.call_tool("image_snapshot_create", {"path": str(base), "tag": "clean"})
snaps = result_data(await client.call_tool("image_snapshot_list", {"path": str(base)}))
assert [s["tag"] for s in snaps] == ["clean"]
await client.call_tool("image_snapshot_apply", {"path": str(base), "tag": "clean"})
await client.call_tool("image_snapshot_delete", {"path": str(base), "tag": "clean"})
assert result_data(await client.call_tool("image_snapshot_list", {"path": str(base)})) == []
@needs_qemu
async def test_lifecycle_and_display_on_a_diskless_vm(dirs):
"""Lifecycle plus see-and-drive against SeaBIOS — no guest OS required."""
async with Client(mcp) as client:
launched = result_data(
await client.call_tool(
"launch_vm",
{"name": "acc-bios", "memory_mb": 128, "cpus": 1, "no_net": True},
)
)
assert launched["status"] == "running"
try:
listing = result_data(await client.call_tool("list_vms", {}))
assert listing["registry_warnings"] == []
assert [v["name"] for v in listing["vms"]] == ["acc-bios"]
info = result_data(await client.call_tool("vm_info", {"name": "acc-bios"}))
assert info["vcpus"] == 1 and info["status"] == "running"
shot = await client.call_tool("vm_screenshot", {"name": "acc-bios"})
assert base64.b64decode(shot.content[0].data)[:8] == b"\x89PNG\r\n\x1a\n"
await client.call_tool("vm_send_keys", {"name": "acc-bios", "keys": ["f2", "esc"]})
await client.call_tool("vm_type_text", {"name": "acc-bios", "text": "hello"})
await client.call_tool("vm_click", {"name": "acc-bios", "x": 5, "y": 5})
moved = result_data(
await client.call_tool(
"vm_mouse_move",
{"name": "acc-bios", "home": "bottom-right", "dx": -20, "dy": -20},
)
)
assert moved["homed"] == "bottom-right"
serial = result_data(await client.call_tool("vm_serial_read", {"name": "acc-bios"}))
assert "log_bytes" in serial
assert (
result_data(await client.call_tool("pause_vm", {"name": "acc-bios"}))["status"]
== "paused"
)
assert (
result_data(await client.call_tool("resume_vm", {"name": "acc-bios"}))["status"]
== "running"
)
finally:
result_data(await client.call_tool("stop_vm", {"name": "acc-bios", "force": True}))
assert result_data(await client.call_tool("list_vms", {}))["vms"][0]["status"] == "stopped"
await client.call_tool("forget_vm", {"name": "acc-bios"})
@needs_qemu
async def test_attach_to_an_externally_managed_vm(dirs):
"""A VM we forget (leaving it running) can be re-adopted through attach_vm."""
async with Client(mcp) as client:
launched = result_data(
await client.call_tool(
"launch_vm",
{"name": "acc-attach", "memory_mb": 128, "cpus": 1, "no_net": True},
)
)
socket_path, pid = launched["qmp_socket"], launched["pid"]
# Deliberately orphan it, then adopt it under a new name.
result_data(await client.call_tool("forget_vm", {"name": "acc-attach", "force": True}))
assert result_data(await client.call_tool("list_vms", {}))["vms"] == []
adopted = result_data(
await client.call_tool(
"attach_vm",
{"name": "acc-adopted", "qmp_socket": socket_path, "pid": pid},
)
)
assert adopted["status"] == "running"
try:
info = result_data(await client.call_tool("vm_info", {"name": "acc-adopted"}))
assert info["source"] == "attached" and info["status"] == "running"
finally:
result_data(await client.call_tool("stop_vm", {"name": "acc-adopted", "force": True}))
await client.call_tool("forget_vm", {"name": "acc-adopted"})
@needs_qemu
async def test_launch_refuses_to_trample_a_running_vm(dirs):
"""The name of a running-but-forgotten VM must not be silently reused."""
async with Client(mcp) as client:
launched = result_data(
await client.call_tool(
"launch_vm", {"name": "acc-ghost", "memory_mb": 128, "cpus": 1, "no_net": True}
)
)
await client.call_tool("forget_vm", {"name": "acc-ghost", "force": True})
try:
with pytest.raises(Exception, match="LIVE QMP socket"):
await client.call_tool(
"launch_vm",
{"name": "acc-ghost", "memory_mb": 128, "cpus": 1, "no_net": True},
)
finally:
await client.call_tool(
"attach_vm",
{
"name": "acc-ghost-adopted",
"qmp_socket": launched["qmp_socket"],
"pid": launched["pid"],
},
)
await client.call_tool("stop_vm", {"name": "acc-ghost-adopted", "force": True})
await client.call_tool("forget_vm", {"name": "acc-ghost-adopted"})
@needs_qemu
@needs_agent_image
async def test_sandbox_guest_and_snapshot_journey(dirs):
"""The full disposable-sandbox workflow against a real guest OS."""
base_before = (BASE_IMAGE.stat().st_size, BASE_IMAGE.stat().st_mtime)
async with Client(mcp) as client:
sandbox = result_data(
await client.call_tool(
"sandbox_vm", {"base_image": str(BASE_IMAGE), "memory_mb": 2048}
)
)
assert sandbox["guest_agent"] == "responding", sandbox
assert sandbox["network"] == "outbound blocked"
assert sandbox["port_forwards"][0].endswith(":22")
name = sandbox["name"]
try:
info = result_data(await client.call_tool("guest_info", {"name": name}))
assert info["os"]["id"], "guest should report its OS"
assert "guest-exec" in info["supported_commands"]
assert "acceptance" in await guest_sh(client, name, "echo acceptance")
marker = "/tmp/mcqemu-acceptance.txt"
written = result_data(
await client.call_tool(
"guest_file_write",
{"name": name, "path": marker, "content": "written by the host\n"},
)
)
assert written["bytes_written"] == len("written by the host\n")
read = result_data(
await client.call_tool("guest_file_read", {"name": name, "path": marker})
)
assert read["content"] == "written by the host\n"
# The guest must agree the file exists — proves we wrote to the guest,
# not to some host path.
assert "written by the host" in await guest_sh(client, name, f"cat {marker}")
# Live snapshot round trip, with a marker that must survive the
# restore because it predates the snapshot.
await guest_sh(client, name, "echo pre-snapshot > /tmp/marker")
result_data(
await client.call_tool("vm_snapshot_create", {"name": name, "tag": "acc-point"})
)
snaps = result_data(await client.call_tool("vm_snapshot_list", {"name": name}))
assert "acc-point" in snaps["snapshots_raw"]
await guest_sh(client, name, "echo post-snapshot >> /tmp/marker")
assert "post-snapshot" in await guest_sh(client, name, "cat /tmp/marker")
result_data(
await client.call_tool("vm_snapshot_restore", {"name": name, "tag": "acc-point"})
)
restored = await guest_sh(client, name, "cat /tmp/marker")
assert "pre-snapshot" in restored
assert "post-snapshot" not in restored, "restore must roll back guest state"
result_data(
await client.call_tool("vm_snapshot_delete", {"name": name, "tag": "acc-point"})
)
shot = await client.call_tool("vm_screenshot", {"name": name})
assert base64.b64decode(shot.content[0].data)[:8] == b"\x89PNG\r\n\x1a\n"
finally:
destroyed = result_data(await client.call_tool("sandbox_destroy", {"name": name}))
assert destroyed["destroyed"] is True
assert destroyed["overlay_deleted"] is True
assert result_data(await client.call_tool("list_vms", {}))["vms"] == []
assert (BASE_IMAGE.stat().st_size, BASE_IMAGE.stat().st_mtime) == base_before, (
"the base image must never be modified"
)
@needs_qemu
async def test_resources_and_prompt_are_reachable(dirs):
async with Client(mcp) as client:
launched = result_data(
await client.call_tool(
"launch_vm", {"name": "acc-res", "memory_mb": 128, "cpus": 1, "no_net": True}
)
)
assert launched["status"] == "running"
try:
contents = await client.read_resource("mcqemu://vms")
assert "acc-res" in contents[0].text
one = await client.read_resource("mcqemu://vm/acc-res")
assert "acc-res" in one[0].text
prompt = await client.get_prompt("provision_test_vm", {"os_hint": "Alpine"})
assert "image_create" in prompt.messages[0].content.text
finally:
await client.call_tool("stop_vm", {"name": "acc-res", "force": True})
await client.call_tool("forget_vm", {"name": "acc-res"})

View File

@ -445,3 +445,18 @@ def test_sandbox_base_counts_as_in_use(dirs):
assert str(base.resolve()) in registry.disks_in_use() assert str(base.resolve()) in registry.disks_in_use()
finally: finally:
listener.close() listener.close()
def test_attached_vm_identity_is_not_judged_by_name(dirs, monkeypatch):
"""An adopted VM carries whatever -name its launcher chose, so the
spawned-VM identity check must not apply to it."""
registry = VMRegistry(config_for(dirs))
registry.load()
monkeypatch.setattr("mcqemu.registry.pid_alive", lambda pid: True)
monkeypatch.setattr("mcqemu.registry.pid_matches_vm", lambda pid, name: False)
attached = VMRecord(name="adopted", source="attached", qmp_socket="/tmp/q.sock", pid=1234)
assert registry.is_process_alive(attached) is True
spawned = VMRecord(name="ours", source="spawned", qmp_socket="/tmp/q.sock", pid=1234)
assert registry.is_process_alive(spawned) is False