Add test suite: fake QMP client, launcher table tests, real qemu-img and boot integration

This commit is contained in:
Ryan Malloy 2026-08-16 21:05:02 -06:00
parent 8e7286c474
commit 7377ad5dd8
18 changed files with 886 additions and 26 deletions

View File

@ -46,6 +46,10 @@ src = ["src", "tests"]
[tool.ruff.lint] [tool.ruff.lint]
select = ["E", "F", "I", "UP", "B", "SIM", "ASYNC"] select = ["E", "F", "I", "UP", "B", "SIM", "ASYNC"]
# ASYNC109: `timeout` params here are deliberate LLM-facing tool parameters.
# ASYNC110: polling an external process's death has no event to await.
# ASYNC240: pathlib use is microsecond stat/exists checks, not bulk I/O.
ignore = ["ASYNC109", "ASYNC110", "ASYNC240"]
[tool.pytest.ini_options] [tool.pytest.ini_options]
asyncio_mode = "auto" asyncio_mode = "auto"

View File

@ -3,8 +3,8 @@
from __future__ import annotations from __future__ import annotations
import os import os
import re
import platform import platform
import re
from dataclasses import dataclass from dataclasses import dataclass
from pathlib import Path from pathlib import Path

View File

@ -6,7 +6,7 @@ import asyncio
import re import re
import shutil import shutil
from dataclasses import dataclass from dataclasses import dataclass
from datetime import datetime, timezone from datetime import UTC, datetime
from pathlib import Path from pathlib import Path
from fastmcp.exceptions import ToolError from fastmcp.exceptions import ToolError
@ -78,8 +78,10 @@ def build_cmdline(
) )
if fw["mode"] == "pflash": if fw["mode"] == "pflash":
args += [ args += [
"-drive", f"if=pflash,format=raw,readonly=on,file={fw['code']}", "-drive",
"-drive", f"if=pflash,format=raw,file={paths.uefi_vars}", f"if=pflash,format=raw,readonly=on,file={fw['code']}",
"-drive",
f"if=pflash,format=raw,file={paths.uefi_vars}",
] ]
else: else:
args += ["-bios", fw["code"]] args += ["-bios", fw["code"]]
@ -108,15 +110,23 @@ def build_cmdline(
args += ["-netdev", netdev, "-device", "virtio-net-pci,netdev=net0"] args += ["-netdev", netdev, "-device", "virtio-net-pci,netdev=net0"]
args += [ args += [
"-display", "none", "-display",
"-serial", f"file:{paths.serial_log}", "none",
"-qmp", f"unix:{paths.qmp_socket},server=on,wait=off", "-serial",
"-chardev", f"socket,id=qga0,path={paths.qga_socket},server=on,wait=off", f"file:{paths.serial_log}",
"-device", "virtio-serial", "-qmp",
"-device", "virtserialport,chardev=qga0,name=org.qemu.guest_agent.0", f"unix:{paths.qmp_socket},server=on,wait=off",
"-chardev",
f"socket,id=qga0,path={paths.qga_socket},server=on,wait=off",
"-device",
"virtio-serial",
"-device",
"virtserialport,chardev=qga0,name=org.qemu.guest_agent.0",
"-daemonize", "-daemonize",
"-pidfile", str(paths.pidfile), "-pidfile",
"-D", str(paths.qemu_log), str(paths.pidfile),
"-D",
str(paths.qemu_log),
] ]
args += cfg.extra_args args += cfg.extra_args
return args return args
@ -189,5 +199,5 @@ async def spawn_vm(cfg: VMConfig, config: Config) -> VMRecord:
config=cfg.__dict__.copy(), config=cfg.__dict__.copy(),
qemu_log=str(paths.qemu_log), qemu_log=str(paths.qemu_log),
serial_log=str(paths.serial_log), serial_log=str(paths.serial_log),
created_at=datetime.now(timezone.utc).isoformat(timespec="seconds"), created_at=datetime.now(UTC).isoformat(timespec="seconds"),
) )

View File

@ -2,7 +2,7 @@
from __future__ import annotations from __future__ import annotations
from dataclasses import dataclass, field, asdict from dataclasses import asdict, dataclass, field
from typing import Any, Literal from typing import Any, Literal

View File

