Add workspace service lifecycle with typed readiness
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.
This commit is contained in:
parent
84a7e18d4d
commit
f504f0a331
28 changed files with 4098 additions and 124 deletions
|
|
@ -1,7 +1,6 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import time
|
||||
from pathlib import Path
|
||||
from typing import Any, cast
|
||||
|
||||
|
|
@ -58,6 +57,11 @@ def test_pyro_create_server_registers_vm_run(tmp_path: Path) -> None:
|
|||
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:
|
||||
|
|
@ -141,16 +145,16 @@ def test_pyro_workspace_methods_delegate_to_manager(tmp_path: Path) -> None:
|
|||
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)
|
||||
opened = pyro.open_shell(workspace_id)
|
||||
shell_id = str(opened["shell_id"])
|
||||
written = pyro.write_shell(workspace_id, shell_id, input="pwd")
|
||||
read = pyro.read_shell(workspace_id, shell_id)
|
||||
deadline = time.time() + 5
|
||||
while "/workspace" not in str(read["output"]) and time.time() < deadline:
|
||||
read = pyro.read_shell(workspace_id, shell_id, cursor=0)
|
||||
time.sleep(0.05)
|
||||
signaled = pyro.signal_shell(workspace_id, shell_id)
|
||||
closed = pyro.close_shell(workspace_id, shell_id)
|
||||
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)
|
||||
|
|
@ -158,13 +162,15 @@ def test_pyro_workspace_methods_delegate_to_manager(tmp_path: Path) -> None:
|
|||
assert executed["stdout"] == "ok\n"
|
||||
assert created["workspace_seed"]["mode"] == "directory"
|
||||
assert synced["workspace_sync"]["destination"] == "/workspace/subdir"
|
||||
assert written["input_length"] == 3
|
||||
assert diff_payload["changed"] is True
|
||||
assert exported["output_path"] == str(export_path)
|
||||
assert export_path.read_text(encoding="utf-8") == "ok\n"
|
||||
assert "/workspace" in read["output"]
|
||||
assert signaled["signal"] == "INT"
|
||||
assert closed["closed"] is True
|
||||
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
|
||||
|
|
|
|||
1054
tests/test_cli.py
1054
tests/test_cli.py
File diff suppressed because it is too large
Load diff
|
|
@ -20,6 +20,12 @@ from pyro_mcp.contract import (
|
|||
PUBLIC_CLI_WORKSPACE_CREATE_FLAGS,
|
||||
PUBLIC_CLI_WORKSPACE_DIFF_FLAGS,
|
||||
PUBLIC_CLI_WORKSPACE_EXPORT_FLAGS,
|
||||
PUBLIC_CLI_WORKSPACE_SERVICE_LIST_FLAGS,
|
||||
PUBLIC_CLI_WORKSPACE_SERVICE_LOGS_FLAGS,
|
||||
PUBLIC_CLI_WORKSPACE_SERVICE_START_FLAGS,
|
||||
PUBLIC_CLI_WORKSPACE_SERVICE_STATUS_FLAGS,
|
||||
PUBLIC_CLI_WORKSPACE_SERVICE_STOP_FLAGS,
|
||||
PUBLIC_CLI_WORKSPACE_SERVICE_SUBCOMMANDS,
|
||||
PUBLIC_CLI_WORKSPACE_SHELL_CLOSE_FLAGS,
|
||||
PUBLIC_CLI_WORKSPACE_SHELL_OPEN_FLAGS,
|
||||
PUBLIC_CLI_WORKSPACE_SHELL_READ_FLAGS,
|
||||
|
|
@ -135,6 +141,37 @@ def test_public_cli_help_lists_commands_and_run_flags() -> None:
|
|||
).format_help()
|
||||
for flag in PUBLIC_CLI_WORKSPACE_SHELL_CLOSE_FLAGS:
|
||||
assert flag in workspace_shell_close_help_text
|
||||
workspace_service_help_text = _subparser_choice(
|
||||
_subparser_choice(parser, "workspace"),
|
||||
"service",
|
||||
).format_help()
|
||||
for subcommand_name in PUBLIC_CLI_WORKSPACE_SERVICE_SUBCOMMANDS:
|
||||
assert subcommand_name in workspace_service_help_text
|
||||
workspace_service_start_help_text = _subparser_choice(
|
||||
_subparser_choice(_subparser_choice(parser, "workspace"), "service"), "start"
|
||||
).format_help()
|
||||
for flag in PUBLIC_CLI_WORKSPACE_SERVICE_START_FLAGS:
|
||||
assert flag in workspace_service_start_help_text
|
||||
workspace_service_list_help_text = _subparser_choice(
|
||||
_subparser_choice(_subparser_choice(parser, "workspace"), "service"), "list"
|
||||
).format_help()
|
||||
for flag in PUBLIC_CLI_WORKSPACE_SERVICE_LIST_FLAGS:
|
||||
assert flag in workspace_service_list_help_text
|
||||
workspace_service_status_help_text = _subparser_choice(
|
||||
_subparser_choice(_subparser_choice(parser, "workspace"), "service"), "status"
|
||||
).format_help()
|
||||
for flag in PUBLIC_CLI_WORKSPACE_SERVICE_STATUS_FLAGS:
|
||||
assert flag in workspace_service_status_help_text
|
||||
workspace_service_logs_help_text = _subparser_choice(
|
||||
_subparser_choice(_subparser_choice(parser, "workspace"), "service"), "logs"
|
||||
).format_help()
|
||||
for flag in PUBLIC_CLI_WORKSPACE_SERVICE_LOGS_FLAGS:
|
||||
assert flag in workspace_service_logs_help_text
|
||||
workspace_service_stop_help_text = _subparser_choice(
|
||||
_subparser_choice(_subparser_choice(parser, "workspace"), "service"), "stop"
|
||||
).format_help()
|
||||
for flag in PUBLIC_CLI_WORKSPACE_SERVICE_STOP_FLAGS:
|
||||
assert flag in workspace_service_stop_help_text
|
||||
|
||||
demo_help_text = _subparser_choice(parser, "demo").format_help()
|
||||
for subcommand_name in PUBLIC_CLI_DEMO_SUBCOMMANDS:
|
||||
|
|
|
|||
|
|
@ -1,7 +1,6 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import time
|
||||
from pathlib import Path
|
||||
from typing import Any, cast
|
||||
|
||||
|
|
@ -42,6 +41,11 @@ def test_create_server_registers_vm_tools(tmp_path: Path) -> None:
|
|||
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_vm_run_round_trip(tmp_path: Path) -> None:
|
||||
|
|
@ -192,20 +196,7 @@ def test_workspace_tools_round_trip(tmp_path: Path) -> None:
|
|||
raise TypeError("expected structured dictionary result")
|
||||
return cast(dict[str, Any], structured)
|
||||
|
||||
async def _run() -> tuple[
|
||||
dict[str, Any],
|
||||
dict[str, Any],
|
||||
dict[str, Any],
|
||||
dict[str, Any],
|
||||
dict[str, Any],
|
||||
dict[str, Any],
|
||||
dict[str, Any],
|
||||
dict[str, Any],
|
||||
dict[str, Any],
|
||||
dict[str, Any],
|
||||
dict[str, Any],
|
||||
dict[str, Any],
|
||||
]:
|
||||
async def _run() -> tuple[dict[str, Any], ...]:
|
||||
server = create_server(manager=manager)
|
||||
created = _extract_structured(
|
||||
await server.call_tool(
|
||||
|
|
@ -254,57 +245,45 @@ def test_workspace_tools_round_trip(tmp_path: Path) -> None:
|
|||
},
|
||||
)
|
||||
)
|
||||
opened = _extract_structured(
|
||||
await server.call_tool("shell_open", {"workspace_id": workspace_id})
|
||||
)
|
||||
shell_id = str(opened["shell_id"])
|
||||
written = _extract_structured(
|
||||
service = _extract_structured(
|
||||
await server.call_tool(
|
||||
"shell_write",
|
||||
"service_start",
|
||||
{
|
||||
"workspace_id": workspace_id,
|
||||
"shell_id": shell_id,
|
||||
"input": "pwd",
|
||||
"service_name": "app",
|
||||
"command": "sh -lc 'touch .ready; while true; do sleep 60; done'",
|
||||
"ready_file": ".ready",
|
||||
},
|
||||
)
|
||||
)
|
||||
read = _extract_structured(
|
||||
services = _extract_structured(
|
||||
await server.call_tool("service_list", {"workspace_id": workspace_id})
|
||||
)
|
||||
service_status = _extract_structured(
|
||||
await server.call_tool(
|
||||
"shell_read",
|
||||
"service_status",
|
||||
{
|
||||
"workspace_id": workspace_id,
|
||||
"shell_id": shell_id,
|
||||
"service_name": "app",
|
||||
},
|
||||
)
|
||||
)
|
||||
deadline = time.time() + 5
|
||||
while "/workspace" not in str(read["output"]) and time.time() < deadline:
|
||||
read = _extract_structured(
|
||||
await server.call_tool(
|
||||
"shell_read",
|
||||
{
|
||||
"workspace_id": workspace_id,
|
||||
"shell_id": shell_id,
|
||||
"cursor": 0,
|
||||
},
|
||||
)
|
||||
)
|
||||
await asyncio.sleep(0.05)
|
||||
signaled = _extract_structured(
|
||||
service_logs = _extract_structured(
|
||||
await server.call_tool(
|
||||
"shell_signal",
|
||||
"service_logs",
|
||||
{
|
||||
"workspace_id": workspace_id,
|
||||
"shell_id": shell_id,
|
||||
"service_name": "app",
|
||||
"all": True,
|
||||
},
|
||||
)
|
||||
)
|
||||
closed = _extract_structured(
|
||||
service_stopped = _extract_structured(
|
||||
await server.call_tool(
|
||||
"shell_close",
|
||||
"service_stop",
|
||||
{
|
||||
"workspace_id": workspace_id,
|
||||
"shell_id": shell_id,
|
||||
"service_name": "app",
|
||||
},
|
||||
)
|
||||
)
|
||||
|
|
@ -320,11 +299,11 @@ def test_workspace_tools_round_trip(tmp_path: Path) -> None:
|
|||
executed,
|
||||
diffed,
|
||||
exported,
|
||||
opened,
|
||||
written,
|
||||
read,
|
||||
signaled,
|
||||
closed,
|
||||
service,
|
||||
services,
|
||||
service_status,
|
||||
service_logs,
|
||||
service_stopped,
|
||||
logs,
|
||||
deleted,
|
||||
)
|
||||
|
|
@ -335,11 +314,11 @@ def test_workspace_tools_round_trip(tmp_path: Path) -> None:
|
|||
executed,
|
||||
diffed,
|
||||
exported,
|
||||
opened,
|
||||
written,
|
||||
read,
|
||||
signaled,
|
||||
closed,
|
||||
service,
|
||||
services,
|
||||
service_status,
|
||||
service_logs,
|
||||
service_stopped,
|
||||
logs,
|
||||
deleted,
|
||||
) = asyncio.run(_run())
|
||||
|
|
@ -350,10 +329,10 @@ def test_workspace_tools_round_trip(tmp_path: Path) -> None:
|
|||
assert diffed["changed"] is True
|
||||
assert exported["artifact_type"] == "file"
|
||||
assert Path(str(exported["output_path"])).read_text(encoding="utf-8") == "more\n"
|
||||
assert opened["state"] == "running"
|
||||
assert written["input_length"] == 3
|
||||
assert "/workspace" in read["output"]
|
||||
assert signaled["signal"] == "INT"
|
||||
assert closed["closed"] is True
|
||||
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 logs["count"] == 1
|
||||
assert deleted["deleted"] is True
|
||||
|
|
|
|||
|
|
@ -262,6 +262,105 @@ def test_vsock_exec_client_shell_round_trip(monkeypatch: pytest.MonkeyPatch) ->
|
|||
assert open_request["shell_id"] == "shell-1"
|
||||
|
||||
|
||||
def test_vsock_exec_client_service_round_trip(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
monkeypatch.setattr(socket, "AF_VSOCK", 40, raising=False)
|
||||
responses = [
|
||||
json.dumps(
|
||||
{
|
||||
"service_name": "app",
|
||||
"command": "echo ok",
|
||||
"cwd": "/workspace",
|
||||
"state": "running",
|
||||
"started_at": 1.0,
|
||||
"ready_at": 2.0,
|
||||
"ended_at": None,
|
||||
"exit_code": None,
|
||||
"pid": 42,
|
||||
"readiness": {"type": "file", "path": "/workspace/.ready"},
|
||||
"stop_reason": None,
|
||||
}
|
||||
).encode("utf-8"),
|
||||
json.dumps(
|
||||
{
|
||||
"service_name": "app",
|
||||
"command": "echo ok",
|
||||
"cwd": "/workspace",
|
||||
"state": "running",
|
||||
"started_at": 1.0,
|
||||
"ready_at": 2.0,
|
||||
"ended_at": None,
|
||||
"exit_code": None,
|
||||
"pid": 42,
|
||||
"readiness": {"type": "file", "path": "/workspace/.ready"},
|
||||
"stop_reason": None,
|
||||
}
|
||||
).encode("utf-8"),
|
||||
json.dumps(
|
||||
{
|
||||
"service_name": "app",
|
||||
"command": "echo ok",
|
||||
"cwd": "/workspace",
|
||||
"state": "running",
|
||||
"started_at": 1.0,
|
||||
"ready_at": 2.0,
|
||||
"ended_at": None,
|
||||
"exit_code": None,
|
||||
"pid": 42,
|
||||
"readiness": {"type": "file", "path": "/workspace/.ready"},
|
||||
"stop_reason": None,
|
||||
"stdout": "ok\n",
|
||||
"stderr": "",
|
||||
"tail_lines": 200,
|
||||
"truncated": False,
|
||||
}
|
||||
).encode("utf-8"),
|
||||
json.dumps(
|
||||
{
|
||||
"service_name": "app",
|
||||
"command": "echo ok",
|
||||
"cwd": "/workspace",
|
||||
"state": "stopped",
|
||||
"started_at": 1.0,
|
||||
"ready_at": 2.0,
|
||||
"ended_at": 3.0,
|
||||
"exit_code": 0,
|
||||
"pid": 42,
|
||||
"readiness": {"type": "file", "path": "/workspace/.ready"},
|
||||
"stop_reason": "sigterm",
|
||||
}
|
||||
).encode("utf-8"),
|
||||
]
|
||||
stubs = [StubSocket(response) for response in responses]
|
||||
remaining = list(stubs)
|
||||
|
||||
def socket_factory(family: int, sock_type: int) -> StubSocket:
|
||||
assert family == socket.AF_VSOCK
|
||||
assert sock_type == socket.SOCK_STREAM
|
||||
return remaining.pop(0)
|
||||
|
||||
client = VsockExecClient(socket_factory=socket_factory)
|
||||
started = client.start_service(
|
||||
1234,
|
||||
5005,
|
||||
service_name="app",
|
||||
command="echo ok",
|
||||
cwd="/workspace",
|
||||
readiness={"type": "file", "path": "/workspace/.ready"},
|
||||
ready_timeout_seconds=30,
|
||||
ready_interval_ms=500,
|
||||
)
|
||||
assert started["service_name"] == "app"
|
||||
status = client.status_service(1234, 5005, service_name="app")
|
||||
assert status["state"] == "running"
|
||||
logs = client.logs_service(1234, 5005, service_name="app", tail_lines=200)
|
||||
assert logs["stdout"] == "ok\n"
|
||||
stopped = client.stop_service(1234, 5005, service_name="app")
|
||||
assert stopped["state"] == "stopped"
|
||||
start_request = json.loads(stubs[0].sent.decode("utf-8").strip())
|
||||
assert start_request["action"] == "start_service"
|
||||
assert start_request["service_name"] == "app"
|
||||
|
||||
|
||||
def test_vsock_exec_client_raises_agent_error(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
monkeypatch.setattr(socket, "AF_VSOCK", 40, raising=False)
|
||||
stub = StubSocket(b'{"error":"shell is unavailable"}')
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ from __future__ import annotations
|
|||
import io
|
||||
import json
|
||||
import os
|
||||
import signal
|
||||
import subprocess
|
||||
import tarfile
|
||||
import time
|
||||
|
|
@ -1144,3 +1145,369 @@ def test_reap_expired_workspaces_removes_invalid_and_expired_records(tmp_path: P
|
|||
|
||||
assert not invalid_dir.exists()
|
||||
assert not (tmp_path / "vms" / "workspaces" / workspace_id).exists()
|
||||
|
||||
|
||||
def test_workspace_service_lifecycle_and_status_counts(tmp_path: Path) -> None:
|
||||
manager = VmManager(
|
||||
backend_name="mock",
|
||||
base_dir=tmp_path / "vms",
|
||||
network_manager=TapNetworkManager(enabled=False),
|
||||
)
|
||||
workspace_id = str(
|
||||
manager.create_workspace(
|
||||
environment="debian:12-base",
|
||||
allow_host_compat=True,
|
||||
)["workspace_id"]
|
||||
)
|
||||
|
||||
started = manager.start_service(
|
||||
workspace_id,
|
||||
"app",
|
||||
command="sh -lc 'printf \"service ready\\n\"; touch .ready; while true; do sleep 60; done'",
|
||||
readiness={"type": "file", "path": ".ready"},
|
||||
)
|
||||
assert started["state"] == "running"
|
||||
|
||||
listed = manager.list_services(workspace_id)
|
||||
assert listed["count"] == 1
|
||||
assert listed["running_count"] == 1
|
||||
|
||||
status = manager.status_service(workspace_id, "app")
|
||||
assert status["state"] == "running"
|
||||
assert status["ready_at"] is not None
|
||||
|
||||
logs = manager.logs_service(workspace_id, "app")
|
||||
assert "service ready" in str(logs["stdout"])
|
||||
|
||||
workspace_status = manager.status_workspace(workspace_id)
|
||||
assert workspace_status["service_count"] == 1
|
||||
assert workspace_status["running_service_count"] == 1
|
||||
|
||||
stopped = manager.stop_service(workspace_id, "app")
|
||||
assert stopped["state"] == "stopped"
|
||||
assert stopped["stop_reason"] in {"sigterm", "sigkill"}
|
||||
|
||||
deleted = manager.delete_workspace(workspace_id)
|
||||
assert deleted["deleted"] is True
|
||||
|
||||
|
||||
def test_workspace_service_start_replaces_non_running_record(tmp_path: Path) -> None:
|
||||
manager = VmManager(
|
||||
backend_name="mock",
|
||||
base_dir=tmp_path / "vms",
|
||||
network_manager=TapNetworkManager(enabled=False),
|
||||
)
|
||||
workspace_id = str(
|
||||
manager.create_workspace(
|
||||
environment="debian:12-base",
|
||||
allow_host_compat=True,
|
||||
)["workspace_id"]
|
||||
)
|
||||
|
||||
failed = manager.start_service(
|
||||
workspace_id,
|
||||
"app",
|
||||
command="sh -lc 'exit 2'",
|
||||
readiness={"type": "file", "path": ".ready"},
|
||||
ready_timeout_seconds=1,
|
||||
ready_interval_ms=50,
|
||||
)
|
||||
assert failed["state"] == "failed"
|
||||
|
||||
started = manager.start_service(
|
||||
workspace_id,
|
||||
"app",
|
||||
command="sh -lc 'touch .ready; while true; do sleep 60; done'",
|
||||
readiness={"type": "file", "path": ".ready"},
|
||||
)
|
||||
assert started["state"] == "running"
|
||||
manager.delete_workspace(workspace_id)
|
||||
|
||||
|
||||
def test_workspace_service_supports_command_readiness_and_helper_probes(
|
||||
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
manager = VmManager(
|
||||
backend_name="mock",
|
||||
base_dir=tmp_path / "vms",
|
||||
network_manager=TapNetworkManager(enabled=False),
|
||||
)
|
||||
workspace_id = str(
|
||||
manager.create_workspace(
|
||||
environment="debian:12-base",
|
||||
allow_host_compat=True,
|
||||
)["workspace_id"]
|
||||
)
|
||||
|
||||
command_started = manager.start_service(
|
||||
workspace_id,
|
||||
"command-ready",
|
||||
command="sh -lc 'touch command.ready; while true; do sleep 60; done'",
|
||||
readiness={"type": "command", "command": "test -f command.ready"},
|
||||
)
|
||||
assert command_started["state"] == "running"
|
||||
|
||||
listed = manager.list_services(workspace_id)
|
||||
assert listed["count"] == 1
|
||||
assert listed["running_count"] == 1
|
||||
|
||||
status = manager.status_workspace(workspace_id)
|
||||
assert status["service_count"] == 1
|
||||
assert status["running_service_count"] == 1
|
||||
|
||||
assert manager.stop_service(workspace_id, "command-ready")["state"] == "stopped"
|
||||
|
||||
workspace_dir = tmp_path / "vms" / "workspaces" / workspace_id / "workspace"
|
||||
ready_file = workspace_dir / "probe.ready"
|
||||
ready_file.write_text("ok\n", encoding="utf-8")
|
||||
assert vm_manager_module._service_ready_on_host( # noqa: SLF001
|
||||
readiness={"type": "file", "path": "/workspace/probe.ready"},
|
||||
workspace_dir=workspace_dir,
|
||||
cwd=workspace_dir,
|
||||
)
|
||||
|
||||
class StubSocket:
|
||||
def __enter__(self) -> StubSocket:
|
||||
return self
|
||||
|
||||
def __exit__(self, *args: object) -> None:
|
||||
del args
|
||||
|
||||
def settimeout(self, timeout: int) -> None:
|
||||
assert timeout == 1
|
||||
|
||||
def connect(self, address: tuple[str, int]) -> None:
|
||||
assert address == ("127.0.0.1", 8080)
|
||||
|
||||
monkeypatch.setattr("pyro_mcp.vm_manager.socket.socket", lambda *args: StubSocket())
|
||||
assert vm_manager_module._service_ready_on_host( # noqa: SLF001
|
||||
readiness={"type": "tcp", "address": "127.0.0.1:8080"},
|
||||
workspace_dir=workspace_dir,
|
||||
cwd=workspace_dir,
|
||||
)
|
||||
|
||||
class StubResponse:
|
||||
status = 204
|
||||
|
||||
def __enter__(self) -> StubResponse:
|
||||
return self
|
||||
|
||||
def __exit__(self, *args: object) -> None:
|
||||
del args
|
||||
|
||||
def _urlopen(request: object, timeout: int) -> StubResponse:
|
||||
del request
|
||||
assert timeout == 2
|
||||
return StubResponse()
|
||||
|
||||
monkeypatch.setattr("pyro_mcp.vm_manager.urllib.request.urlopen", _urlopen)
|
||||
assert vm_manager_module._service_ready_on_host( # noqa: SLF001
|
||||
readiness={"type": "http", "url": "http://127.0.0.1:8080/"},
|
||||
workspace_dir=workspace_dir,
|
||||
cwd=workspace_dir,
|
||||
)
|
||||
|
||||
|
||||
def test_workspace_service_logs_tail_and_delete_cleanup(tmp_path: Path) -> None:
|
||||
manager = VmManager(
|
||||
backend_name="mock",
|
||||
base_dir=tmp_path / "vms",
|
||||
network_manager=TapNetworkManager(enabled=False),
|
||||
)
|
||||
workspace_id = str(
|
||||
manager.create_workspace(
|
||||
environment="debian:12-base",
|
||||
allow_host_compat=True,
|
||||
)["workspace_id"]
|
||||
)
|
||||
|
||||
manager.start_service(
|
||||
workspace_id,
|
||||
"logger",
|
||||
command=(
|
||||
"sh -lc 'printf \"one\\n\"; printf \"two\\n\"; "
|
||||
"touch .ready; while true; do sleep 60; done'"
|
||||
),
|
||||
readiness={"type": "file", "path": ".ready"},
|
||||
)
|
||||
|
||||
logs = manager.logs_service(workspace_id, "logger", tail_lines=1)
|
||||
assert logs["stdout"] == "two\n"
|
||||
assert logs["truncated"] is True
|
||||
|
||||
services_dir = tmp_path / "vms" / "workspaces" / workspace_id / "services"
|
||||
assert services_dir.exists()
|
||||
deleted = manager.delete_workspace(workspace_id)
|
||||
assert deleted["deleted"] is True
|
||||
assert not services_dir.exists()
|
||||
|
||||
|
||||
def test_workspace_status_stops_service_counts_when_workspace_is_stopped(tmp_path: Path) -> None:
|
||||
manager = VmManager(
|
||||
backend_name="mock",
|
||||
base_dir=tmp_path / "vms",
|
||||
network_manager=TapNetworkManager(enabled=False),
|
||||
)
|
||||
workspace_id = str(
|
||||
manager.create_workspace(
|
||||
environment="debian:12-base",
|
||||
allow_host_compat=True,
|
||||
)["workspace_id"]
|
||||
)
|
||||
manager.start_service(
|
||||
workspace_id,
|
||||
"app",
|
||||
command="sh -lc 'touch .ready; while true; do sleep 60; done'",
|
||||
readiness={"type": "file", "path": ".ready"},
|
||||
)
|
||||
service_path = tmp_path / "vms" / "workspaces" / workspace_id / "services" / "app.json"
|
||||
live_service_payload = json.loads(service_path.read_text(encoding="utf-8"))
|
||||
live_pid = int(live_service_payload["pid"])
|
||||
|
||||
try:
|
||||
workspace_path = tmp_path / "vms" / "workspaces" / workspace_id / "workspace.json"
|
||||
payload = json.loads(workspace_path.read_text(encoding="utf-8"))
|
||||
payload["state"] = "stopped"
|
||||
workspace_path.write_text(json.dumps(payload, indent=2, sort_keys=True), encoding="utf-8")
|
||||
|
||||
status = manager.status_workspace(workspace_id)
|
||||
assert status["state"] == "stopped"
|
||||
assert status["service_count"] == 1
|
||||
assert status["running_service_count"] == 0
|
||||
|
||||
service_payload = json.loads(service_path.read_text(encoding="utf-8"))
|
||||
assert service_payload["state"] == "stopped"
|
||||
assert service_payload["stop_reason"] == "workspace_stopped"
|
||||
finally:
|
||||
vm_manager_module._stop_process_group(live_pid) # noqa: SLF001
|
||||
|
||||
|
||||
def test_workspace_service_readiness_validation_helpers() -> None:
|
||||
assert vm_manager_module._normalize_workspace_service_name("app-1") == "app-1" # noqa: SLF001
|
||||
with pytest.raises(ValueError, match="service_name must not be empty"):
|
||||
vm_manager_module._normalize_workspace_service_name(" ") # noqa: SLF001
|
||||
with pytest.raises(ValueError, match="service_name must match"):
|
||||
vm_manager_module._normalize_workspace_service_name("bad name") # noqa: SLF001
|
||||
|
||||
assert vm_manager_module._normalize_workspace_service_readiness( # noqa: SLF001
|
||||
{"type": "file", "path": "subdir/.ready"}
|
||||
) == {"type": "file", "path": "/workspace/subdir/.ready"}
|
||||
assert vm_manager_module._normalize_workspace_service_readiness( # noqa: SLF001
|
||||
{"type": "tcp", "address": "127.0.0.1:8080"}
|
||||
) == {"type": "tcp", "address": "127.0.0.1:8080"}
|
||||
assert vm_manager_module._normalize_workspace_service_readiness( # noqa: SLF001
|
||||
{"type": "http", "url": "http://127.0.0.1:8080/"}
|
||||
) == {"type": "http", "url": "http://127.0.0.1:8080/"}
|
||||
assert vm_manager_module._normalize_workspace_service_readiness( # noqa: SLF001
|
||||
{"type": "command", "command": "test -f .ready"}
|
||||
) == {"type": "command", "command": "test -f .ready"}
|
||||
|
||||
with pytest.raises(ValueError, match="one of: file, tcp, http, command"):
|
||||
vm_manager_module._normalize_workspace_service_readiness({"type": "bogus"}) # noqa: SLF001
|
||||
with pytest.raises(ValueError, match="required for file readiness"):
|
||||
vm_manager_module._normalize_workspace_service_readiness({"type": "file"}) # noqa: SLF001
|
||||
with pytest.raises(ValueError, match="HOST:PORT format"):
|
||||
vm_manager_module._normalize_workspace_service_readiness( # noqa: SLF001
|
||||
{"type": "tcp", "address": "127.0.0.1"}
|
||||
)
|
||||
with pytest.raises(ValueError, match="required for http readiness"):
|
||||
vm_manager_module._normalize_workspace_service_readiness({"type": "http"}) # noqa: SLF001
|
||||
with pytest.raises(ValueError, match="required for command readiness"):
|
||||
vm_manager_module._normalize_workspace_service_readiness({"type": "command"}) # noqa: SLF001
|
||||
|
||||
|
||||
def test_workspace_service_text_and_exit_code_helpers(tmp_path: Path) -> None:
|
||||
status_path = tmp_path / "service.status"
|
||||
assert vm_manager_module._read_service_exit_code(status_path) is None # noqa: SLF001
|
||||
status_path.write_text("", encoding="utf-8")
|
||||
assert vm_manager_module._read_service_exit_code(status_path) is None # noqa: SLF001
|
||||
status_path.write_text("7\n", encoding="utf-8")
|
||||
assert vm_manager_module._read_service_exit_code(status_path) == 7 # noqa: SLF001
|
||||
|
||||
log_path = tmp_path / "service.log"
|
||||
assert vm_manager_module._tail_text(log_path, tail_lines=10) == ("", False) # noqa: SLF001
|
||||
log_path.write_text("one\ntwo\nthree\n", encoding="utf-8")
|
||||
assert vm_manager_module._tail_text(log_path, tail_lines=None) == ( # noqa: SLF001
|
||||
"one\ntwo\nthree\n",
|
||||
False,
|
||||
)
|
||||
assert vm_manager_module._tail_text(log_path, tail_lines=5) == ( # noqa: SLF001
|
||||
"one\ntwo\nthree\n",
|
||||
False,
|
||||
)
|
||||
assert vm_manager_module._tail_text(log_path, tail_lines=1) == ("three\n", True) # noqa: SLF001
|
||||
|
||||
|
||||
def test_workspace_service_process_group_helpers(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
def _missing(_pid: int, _signal: int) -> None:
|
||||
raise ProcessLookupError()
|
||||
|
||||
monkeypatch.setattr("pyro_mcp.vm_manager.os.killpg", _missing)
|
||||
assert vm_manager_module._stop_process_group(123) == (False, False) # noqa: SLF001
|
||||
|
||||
kill_calls: list[int] = []
|
||||
monotonic_values = iter([0.0, 0.0, 5.0, 5.0, 10.0])
|
||||
running_states = iter([True, True, False])
|
||||
|
||||
def _killpg(_pid: int, signum: int) -> None:
|
||||
kill_calls.append(signum)
|
||||
|
||||
def _monotonic() -> float:
|
||||
return next(monotonic_values)
|
||||
|
||||
def _is_running(_pid: int | None) -> bool:
|
||||
return next(running_states)
|
||||
|
||||
monkeypatch.setattr("pyro_mcp.vm_manager.os.killpg", _killpg)
|
||||
monkeypatch.setattr("pyro_mcp.vm_manager.time.monotonic", _monotonic)
|
||||
monkeypatch.setattr("pyro_mcp.vm_manager.time.sleep", lambda _seconds: None)
|
||||
monkeypatch.setattr("pyro_mcp.vm_manager._pid_is_running", _is_running)
|
||||
|
||||
stopped, killed = vm_manager_module._stop_process_group(456, wait_seconds=5) # noqa: SLF001
|
||||
assert (stopped, killed) == (True, True)
|
||||
assert kill_calls == [signal.SIGTERM, signal.SIGKILL]
|
||||
|
||||
|
||||
def test_workspace_service_probe_and_refresh_helpers(
|
||||
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
assert vm_manager_module._run_service_probe_command(tmp_path, "exit 3") == 3 # noqa: SLF001
|
||||
|
||||
services_dir = tmp_path / "services"
|
||||
services_dir.mkdir()
|
||||
status_path = services_dir / "app.status"
|
||||
status_path.write_text("9\n", encoding="utf-8")
|
||||
running = vm_manager_module.WorkspaceServiceRecord( # noqa: SLF001
|
||||
workspace_id="workspace-1",
|
||||
service_name="app",
|
||||
command="sleep 60",
|
||||
cwd="/workspace",
|
||||
state="running",
|
||||
started_at=time.time(),
|
||||
readiness=None,
|
||||
ready_at=None,
|
||||
ended_at=None,
|
||||
exit_code=None,
|
||||
pid=1234,
|
||||
execution_mode="host_compat",
|
||||
stop_reason=None,
|
||||
)
|
||||
|
||||
monkeypatch.setattr("pyro_mcp.vm_manager._pid_is_running", lambda _pid: False)
|
||||
refreshed = vm_manager_module._refresh_local_service_record( # noqa: SLF001
|
||||
running,
|
||||
services_dir=services_dir,
|
||||
)
|
||||
assert refreshed.state == "exited"
|
||||
assert refreshed.exit_code == 9
|
||||
|
||||
monkeypatch.setattr(
|
||||
"pyro_mcp.vm_manager._stop_process_group",
|
||||
lambda _pid: (True, False),
|
||||
)
|
||||
stopped = vm_manager_module._stop_local_service( # noqa: SLF001
|
||||
refreshed,
|
||||
services_dir=services_dir,
|
||||
)
|
||||
assert stopped.state == "stopped"
|
||||
assert stopped.stop_reason == "sigterm"
|
||||
|
|
|
|||
|
|
@ -165,6 +165,7 @@ def test_workspace_shells_write_and_signal_runtime_errors(
|
|||
try:
|
||||
with session._lock: # noqa: SLF001
|
||||
session._master_fd = None # noqa: SLF001
|
||||
session._input_pipe = None # noqa: SLF001
|
||||
with pytest.raises(RuntimeError, match="transport is unavailable"):
|
||||
session.write("pwd", append_newline=True)
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue