"""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