@ -12,8 +12,8 @@ from __future__ import annotations
import asyncio import asyncio
import contextlib import contextlib
import secrets import secrets
from collections.abc import AsyncIterator
from contextlib import asynccontextmanager from contextlib import asynccontextmanager
from typing import AsyncIterator
from fastmcp.exceptions import ToolError from fastmcp.exceptions import ToolError
from qemu.qmp import QMPClient from qemu.qmp import QMPClient
@ -44,7 +44,7 @@ async def qga_session(record: VMRecord) -> AsyncIterator[QMPClient]:
) )
if reply != token: if reply != token:
raise guest_agent_unavailable(record.name) raise guest_agent_unavailable(record.name)
except (asyncio.TimeoutError, OSError): except (TimeoutError, OSError):
raise guest_agent_unavailable(record.name) from None raise guest_agent_unavailable(record.name) from None
except ToolError: except ToolError:
raise raise

View File

@ -10,8 +10,9 @@ from __future__ import annotations
import asyncio import asyncio
import contextlib import contextlib
from collections.abc import AsyncIterator
from contextlib import asynccontextmanager from contextlib import asynccontextmanager
from typing import Any, AsyncIterator from typing import Any
from qemu.qmp import ExecuteError, QMPClient from qemu.qmp import ExecuteError, QMPClient
@ -27,7 +28,7 @@ async def qmp_session(record: VMRecord) -> AsyncIterator[QMPClient]:
try: try:
try: try:
await asyncio.wait_for(client.connect(record.qmp_socket), timeout=CONNECT_TIMEOUT) await asyncio.wait_for(client.connect(record.qmp_socket), timeout=CONNECT_TIMEOUT)
except asyncio.TimeoutError: except TimeoutError:
raise qmp_unreachable( raise qmp_unreachable(
record.name, record.qmp_socket, "connect timed out", record.qemu_log record.name, record.qmp_socket, "connect timed out", record.qemu_log
) from None ) from None
@ -63,5 +64,5 @@ async def wait_for_event(client: QMPClient, event_name: str, timeout: float) ->
try: try:
await asyncio.wait_for(_drain(), timeout=timeout) await asyncio.wait_for(_drain(), timeout=timeout)
return True return True
except asyncio.TimeoutError: except TimeoutError:
return False return False

View File

@ -2,6 +2,7 @@
from __future__ import annotations from __future__ import annotations
import contextlib
import json import json
import os import os
from pathlib import Path from pathlib import Path
@ -66,10 +67,8 @@ class VMRegistry:
def refresh_pid(self, record: VMRecord) -> int | None: def refresh_pid(self, record: VMRecord) -> int | None:
"""Re-read the pidfile for a spawned VM; returns the PID or None.""" """Re-read the pidfile for a spawned VM; returns the PID or None."""
if record.pidfile: if record.pidfile:
try: with contextlib.suppress(OSError, ValueError):
record.pid = int(Path(record.pidfile).read_text().strip()) record.pid = int(Path(record.pidfile).read_text().strip())
except (OSError, ValueError):
pass
return record.pid return record.pid
def is_process_alive(self, record: VMRecord) -> bool: def is_process_alive(self, record: VMRecord) -> bool:

View File

@ -32,9 +32,7 @@ async def guest_info(name: str, ctx: Context = None) -> dict:
osinfo = await client.execute("guest-get-osinfo") osinfo = await client.execute("guest-get-osinfo")
except Exception: except Exception:
osinfo = None osinfo = None
supported = sorted( supported = sorted(c["name"] for c in agent.get("supported_commands", []) if c.get("enabled"))
c["name"] for c in agent.get("supported_commands", []) if c.get("enabled")
)
return { return {
"name": name, "name": name,
"agent_version": agent.get("version"), "agent_version": agent.get("version"),

View File

@ -193,7 +193,11 @@ async def forget_vm(name: str, force: bool = False, ctx: Context = None) -> dict
"or pass force=True to deliberately orphan the process." "or pass force=True to deliberately orphan the process."
) )
state.registry.remove(name) state.registry.remove(name)
return {"name": name, "forgotten": True, "process_left_running": bool(record.pid and pid_alive(record.pid))} return {
"name": name,
"forgotten": True,
"process_left_running": bool(record.pid and pid_alive(record.pid)),
}
def register(mcp: FastMCP) -> None: def register(mcp: FastMCP) -> None:

