QEMU command line: - escape commas in every interpolated path (qopt); a path like 'data,readonly=on.qcow2' previously injected a drive option - reject extra_args flags that breach VM isolation (host filesystem passthrough, host block devices, spawning chardevs, -runas) and document the parameter as operator-only - detect duplicate host ports across port_forwards instead of failing at QEMU launch; auto ports no longer collide with each other Sandbox isolation: - sandbox_vm now blocks guest-initiated traffic by default (restrict=on), with allow_network=True to opt in. Verified end to end: with identical guest network state, a default sandbox reaches neither a host loopback service nor the internet, while allow_network=True reaches both - note in the docstring that the guest agent answers before the guest has finished booting Bounded work per call: - vm_serial_read seeks a 256KB window from the end instead of reading a console log that grows without bound into memory - cap vm_type_text length and vm_mouse_move deltas - screenshots get a unique filename and are cleaned up, so a concurrent capture cannot swap the frame under vm_click Identity and liveness: - attach_vm requires an actual unix socket and stores the resolved path - attached VMs are judged by connecting, not by a stat that a stale socket file would pass - refuse to act on a PID whose cmdline proves it is a different VM - a sandbox's base image counts as in use while its overlay is live - fix a latent NameError in vm_mouse_move's homing branch
178 lines
7.3 KiB
Python
178 lines
7.3 KiB
Python
"""Lifecycle tools with mocked spawn / fake QMP."""
|
|
|
|
import socket
|
|
|
|
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_rejects_a_regular_file(client, tmp_path):
|
|
"""A path that exists but is not a socket cannot be a QMP endpoint."""
|
|
plain = tmp_path / "not-a-socket"
|
|
plain.touch()
|
|
with pytest.raises(ToolError, match="not a unix socket"):
|
|
await client.call_tool("attach_vm", {"name": "ext", "qmp_socket": str(plain)})
|
|
|
|
|
|
async def test_attach_and_forget(dirs, fake_qmp, tmp_path):
|
|
sock = tmp_path / "ext.sock"
|
|
listener = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
|
|
listener.bind(str(sock))
|
|
listener.listen(1)
|
|
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"] == []
|
|
listener.close()
|
|
|
|
|
|
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
|