Make persistent workspaces capable of running long-lived background processes instead of forcing everything through one-shot exec calls. Add workspace service start/list/status/logs/stop across the CLI, Python SDK, and MCP server, with multiple named services per workspace, typed readiness probes (file, tcp, http, and command), and aggregate service counts on workspace status. Keep service state and logs outside /workspace so diff and export semantics stay workspace-scoped, and extend the guest agent plus backends to persist service records and logs across separate calls. Update the 2.7.0 docs, examples, changelog, and roadmap milestone to reflect the shipped surface. Validation: uv lock; UV_CACHE_DIR=.uv-cache make check; UV_CACHE_DIR=.uv-cache make dist-check; real guest-backed Firecracker smoke for workspace create, two service starts, list/status/logs, diff unaffected, stop, and delete.
176 lines
5.8 KiB
Python
176 lines
5.8 KiB
Python
from __future__ import annotations
|
|
|
|
import asyncio
|
|
from pathlib import Path
|
|
from typing import Any, cast
|
|
|
|
from pyro_mcp.api import Pyro
|
|
from pyro_mcp.vm_manager import VmManager
|
|
from pyro_mcp.vm_network import TapNetworkManager
|
|
|
|
|
|
def test_pyro_run_in_vm_delegates_to_manager(tmp_path: Path) -> None:
|
|
pyro = Pyro(
|
|
manager=VmManager(
|
|
backend_name="mock",
|
|
base_dir=tmp_path / "vms",
|
|
network_manager=TapNetworkManager(enabled=False),
|
|
)
|
|
)
|
|
result = pyro.run_in_vm(
|
|
environment="debian:12-base",
|
|
command="printf 'ok\\n'",
|
|
vcpu_count=1,
|
|
mem_mib=512,
|
|
timeout_seconds=30,
|
|
ttl_seconds=600,
|
|
network=False,
|
|
allow_host_compat=True,
|
|
)
|
|
assert int(result["exit_code"]) == 0
|
|
assert str(result["stdout"]) == "ok\n"
|
|
|
|
|
|
def test_pyro_create_server_registers_vm_run(tmp_path: Path) -> None:
|
|
pyro = Pyro(
|
|
manager=VmManager(
|
|
backend_name="mock",
|
|
base_dir=tmp_path / "vms",
|
|
network_manager=TapNetworkManager(enabled=False),
|
|
)
|
|
)
|
|
|
|
async def _run() -> list[str]:
|
|
server = pyro.create_server()
|
|
tools = await server.list_tools()
|
|
return sorted(tool.name for tool in tools)
|
|
|
|
tool_names = asyncio.run(_run())
|
|
assert "vm_run" in tool_names
|
|
assert "vm_create" in tool_names
|
|
assert "workspace_create" in tool_names
|
|
assert "workspace_diff" in tool_names
|
|
assert "workspace_sync_push" in tool_names
|
|
assert "workspace_export" in tool_names
|
|
assert "shell_open" in tool_names
|
|
assert "shell_read" in tool_names
|
|
assert "shell_write" in tool_names
|
|
assert "shell_signal" in tool_names
|
|
assert "shell_close" in tool_names
|
|
assert "service_start" in tool_names
|
|
assert "service_list" in tool_names
|
|
assert "service_status" in tool_names
|
|
assert "service_logs" in tool_names
|
|
assert "service_stop" in tool_names
|
|
|
|
|
|
def test_pyro_vm_run_tool_executes(tmp_path: Path) -> None:
|
|
pyro = Pyro(
|
|
manager=VmManager(
|
|
backend_name="mock",
|
|
base_dir=tmp_path / "vms",
|
|
network_manager=TapNetworkManager(enabled=False),
|
|
)
|
|
)
|
|
|
|
def _extract_structured(raw_result: object) -> dict[str, Any]:
|
|
if not isinstance(raw_result, tuple) or len(raw_result) != 2:
|
|
raise TypeError("unexpected call_tool result shape")
|
|
_, structured = raw_result
|
|
if not isinstance(structured, dict):
|
|
raise TypeError("expected structured dictionary result")
|
|
return cast(dict[str, Any], structured)
|
|
|
|
async def _run() -> dict[str, Any]:
|
|
server = pyro.create_server()
|
|
return _extract_structured(
|
|
await server.call_tool(
|
|
"vm_run",
|
|
{
|
|
"environment": "debian:12-base",
|
|
"command": "printf 'ok\\n'",
|
|
"network": False,
|
|
"allow_host_compat": True,
|
|
},
|
|
)
|
|
)
|
|
|
|
result = asyncio.run(_run())
|
|
assert int(result["exit_code"]) == 0
|
|
|
|
|
|
def test_pyro_create_vm_defaults_sizing_and_host_compat(tmp_path: Path) -> None:
|
|
pyro = Pyro(
|
|
manager=VmManager(
|
|
backend_name="mock",
|
|
base_dir=tmp_path / "vms",
|
|
network_manager=TapNetworkManager(enabled=False),
|
|
)
|
|
)
|
|
|
|
created = pyro.create_vm(
|
|
environment="debian:12-base",
|
|
allow_host_compat=True,
|
|
)
|
|
|
|
assert created["vcpu_count"] == 1
|
|
assert created["mem_mib"] == 1024
|
|
assert created["allow_host_compat"] is True
|
|
|
|
|
|
def test_pyro_workspace_methods_delegate_to_manager(tmp_path: Path) -> None:
|
|
pyro = Pyro(
|
|
manager=VmManager(
|
|
backend_name="mock",
|
|
base_dir=tmp_path / "vms",
|
|
network_manager=TapNetworkManager(enabled=False),
|
|
)
|
|
)
|
|
|
|
source_dir = tmp_path / "seed"
|
|
source_dir.mkdir()
|
|
(source_dir / "note.txt").write_text("ok\n", encoding="utf-8")
|
|
|
|
created = pyro.create_workspace(
|
|
environment="debian:12-base",
|
|
allow_host_compat=True,
|
|
seed_path=source_dir,
|
|
)
|
|
workspace_id = str(created["workspace_id"])
|
|
updated_dir = tmp_path / "updated"
|
|
updated_dir.mkdir()
|
|
(updated_dir / "more.txt").write_text("more\n", encoding="utf-8")
|
|
synced = pyro.push_workspace_sync(workspace_id, updated_dir, dest="subdir")
|
|
executed = pyro.exec_workspace(workspace_id, command="cat note.txt")
|
|
diff_payload = pyro.diff_workspace(workspace_id)
|
|
export_path = tmp_path / "exported-note.txt"
|
|
exported = pyro.export_workspace(workspace_id, "note.txt", output_path=export_path)
|
|
service = pyro.start_service(
|
|
workspace_id,
|
|
"app",
|
|
command="sh -lc 'touch .ready; while true; do sleep 60; done'",
|
|
readiness={"type": "file", "path": ".ready"},
|
|
)
|
|
services = pyro.list_services(workspace_id)
|
|
service_status = pyro.status_service(workspace_id, "app")
|
|
service_logs = pyro.logs_service(workspace_id, "app", all=True)
|
|
service_stopped = pyro.stop_service(workspace_id, "app")
|
|
status = pyro.status_workspace(workspace_id)
|
|
logs = pyro.logs_workspace(workspace_id)
|
|
deleted = pyro.delete_workspace(workspace_id)
|
|
|
|
assert executed["stdout"] == "ok\n"
|
|
assert created["workspace_seed"]["mode"] == "directory"
|
|
assert synced["workspace_sync"]["destination"] == "/workspace/subdir"
|
|
assert diff_payload["changed"] is True
|
|
assert exported["output_path"] == str(export_path)
|
|
assert export_path.read_text(encoding="utf-8") == "ok\n"
|
|
assert service["state"] == "running"
|
|
assert services["count"] == 1
|
|
assert service_status["state"] == "running"
|
|
assert service_logs["tail_lines"] is None
|
|
assert service_stopped["state"] == "stopped"
|
|
assert status["command_count"] == 1
|
|
assert status["service_count"] == 1
|
|
assert logs["count"] == 1
|
|
assert deleted["deleted"] is True
|