109
tests/conftest.py Normal file
View File

@ -0,0 +1,109 @@
"""Shared fixtures: temp XDG dirs, scriptable fake QMP client, in-memory MCP client."""
import asyncio
import json
from types import SimpleNamespace
import pytest
from fastmcp import Client
from mcqemu.server import mcp
@pytest.fixture
def dirs(tmp_path, monkeypatch):
state = tmp_path / "state"
run = tmp_path / "run"
state.mkdir()
run.mkdir()
monkeypatch.setenv("MCQEMU_STATE_DIR", str(state))
monkeypatch.setenv("MCQEMU_RUNTIME_DIR", str(run))
return SimpleNamespace(state=state, run=run)
def write_registry(dirs, *records) -> None:
"""Pre-seed the registry file before a Client connects (lifespan loads it)."""
(dirs.state / "vms.json").write_text(json.dumps({r.name: r.to_dict() for r in records}))
@pytest.fixture
async def client(dirs):
async with Client(mcp) as c:
yield c
def result_data(result):
# Empty-list results carry no text content block; .data covers both shapes.
if result.data is not None:
return result.data
return json.loads(result.content[0].text)
class FakeQMPClient:
"""Stands in for qemu.qmp.QMPClient. Script it via class attributes:
responses: dict command -> canned reply, Exception to raise, or callable(args)
events_to_emit: list of event dicts fed to the events queue
connect_error: exception raised from connect()
calls: recorded (command, arguments) tuples
"""
responses: dict = {}
events_to_emit: list = []
connect_error: Exception | None = None
calls: list = []
@classmethod
def reset(cls):
cls.responses = {}
cls.events_to_emit = []
cls.connect_error = None
cls.calls = []
def __init__(self, name: str):
self.name = name
self.await_greeting = True
self.negotiate = True
self._events: asyncio.Queue = asyncio.Queue()
async def connect(self, address):
if FakeQMPClient.connect_error is not None:
raise FakeQMPClient.connect_error
for event in FakeQMPClient.events_to_emit:
self._events.put_nowait(event)
async def disconnect(self):
pass
async def execute(self, command, arguments=None):
FakeQMPClient.calls.append((command, arguments))
reply = FakeQMPClient.responses.get(command, {})
if isinstance(reply, Exception):
raise reply
if callable(reply):
return reply(arguments)
return reply
@property
def events(self):
return self._events
@pytest.fixture
def fake_qmp(monkeypatch):
FakeQMPClient.reset()
monkeypatch.setattr("mcqemu.qmp.QMPClient", FakeQMPClient)
monkeypatch.setattr("mcqemu.qga.QMPClient", FakeQMPClient)
return FakeQMPClient
@pytest.fixture
def all_pids_dead(monkeypatch):
monkeypatch.setattr("mcqemu.registry.pid_alive", lambda pid: False)
monkeypatch.setattr("mcqemu.tools.lifecycle.pid_alive", lambda pid: False)
@pytest.fixture
def all_pids_alive(monkeypatch):
monkeypatch.setattr("mcqemu.registry.pid_alive", lambda pid: bool(pid))
monkeypatch.setattr("mcqemu.tools.lifecycle.pid_alive", lambda pid: bool(pid))

120
tests/test_guest.py Normal file
View File

