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.
73 lines
2.4 KiB
Python
73 lines
2.4 KiB
Python
"""Integration: boots a real (diskless) QEMU VM through the MCP tools.
|
|
|
|
Run with: uv run pytest -m integration
|
|
"""
|
|
|
|
import base64
|
|
import shutil
|
|
|
|
import pytest
|
|
from fastmcp import Client
|
|
|
|
from conftest import result_data
|
|
from mcqemu.registry import pid_alive
|
|
from mcqemu.server import mcp
|
|
|
|
pytestmark = [
|
|
pytest.mark.integration,
|
|
pytest.mark.skipif(
|
|
shutil.which("qemu-system-x86_64") is None, reason="qemu-system-x86_64 not installed"
|
|
),
|
|
]
|
|
|
|
|
|
async def test_real_vm_lifecycle(dirs):
|
|
async with Client(mcp) as client:
|
|
data = result_data(
|
|
await client.call_tool(
|
|
"launch_vm",
|
|
{"name": "itest", "memory_mb": 128, "cpus": 1, "no_net": True},
|
|
)
|
|
)
|
|
pid = data["pid"]
|
|
try:
|
|
assert data["status"] == "running"
|
|
assert pid_alive(pid)
|
|
|
|
listing = result_data(await client.call_tool("list_vms", {}))
|
|
assert [(v["name"], v["status"]) for v in listing["vms"]] == [("itest", "running")]
|
|
|
|
info = result_data(await client.call_tool("vm_info", {"name": "itest"}))
|
|
assert info["vcpus"] == 1
|
|
|
|
data = result_data(await client.call_tool("pause_vm", {"name": "itest"}))
|
|
assert data["status"] == "paused"
|
|
listing = result_data(await client.call_tool("list_vms", {}))
|
|
assert listing["vms"][0]["status"] == "paused"
|
|
|
|
await client.call_tool("resume_vm", {"name": "itest"})
|
|
|
|
# See & drive against the live SeaBIOS display.
|
|
result = await client.call_tool("vm_screenshot", {"name": "itest"})
|
|
block = result.content[0]
|
|
assert block.type == "image"
|
|
png = base64.b64decode(block.data)
|
|
assert png[:8] == b"\x89PNG\r\n\x1a\n"
|
|
|
|
await client.call_tool(
|
|
"vm_send_keys", {"name": "itest", "keys": ["f2", "enter"], "delay_ms": 0}
|
|
)
|
|
await client.call_tool("vm_click", {"name": "itest", "x": 10, "y": 10})
|
|
data = result_data(await client.call_tool("vm_serial_read", {"name": "itest"}))
|
|
assert "tail" in data
|
|
|
|
data = result_data(await client.call_tool("stop_vm", {"name": "itest", "force": True}))
|
|
assert data["status"] == "stopped"
|
|
assert not pid_alive(pid)
|
|
finally:
|
|
if pid_alive(pid):
|
|
import os
|
|
import signal
|
|
|
|
os.kill(pid, signal.SIGKILL)
|