@ -0,0 +1,120 @@
"""Guest-agent tools with a scripted fake QGA."""
import base64
import pytest
from fastmcp import Client
from fastmcp.exceptions import ToolError
from conftest import FakeQMPClient, result_data, write_registry
from mcqemu.server import mcp
from test_lifecycle import seeded_record
def b64(s: str) -> str:
return base64.b64encode(s.encode()).decode()
def arm_sync():
FakeQMPClient.responses["guest-sync"] = lambda args: args["id"]
async def test_guest_ping(dirs, fake_qmp):
arm_sync()
FakeQMPClient.responses["guest-ping"] = {}
write_registry(dirs, seeded_record(dirs, "vm1"))
async with Client(mcp) as client:
data = result_data(await client.call_tool("guest_ping", {"name": "vm1"}))
assert data["guest_agent"] == "responding"
async def test_guest_agent_absent_gives_actionable_error(dirs, fake_qmp):
FakeQMPClient.connect_error = ConnectionRefusedError("nobody listening")
write_registry(dirs, seeded_record(dirs, "vm1"))
async with Client(mcp) as client:
with pytest.raises(ToolError, match="qemu-guest-agent"):
await client.call_tool("guest_ping", {"name": "vm1"})
async def test_guest_exec_collects_output(dirs, fake_qmp):
arm_sync()
FakeQMPClient.responses["guest-exec"] = {"pid": 77}
FakeQMPClient.responses["guest-exec-status"] = {
"exited": True,
"exitcode": 0,
"out-data": b64("Linux vm1 6.1.0\n"),
}
write_registry(dirs, seeded_record(dirs, "vm1"))
async with Client(mcp) as client:
data = result_data(
await client.call_tool(
"guest_exec", {"name": "vm1", "command": "uname", "args": ["-a"]}
)
)
assert data["exitcode"] == 0
assert "Linux vm1" in data["stdout"]
exec_call = next(a for c, a in FakeQMPClient.calls if c == "guest-exec")
assert exec_call == {"path": "uname", "capture-output": True, "arg": ["-a"]}
async def test_guest_exec_timeout(dirs, fake_qmp):
arm_sync()
FakeQMPClient.responses["guest-exec"] = {"pid": 77}
FakeQMPClient.responses["guest-exec-status"] = {"exited": False}
write_registry(dirs, seeded_record(dirs, "vm1"))
async with Client(mcp) as client:
with pytest.raises(ToolError, match="still running"):
await client.call_tool(
"guest_exec", {"name": "vm1", "command": "sleep", "args": ["999"], "timeout": 1}
)
async def test_guest_file_round_trip(dirs, fake_qmp):
arm_sync()
FakeQMPClient.responses["guest-file-open"] = 5
FakeQMPClient.responses["guest-file-write"] = {"count": 12}
FakeQMPClient.responses["guest-file-read"] = {
"count": 12,
"buf-b64": b64("hello guest\n"),
"eof": True,
}
FakeQMPClient.responses["guest-file-close"] = {}
write_registry(dirs, seeded_record(dirs, "vm1"))
async with Client(mcp) as client:
data = result_data(
await client.call_tool(
"guest_file_write",
{"name": "vm1", "path": "/tmp/x", "content": "hello guest\n"},
)
)
assert data["bytes_written"] == 12
data = result_data(
await client.call_tool("guest_file_read", {"name": "vm1", "path": "/tmp/x"})
)
assert data["content"] == "hello guest\n"
assert data["truncated"] is False
open_calls = [a for c, a in FakeQMPClient.calls if c == "guest-file-open"]
assert {"path": "/tmp/x", "mode": "w"} in open_calls
assert {"path": "/tmp/x", "mode": "r"} in open_calls
assert len([c for c, _ in FakeQMPClient.calls if c == "guest-file-close"]) == 2
async def test_guest_session_disables_negotiation(dirs, fake_qmp):
arm_sync()
FakeQMPClient.responses["guest-ping"] = {}
seen = {}
original_connect = FakeQMPClient.connect
async def spy_connect(self, address):
seen["negotiate"] = self.negotiate
seen["await_greeting"] = self.await_greeting
await original_connect(self, address)
FakeQMPClient.connect = spy_connect
try:
write_registry(dirs, seeded_record(dirs, "vm1"))
async with Client(mcp) as client:
await client.call_tool("guest_ping", {"name": "vm1"})
finally:
FakeQMPClient.connect = original_connect
assert seen == {"negotiate": False, "await_greeting": False}

87
tests/test_images.py Normal file
View File

@ -0,0 +1,87 @@
"""Image tools exercised against the real qemu-img (installed, fast, no VM)."""
import pytest
from fastmcp.exceptions import ToolError
from conftest import result_data
async def test_create_and_info(client, tmp_path):
img = tmp_path / "disk.qcow2"
result = await client.call_tool("image_create", {"path": str(img), "size": "1G"})
data = result_data(result)
assert data["format"] == "qcow2"
assert data["virtual_size_bytes"] == 1024**3
assert img.exists()
info = result_data(await client.call_tool("image_info", {"path": str(img)}))
assert info["format"] == "qcow2"
async def test_create_refuses_overwrite(client, tmp_path):
img = tmp_path / "disk.qcow2"
await client.call_tool("image_create", {"path": str(img), "size": "1G"})
with pytest.raises(ToolError, match="overwrite=True"):
await client.call_tool("image_create", {"path": str(img), "size": "2G"})
# ... and succeeds when explicitly allowed
data = result_data(
await client.call_tool("image_create", {"path": str(img), "size": "2G", "overwrite": True})
)
assert data["virtual_size_bytes"] == 2 * 1024**3
async def test_backing_file_overlay(client, tmp_path):
base = tmp_path / "base.qcow2"
overlay = tmp_path / "overlay.qcow2"
await client.call_tool("image_create", {"path": str(base), "size": "1G"})
data = result_data(
await client.call_tool(
"image_create",
{"path": str(overlay), "size": "1G", "backing_file": str(base)},
)
)
assert data["backing_file"] == str(base)
info = result_data(await client.call_tool("image_info", {"path": str(overlay)}))
assert info["backing-filename"] == str(base)
async def test_convert_to_raw(client, tmp_path):
src = tmp_path / "src.qcow2"
dst = tmp_path / "dst.raw"
await client.call_tool("image_create", {"path": str(src), "size": "64M"})
data = result_data(
await client.call_tool(
"image_convert", {"source": str(src), "dest": str(dst), "format": "raw"}
)
)
assert data["format"] == "raw"
async def test_resize_grow_and_shrink_guard(client, tmp_path):
img = tmp_path / "disk.qcow2"
await client.call_tool("image_create", {"path": str(img), "size": "1G"})
data = result_data(await client.call_tool("image_resize", {"path": str(img), "size": "2G"}))
assert data["virtual_size_bytes"] == 2 * 1024**3
with pytest.raises(ToolError):
await client.call_tool("image_resize", {"path": str(img), "size": "1G"})
data = result_data(
await client.call_tool("image_resize", {"path": str(img), "size": "1G", "shrink": True})
)
assert data["virtual_size_bytes"] == 1024**3
async def test_snapshot_lifecycle(client, tmp_path):
img = tmp_path / "disk.qcow2"
await client.call_tool("image_create", {"path": str(img), "size": "1G"})
await client.call_tool("image_snapshot_create", {"path": str(img), "tag": "clean"})
snaps = result_data(await client.call_tool("image_snapshot_list", {"path": str(img)}))
assert [s["tag"] for s in snaps] == ["clean"]
await client.call_tool("image_snapshot_apply", {"path": str(img), "tag": "clean"})
await client.call_tool("image_snapshot_delete", {"path": str(img), "tag": "clean"})
snaps = result_data(await client.call_tool("image_snapshot_list", {"path": str(img)}))
assert snaps == []
async def test_info_missing_file(client, tmp_path):
with pytest.raises(ToolError, match="not found"):
await client.call_tool("image_info", {"path": str(tmp_path / "nope.qcow2")})

57
tests/test_integration.py Normal file
View File

@ -0,0 +1,57 @@
"""Integration: boots a real (diskless) QEMU VM through the MCP tools.
Run with: uv run pytest -m integration
"""
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)
vms = result_data(await client.call_tool("list_vms", {}))
assert [(v["name"], v["status"]) for v in 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"
vms = result_data(await client.call_tool("list_vms", {}))
assert vms[0]["status"] == "paused"
await client.call_tool("resume_vm", {"name": "itest"})
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)

128
tests/test_launcher.py Normal file
View File

@ -0,0 +1,128 @@
"""build_cmdline is pure — table-test it hard."""
from pathlib import Path
import pytest
from fastmcp.exceptions import ToolError
from mcqemu.launcher import VMPaths, build_cmdline
from mcqemu.models import VMConfig
def paths() -> VMPaths:
return VMPaths(
qmp_socket=Path("/run/t/qmp.sock"),
qga_socket=Path("/run/t/qga.sock"),
pidfile=Path("/run/t/pid"),
qemu_log=Path("/state/t/qemu.log"),
serial_log=Path("/state/t/serial.log"),
uefi_vars=Path("/state/t/uefi-vars.fd"),
)
def argstr(args: list[str]) -> str:
return " ".join(args)
def test_defaults_kvm():
cfg = VMConfig(name="t")
args = build_cmdline(cfg, paths(), accel="kvm", disk_formats={})
s = argstr(args)
assert args[0] == "qemu-system-x86_64"
assert "-machine q35,accel=kvm" in s
assert "-cpu host" in s
assert "-m 2048" in s and "-smp 2" in s
assert "-display none" in s
assert "-daemonize" in s
assert "-qmp unix:/run/t/qmp.sock,server=on,wait=off" in s
assert "org.qemu.guest_agent.0" in s
assert "-netdev user,id=net0 -device virtio-net-pci,netdev=net0" in s
def test_tcg_uses_cpu_max():
cfg = VMConfig(name="t", arch="riscv64")
args = build_cmdline(cfg, paths(), accel="tcg", disk_formats={})
s = argstr(args)
assert args[0] == "qemu-system-riscv64"
assert "-machine virt,accel=tcg" in s
assert "-cpu max" in s
def test_unknown_arch_machine_falls_back_to_accel_flag():
cfg = VMConfig(name="t", arch="m68k")
s = argstr(build_cmdline(cfg, paths(), accel="tcg", disk_formats={}))
assert "-accel tcg" in s
assert "-machine" not in s
def test_disks_get_probed_format():
cfg = VMConfig(name="t", disks=["/vms/a.qcow2", "/vms/b.raw"])
s = argstr(
build_cmdline(
cfg,
paths(),
accel="kvm",
disk_formats={"/vms/a.qcow2": "qcow2", "/vms/b.raw": "raw"},
)
)
assert "-drive file=/vms/a.qcow2,if=virtio,format=qcow2" in s
assert "-drive file=/vms/b.raw,if=virtio,format=raw" in s
def test_iso_with_disk_boots_once_from_cdrom():
cfg = VMConfig(name="t", disks=["/vms/a.qcow2"], iso="/isos/x.iso")
s = argstr(build_cmdline(cfg, paths(), accel="kvm", disk_formats={"/vms/a.qcow2": "qcow2"}))
assert "media=cdrom,readonly=on" in s
assert "-boot once=d" in s
def test_iso_alone_no_boot_flag():
cfg = VMConfig(name="t", iso="/isos/x.iso")
s = argstr(build_cmdline(cfg, paths(), accel="kvm", disk_formats={}))
assert "-boot" not in s
def test_port_forwards():
cfg = VMConfig(name="t", port_forwards=["2222:22", "8080:80"])
s = argstr(build_cmdline(cfg, paths(), accel="kvm", disk_formats={}))
assert "hostfwd=tcp::2222-:22" in s
assert "hostfwd=tcp::8080-:80" in s
def test_bad_port_forward_raises():
cfg = VMConfig(name="t", port_forwards=["22->2222"])
with pytest.raises(ToolError, match="port forward"):
build_cmdline(cfg, paths(), accel="kvm", disk_formats={})
def test_no_net():
cfg = VMConfig(name="t", no_net=True)
s = argstr(build_cmdline(cfg, paths(), accel="kvm", disk_formats={}))
assert "-nic none" in s
assert "-netdev" not in s
def test_uefi_pflash_x86():
cfg = VMConfig(name="t", firmware="uefi")
s = argstr(build_cmdline(cfg, paths(), accel="kvm", disk_formats={}))
assert "if=pflash,format=raw,readonly=on,file=/usr/share/edk2/x64/OVMF_CODE.4m.fd" in s
assert "if=pflash,format=raw,file=/state/t/uefi-vars.fd" in s
def test_uefi_bios_mode_aarch64():
cfg = VMConfig(name="t", arch="aarch64", firmware="uefi")
s = argstr(build_cmdline(cfg, paths(), accel="tcg", disk_formats={}))
assert "-bios /usr/share/edk2/aarch64/QEMU_EFI.fd" in s
assert "pflash" not in s
def test_uefi_unsupported_arch_raises():
cfg = VMConfig(name="t", arch="m68k", firmware="uefi")
with pytest.raises(ToolError, match="UEFI"):
build_cmdline(cfg, paths(), accel="tcg", disk_formats={})
def test_extra_args_appended_last():
cfg = VMConfig(name="t", extra_args=["-vga", "std"])
args = build_cmdline(cfg, paths(), accel="kvm", disk_formats={})
assert args[-2:] == ["-vga", "std"]

152
tests/test_lifecycle.py Normal file
View File

@ -0,0 +1,152 @@
"""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"
vms = result_data(await client.call_tool("list_vms", {}))
assert [v["name"] for v in 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_dead, monkeypatch):
monkeypatch.setattr("mcqemu.tools.lifecycle.Path.exists", lambda self: True)
FakeQMPClient.events_to_emit = [] # guest ignores ACPI
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_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
vms = result_data(await client.call_tool("list_vms", {}))
assert 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

58
tests/test_query.py Normal file
View File

@ -0,0 +1,58 @@
"""list_vms / vm_info status reporting."""
from fastmcp import Client
from conftest import FakeQMPClient, result_data, write_registry
from mcqemu.server import mcp
from test_lifecycle import seeded_record
async def test_list_vms_stopped(dirs, all_pids_dead):
write_registry(dirs, seeded_record(dirs, "vm1"))
async with Client(mcp) as client:
vms = result_data(await client.call_tool("list_vms", {}))
assert vms[0]["status"] == "stopped"
async def test_list_vms_running(dirs, fake_qmp, all_pids_alive):
FakeQMPClient.responses["query-status"] = {"status": "running"}
write_registry(dirs, seeded_record(dirs, "vm1"))
async with Client(mcp) as client:
vms = result_data(await client.call_tool("list_vms", {}))
assert vms[0]["status"] == "running"
async def test_list_vms_unreachable_when_qmp_fails(dirs, fake_qmp, all_pids_alive):
FakeQMPClient.connect_error = OSError("connection refused")
write_registry(dirs, seeded_record(dirs, "vm1"))
async with Client(mcp) as client:
vms = result_data(await client.call_tool("list_vms", {}))
assert vms[0]["status"] == "unreachable"
async def test_vm_info_running_includes_block_devices(dirs, fake_qmp, all_pids_alive):
FakeQMPClient.responses["query-status"] = {"status": "running"}
FakeQMPClient.responses["query-cpus-fast"] = [{"cpu-index": 0}, {"cpu-index": 1}]
FakeQMPClient.responses["query-block"] = [
{
"device": "virtio0",
"inserted": {"file": "/vms/a.qcow2", "drv": "qcow2", "ro": False},
},
{"device": "empty-cd"},
]
write_registry(dirs, seeded_record(dirs, "vm1"))
async with Client(mcp) as client:
info = result_data(await client.call_tool("vm_info", {"name": "vm1"}))
assert info["status"] == "running"
assert info["vcpus"] == 2
assert info["block_devices"] == [
{"device": "virtio0", "file": "/vms/a.qcow2", "format": "qcow2", "read_only": False}
]
async def test_vm_info_stopped_skips_qmp(dirs, all_pids_dead):
write_registry(dirs, seeded_record(dirs, "vm1"))
async with Client(mcp) as client:
info = result_data(await client.call_tool("vm_info", {"name": "vm1"}))
assert info["status"] == "stopped"
assert "vcpus" not in info

69
tests/test_registry.py Normal file
View File

@ -0,0 +1,69 @@
"""Registry persistence and liveness logic."""
from mcqemu.config import Config
from mcqemu.models import VMRecord
from mcqemu.registry import VMRegistry, pid_alive
def make_config(dirs) -> Config:
return Config(state_dir=dirs.state, runtime_dir=dirs.run)
def record(name="vm1", **kw) -> VMRecord:
defaults = dict(source="spawned", qmp_socket=f"/run/{name}/qmp.sock", pid=12345)
defaults.update(kw)
return VMRecord(name=name, **defaults)
def test_round_trip(dirs):
reg = VMRegistry(make_config(dirs))
reg.load()
reg.add(record("alpha", arch="x86_64", config={"disks": ["/vms/a.qcow2"]}))
reg.add(record("beta", source="attached", pid=None))
reg2 = VMRegistry(make_config(dirs))
reg2.load()
assert sorted(reg2.names()) == ["alpha", "beta"]
alpha = reg2.get("alpha")
assert alpha.arch == "x86_64"
assert alpha.config["disks"] == ["/vms/a.qcow2"]
assert reg2.get("beta").source == "attached"
def test_load_missing_file_is_empty(dirs):
reg = VMRegistry(make_config(dirs))
reg.load()
assert reg.all() == []
def test_from_dict_ignores_unknown_fields():
rec = VMRecord.from_dict(
{"name": "x", "source": "spawned", "qmp_socket": "/s", "future_field": 1}
)
assert rec.name == "x"
def test_pid_alive_rejects_non_qemu_process():
import os
# Our own PID exists but comm is python/pytest, not qemu-system — must be False.
assert pid_alive(os.getpid()) is False
def test_pid_alive_rejects_dead_and_bogus_pids():
assert pid_alive(2**22 + 12345) is False
assert pid_alive(None) is False
assert pid_alive(0) is False
def test_disks_in_use_only_counts_live_vms(dirs, monkeypatch):
reg = VMRegistry(make_config(dirs))
reg.load()
reg.add(record("live", pid=111, config={"disks": ["/vms/live.qcow2"]}))
reg.add(record("dead", pid=222, config={"disks": ["/vms/dead.qcow2"]}))
monkeypatch.setattr("mcqemu.registry.pid_alive", lambda pid: pid == 111)
# refresh_pid would overwrite from pidfile; none set, so pids stay put.
used = reg.disks_in_use()
assert "/vms/live.qcow2" in used
assert used["/vms/live.qcow2"] == "live"
assert "/vms/dead.qcow2" not in used

64
tests/test_snapshots.py Normal file
View File

@ -0,0 +1,64 @@
"""Live snapshot tools (HMP passthrough)."""
import pytest
from fastmcp import Client
from fastmcp.exceptions import ToolError
from conftest import FakeQMPClient, result_data, write_registry
from mcqemu.server import mcp
from test_lifecycle import seeded_record
async def test_snapshot_create_success_on_silence(dirs, fake_qmp):
FakeQMPClient.responses["human-monitor-command"] = ""
write_registry(dirs, seeded_record(dirs, "vm1"))
async with Client(mcp) as client:
data = result_data(
await client.call_tool("vm_snapshot_create", {"name": "vm1", "tag": "clean"})
)
assert data["created"] is True
assert ("human-monitor-command", {"command-line": "savevm clean"}) in FakeQMPClient.calls
async def test_snapshot_create_surfaces_hmp_error(dirs, fake_qmp):
FakeQMPClient.responses["human-monitor-command"] = (
"Error: Device 'virtio0' is writable but does not support snapshots"
)
write_registry(dirs, seeded_record(dirs, "vm1"))
async with Client(mcp) as client:
with pytest.raises(ToolError, match="does not support snapshots"):
await client.call_tool("vm_snapshot_create", {"name": "vm1", "tag": "clean"})
async def test_snapshot_restore_and_delete(dirs, fake_qmp):
FakeQMPClient.responses["human-monitor-command"] = ""
write_registry(dirs, seeded_record(dirs, "vm1"))
async with Client(mcp) as client:
assert result_data(
await client.call_tool("vm_snapshot_restore", {"name": "vm1", "tag": "clean"})
)["restored"]
assert result_data(
await client.call_tool("vm_snapshot_delete", {"name": "vm1", "tag": "clean"})
)["deleted"]
cmdlines = [a["command-line"] for _, a in FakeQMPClient.calls]
assert "loadvm clean" in cmdlines
assert "delvm clean" in cmdlines
async def test_snapshot_bad_tag_rejected(dirs, fake_qmp):
write_registry(dirs, seeded_record(dirs, "vm1"))
async with Client(mcp) as client:
for tag in ["has space", "-leading-dash", ""]:
with pytest.raises(ToolError):
await client.call_tool("vm_snapshot_create", {"name": "vm1", "tag": tag})
async def test_snapshot_list_raw(dirs, fake_qmp):
FakeQMPClient.responses["human-monitor-command"] = (
"List of snapshots present on all disks:\n"
"ID TAG VM SIZE DATE\n-- clean 250M 2026-08-16"
)
write_registry(dirs, seeded_record(dirs, "vm1"))
async with Client(mcp) as client:
data = result_data(await client.call_tool("vm_snapshot_list", {"name": "vm1"}))
assert "clean" in data["snapshots_raw"]