Add stopped-workspace disk export and inspection
Finish the 3.1.0 secondary disk-tools milestone so stable workspaces can be stopped, inspected offline, exported as raw ext4 images, and started again without changing the primary workspace-first interaction model. Add workspace stop/start plus workspace disk export/list/read across the CLI, SDK, and MCP, backed by a new offline debugfs inspection helper and guest-only validation. Scrub runtime-only guest state before disk inspection/export, and fix the real guest reliability gaps by flushing the filesystem on stop and removing stale Firecracker socket files before restart. Update the docs, examples, changelog, and roadmap to mark 3.1.0 done, and cover the new lifecycle/disk paths with API, CLI, manager, contract, and package-surface tests. Validation: uv lock; UV_CACHE_DIR=.uv-cache make check; UV_CACHE_DIR=.uv-cache make dist-check; real guest-backed smoke for create, shell/service activity, stop, workspace disk list/read/export, start, exec, and delete.
This commit is contained in:
parent
f2d20ef30a
commit
287f6d100f
26 changed files with 2585 additions and 34 deletions
|
|
@ -50,9 +50,14 @@ def test_pyro_create_server_registers_vm_run(tmp_path: Path) -> None:
|
|||
assert "vm_run" in tool_names
|
||||
assert "vm_create" in tool_names
|
||||
assert "workspace_create" in tool_names
|
||||
assert "workspace_start" in tool_names
|
||||
assert "workspace_stop" in tool_names
|
||||
assert "workspace_diff" in tool_names
|
||||
assert "workspace_sync_push" in tool_names
|
||||
assert "workspace_export" in tool_names
|
||||
assert "workspace_disk_export" in tool_names
|
||||
assert "workspace_disk_list" in tool_names
|
||||
assert "workspace_disk_read" in tool_names
|
||||
assert "snapshot_create" in tool_names
|
||||
assert "snapshot_list" in tool_names
|
||||
assert "snapshot_delete" in tool_names
|
||||
|
|
@ -289,3 +294,603 @@ def test_pyro_workspace_methods_delegate_to_manager(tmp_path: Path) -> None:
|
|||
assert status["service_count"] == 0
|
||||
assert logs["count"] == 0
|
||||
assert deleted["deleted"] is True
|
||||
|
||||
|
||||
def test_pyro_workspace_disk_methods_delegate_to_manager() -> None:
|
||||
calls: list[tuple[str, dict[str, Any]]] = []
|
||||
|
||||
class StubManager:
|
||||
def stop_workspace(self, workspace_id: str) -> dict[str, Any]:
|
||||
calls.append(("stop_workspace", {"workspace_id": workspace_id}))
|
||||
return {"workspace_id": workspace_id, "state": "stopped"}
|
||||
|
||||
def start_workspace(self, workspace_id: str) -> dict[str, Any]:
|
||||
calls.append(("start_workspace", {"workspace_id": workspace_id}))
|
||||
return {"workspace_id": workspace_id, "state": "started"}
|
||||
|
||||
def export_workspace_disk(self, workspace_id: str, *, output_path: Path) -> dict[str, Any]:
|
||||
calls.append(
|
||||
(
|
||||
"export_workspace_disk",
|
||||
{"workspace_id": workspace_id, "output_path": str(output_path)},
|
||||
)
|
||||
)
|
||||
return {"workspace_id": workspace_id, "output_path": str(output_path)}
|
||||
|
||||
def list_workspace_disk(
|
||||
self,
|
||||
workspace_id: str,
|
||||
*,
|
||||
path: str = "/workspace",
|
||||
recursive: bool = False,
|
||||
) -> dict[str, Any]:
|
||||
calls.append(
|
||||
(
|
||||
"list_workspace_disk",
|
||||
{
|
||||
"workspace_id": workspace_id,
|
||||
"path": path,
|
||||
"recursive": recursive,
|
||||
},
|
||||
)
|
||||
)
|
||||
return {"workspace_id": workspace_id, "entries": []}
|
||||
|
||||
def read_workspace_disk(
|
||||
self,
|
||||
workspace_id: str,
|
||||
*,
|
||||
path: str,
|
||||
max_bytes: int = 65536,
|
||||
) -> dict[str, Any]:
|
||||
calls.append(
|
||||
(
|
||||
"read_workspace_disk",
|
||||
{
|
||||
"workspace_id": workspace_id,
|
||||
"path": path,
|
||||
"max_bytes": max_bytes,
|
||||
},
|
||||
)
|
||||
)
|
||||
return {"workspace_id": workspace_id, "content": ""}
|
||||
|
||||
pyro = Pyro(manager=cast(Any, StubManager()))
|
||||
|
||||
stopped = pyro.stop_workspace("workspace-123")
|
||||
started = pyro.start_workspace("workspace-123")
|
||||
exported = pyro.export_workspace_disk("workspace-123", output_path=Path("/tmp/workspace.ext4"))
|
||||
listed = pyro.list_workspace_disk("workspace-123", path="/workspace/src", recursive=True)
|
||||
read = pyro.read_workspace_disk("workspace-123", "note.txt", max_bytes=4096)
|
||||
|
||||
assert stopped["state"] == "stopped"
|
||||
assert started["state"] == "started"
|
||||
assert exported["output_path"] == "/tmp/workspace.ext4"
|
||||
assert listed["entries"] == []
|
||||
assert read["content"] == ""
|
||||
assert calls == [
|
||||
("stop_workspace", {"workspace_id": "workspace-123"}),
|
||||
("start_workspace", {"workspace_id": "workspace-123"}),
|
||||
(
|
||||
"export_workspace_disk",
|
||||
{
|
||||
"workspace_id": "workspace-123",
|
||||
"output_path": "/tmp/workspace.ext4",
|
||||
},
|
||||
),
|
||||
(
|
||||
"list_workspace_disk",
|
||||
{
|
||||
"workspace_id": "workspace-123",
|
||||
"path": "/workspace/src",
|
||||
"recursive": True,
|
||||
},
|
||||
),
|
||||
(
|
||||
"read_workspace_disk",
|
||||
{
|
||||
"workspace_id": "workspace-123",
|
||||
"path": "note.txt",
|
||||
"max_bytes": 4096,
|
||||
},
|
||||
),
|
||||
]
|
||||
|
||||
|
||||
def test_pyro_create_server_workspace_disk_tools_delegate() -> None:
|
||||
calls: list[tuple[str, dict[str, Any]]] = []
|
||||
|
||||
class StubManager:
|
||||
def stop_workspace(self, workspace_id: str) -> dict[str, Any]:
|
||||
calls.append(("stop_workspace", {"workspace_id": workspace_id}))
|
||||
return {"workspace_id": workspace_id, "state": "stopped"}
|
||||
|
||||
def start_workspace(self, workspace_id: str) -> dict[str, Any]:
|
||||
calls.append(("start_workspace", {"workspace_id": workspace_id}))
|
||||
return {"workspace_id": workspace_id, "state": "started"}
|
||||
|
||||
def export_workspace_disk(self, workspace_id: str, *, output_path: str) -> dict[str, Any]:
|
||||
calls.append(
|
||||
(
|
||||
"export_workspace_disk",
|
||||
{"workspace_id": workspace_id, "output_path": output_path},
|
||||
)
|
||||
)
|
||||
return {"workspace_id": workspace_id, "output_path": output_path}
|
||||
|
||||
def list_workspace_disk(
|
||||
self,
|
||||
workspace_id: str,
|
||||
*,
|
||||
path: str = "/workspace",
|
||||
recursive: bool = False,
|
||||
) -> dict[str, Any]:
|
||||
calls.append(
|
||||
(
|
||||
"list_workspace_disk",
|
||||
{
|
||||
"workspace_id": workspace_id,
|
||||
"path": path,
|
||||
"recursive": recursive,
|
||||
},
|
||||
)
|
||||
)
|
||||
return {"workspace_id": workspace_id, "entries": []}
|
||||
|
||||
def read_workspace_disk(
|
||||
self,
|
||||
workspace_id: str,
|
||||
*,
|
||||
path: str,
|
||||
max_bytes: int = 65536,
|
||||
) -> dict[str, Any]:
|
||||
calls.append(
|
||||
(
|
||||
"read_workspace_disk",
|
||||
{
|
||||
"workspace_id": workspace_id,
|
||||
"path": path,
|
||||
"max_bytes": max_bytes,
|
||||
},
|
||||
)
|
||||
)
|
||||
return {"workspace_id": workspace_id, "content": ""}
|
||||
|
||||
pyro = Pyro(manager=cast(Any, StubManager()))
|
||||
|
||||
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() -> tuple[dict[str, Any], ...]:
|
||||
server = pyro.create_server()
|
||||
stopped = _extract_structured(
|
||||
await server.call_tool("workspace_stop", {"workspace_id": "workspace-123"})
|
||||
)
|
||||
started = _extract_structured(
|
||||
await server.call_tool("workspace_start", {"workspace_id": "workspace-123"})
|
||||
)
|
||||
exported = _extract_structured(
|
||||
await server.call_tool(
|
||||
"workspace_disk_export",
|
||||
{
|
||||
"workspace_id": "workspace-123",
|
||||
"output_path": "/tmp/workspace.ext4",
|
||||
},
|
||||
)
|
||||
)
|
||||
listed = _extract_structured(
|
||||
await server.call_tool(
|
||||
"workspace_disk_list",
|
||||
{
|
||||
"workspace_id": "workspace-123",
|
||||
"path": "/workspace/src",
|
||||
"recursive": True,
|
||||
},
|
||||
)
|
||||
)
|
||||
read = _extract_structured(
|
||||
await server.call_tool(
|
||||
"workspace_disk_read",
|
||||
{
|
||||
"workspace_id": "workspace-123",
|
||||
"path": "note.txt",
|
||||
"max_bytes": 4096,
|
||||
},
|
||||
)
|
||||
)
|
||||
return stopped, started, exported, listed, read
|
||||
|
||||
stopped, started, exported, listed, read = asyncio.run(_run())
|
||||
assert stopped["state"] == "stopped"
|
||||
assert started["state"] == "started"
|
||||
assert exported["output_path"] == "/tmp/workspace.ext4"
|
||||
assert listed["entries"] == []
|
||||
assert read["content"] == ""
|
||||
assert calls == [
|
||||
("stop_workspace", {"workspace_id": "workspace-123"}),
|
||||
("start_workspace", {"workspace_id": "workspace-123"}),
|
||||
(
|
||||
"export_workspace_disk",
|
||||
{
|
||||
"workspace_id": "workspace-123",
|
||||
"output_path": "/tmp/workspace.ext4",
|
||||
},
|
||||
),
|
||||
(
|
||||
"list_workspace_disk",
|
||||
{
|
||||
"workspace_id": "workspace-123",
|
||||
"path": "/workspace/src",
|
||||
"recursive": True,
|
||||
},
|
||||
),
|
||||
(
|
||||
"read_workspace_disk",
|
||||
{
|
||||
"workspace_id": "workspace-123",
|
||||
"path": "note.txt",
|
||||
"max_bytes": 4096,
|
||||
},
|
||||
),
|
||||
]
|
||||
|
||||
|
||||
def test_pyro_create_server_workspace_status_shell_and_service_delegate() -> None:
|
||||
calls: list[tuple[str, dict[str, Any]]] = []
|
||||
|
||||
class StubManager:
|
||||
def status_workspace(self, workspace_id: str) -> dict[str, Any]:
|
||||
calls.append(("status_workspace", {"workspace_id": workspace_id}))
|
||||
return {"workspace_id": workspace_id, "state": "started"}
|
||||
|
||||
def logs_workspace(self, workspace_id: str) -> dict[str, Any]:
|
||||
calls.append(("logs_workspace", {"workspace_id": workspace_id}))
|
||||
return {"workspace_id": workspace_id, "count": 0, "entries": []}
|
||||
|
||||
def open_shell(
|
||||
self,
|
||||
workspace_id: str,
|
||||
*,
|
||||
cwd: str = "/workspace",
|
||||
cols: int = 120,
|
||||
rows: int = 30,
|
||||
secret_env: dict[str, str] | None = None,
|
||||
) -> dict[str, Any]:
|
||||
calls.append(
|
||||
(
|
||||
"open_shell",
|
||||
{
|
||||
"workspace_id": workspace_id,
|
||||
"cwd": cwd,
|
||||
"cols": cols,
|
||||
"rows": rows,
|
||||
"secret_env": secret_env,
|
||||
},
|
||||
)
|
||||
)
|
||||
return {"workspace_id": workspace_id, "shell_id": "shell-1", "state": "running"}
|
||||
|
||||
def read_shell(
|
||||
self,
|
||||
workspace_id: str,
|
||||
shell_id: str,
|
||||
*,
|
||||
cursor: int = 0,
|
||||
max_chars: int = 65536,
|
||||
) -> dict[str, Any]:
|
||||
calls.append(
|
||||
(
|
||||
"read_shell",
|
||||
{
|
||||
"workspace_id": workspace_id,
|
||||
"shell_id": shell_id,
|
||||
"cursor": cursor,
|
||||
"max_chars": max_chars,
|
||||
},
|
||||
)
|
||||
)
|
||||
return {"workspace_id": workspace_id, "shell_id": shell_id, "output": ""}
|
||||
|
||||
def write_shell(
|
||||
self,
|
||||
workspace_id: str,
|
||||
shell_id: str,
|
||||
*,
|
||||
input_text: str,
|
||||
append_newline: bool = True,
|
||||
) -> dict[str, Any]:
|
||||
calls.append(
|
||||
(
|
||||
"write_shell",
|
||||
{
|
||||
"workspace_id": workspace_id,
|
||||
"shell_id": shell_id,
|
||||
"input_text": input_text,
|
||||
"append_newline": append_newline,
|
||||
},
|
||||
)
|
||||
)
|
||||
return {
|
||||
"workspace_id": workspace_id,
|
||||
"shell_id": shell_id,
|
||||
"input_length": len(input_text),
|
||||
}
|
||||
|
||||
def signal_shell(
|
||||
self,
|
||||
workspace_id: str,
|
||||
shell_id: str,
|
||||
*,
|
||||
signal_name: str = "INT",
|
||||
) -> dict[str, Any]:
|
||||
calls.append(
|
||||
(
|
||||
"signal_shell",
|
||||
{
|
||||
"workspace_id": workspace_id,
|
||||
"shell_id": shell_id,
|
||||
"signal_name": signal_name,
|
||||
},
|
||||
)
|
||||
)
|
||||
return {"workspace_id": workspace_id, "shell_id": shell_id, "signal": signal_name}
|
||||
|
||||
def close_shell(self, workspace_id: str, shell_id: str) -> dict[str, Any]:
|
||||
calls.append(
|
||||
("close_shell", {"workspace_id": workspace_id, "shell_id": shell_id})
|
||||
)
|
||||
return {"workspace_id": workspace_id, "shell_id": shell_id, "closed": True}
|
||||
|
||||
def start_service(
|
||||
self,
|
||||
workspace_id: str,
|
||||
service_name: str,
|
||||
**kwargs: Any,
|
||||
) -> dict[str, Any]:
|
||||
calls.append(
|
||||
(
|
||||
"start_service",
|
||||
{
|
||||
"workspace_id": workspace_id,
|
||||
"service_name": service_name,
|
||||
**kwargs,
|
||||
},
|
||||
)
|
||||
)
|
||||
return {"workspace_id": workspace_id, "service_name": service_name, "state": "running"}
|
||||
|
||||
pyro = Pyro(manager=cast(Any, StubManager()))
|
||||
|
||||
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() -> tuple[dict[str, Any], ...]:
|
||||
server = pyro.create_server()
|
||||
status = _extract_structured(
|
||||
await server.call_tool("workspace_status", {"workspace_id": "workspace-123"})
|
||||
)
|
||||
logs = _extract_structured(
|
||||
await server.call_tool("workspace_logs", {"workspace_id": "workspace-123"})
|
||||
)
|
||||
opened = _extract_structured(
|
||||
await server.call_tool(
|
||||
"shell_open",
|
||||
{
|
||||
"workspace_id": "workspace-123",
|
||||
"cwd": "/workspace/src",
|
||||
"cols": 100,
|
||||
"rows": 20,
|
||||
"secret_env": {"TOKEN": "API_TOKEN"},
|
||||
},
|
||||
)
|
||||
)
|
||||
read = _extract_structured(
|
||||
await server.call_tool(
|
||||
"shell_read",
|
||||
{
|
||||
"workspace_id": "workspace-123",
|
||||
"shell_id": "shell-1",
|
||||
"cursor": 5,
|
||||
"max_chars": 1024,
|
||||
},
|
||||
)
|
||||
)
|
||||
wrote = _extract_structured(
|
||||
await server.call_tool(
|
||||
"shell_write",
|
||||
{
|
||||
"workspace_id": "workspace-123",
|
||||
"shell_id": "shell-1",
|
||||
"input": "pwd",
|
||||
"append_newline": False,
|
||||
},
|
||||
)
|
||||
)
|
||||
signaled = _extract_structured(
|
||||
await server.call_tool(
|
||||
"shell_signal",
|
||||
{
|
||||
"workspace_id": "workspace-123",
|
||||
"shell_id": "shell-1",
|
||||
"signal_name": "TERM",
|
||||
},
|
||||
)
|
||||
)
|
||||
closed = _extract_structured(
|
||||
await server.call_tool(
|
||||
"shell_close",
|
||||
{"workspace_id": "workspace-123", "shell_id": "shell-1"},
|
||||
)
|
||||
)
|
||||
file_service = _extract_structured(
|
||||
await server.call_tool(
|
||||
"service_start",
|
||||
{
|
||||
"workspace_id": "workspace-123",
|
||||
"service_name": "file",
|
||||
"command": "run-file",
|
||||
"ready_file": ".ready",
|
||||
},
|
||||
)
|
||||
)
|
||||
tcp_service = _extract_structured(
|
||||
await server.call_tool(
|
||||
"service_start",
|
||||
{
|
||||
"workspace_id": "workspace-123",
|
||||
"service_name": "tcp",
|
||||
"command": "run-tcp",
|
||||
"ready_tcp": "127.0.0.1:8080",
|
||||
},
|
||||
)
|
||||
)
|
||||
http_service = _extract_structured(
|
||||
await server.call_tool(
|
||||
"service_start",
|
||||
{
|
||||
"workspace_id": "workspace-123",
|
||||
"service_name": "http",
|
||||
"command": "run-http",
|
||||
"ready_http": "http://127.0.0.1:8080/",
|
||||
},
|
||||
)
|
||||
)
|
||||
command_service = _extract_structured(
|
||||
await server.call_tool(
|
||||
"service_start",
|
||||
{
|
||||
"workspace_id": "workspace-123",
|
||||
"service_name": "command",
|
||||
"command": "run-command",
|
||||
"ready_command": "test -f .ready",
|
||||
},
|
||||
)
|
||||
)
|
||||
return (
|
||||
status,
|
||||
logs,
|
||||
opened,
|
||||
read,
|
||||
wrote,
|
||||
signaled,
|
||||
closed,
|
||||
file_service,
|
||||
tcp_service,
|
||||
http_service,
|
||||
command_service,
|
||||
)
|
||||
|
||||
results = asyncio.run(_run())
|
||||
assert results[0]["state"] == "started"
|
||||
assert results[1]["count"] == 0
|
||||
assert results[2]["shell_id"] == "shell-1"
|
||||
assert results[6]["closed"] is True
|
||||
assert results[7]["state"] == "running"
|
||||
assert results[10]["state"] == "running"
|
||||
assert calls == [
|
||||
("status_workspace", {"workspace_id": "workspace-123"}),
|
||||
("logs_workspace", {"workspace_id": "workspace-123"}),
|
||||
(
|
||||
"open_shell",
|
||||
{
|
||||
"workspace_id": "workspace-123",
|
||||
"cwd": "/workspace/src",
|
||||
"cols": 100,
|
||||
"rows": 20,
|
||||
"secret_env": {"TOKEN": "API_TOKEN"},
|
||||
},
|
||||
),
|
||||
(
|
||||
"read_shell",
|
||||
{
|
||||
"workspace_id": "workspace-123",
|
||||
"shell_id": "shell-1",
|
||||
"cursor": 5,
|
||||
"max_chars": 1024,
|
||||
},
|
||||
),
|
||||
(
|
||||
"write_shell",
|
||||
{
|
||||
"workspace_id": "workspace-123",
|
||||
"shell_id": "shell-1",
|
||||
"input_text": "pwd",
|
||||
"append_newline": False,
|
||||
},
|
||||
),
|
||||
(
|
||||
"signal_shell",
|
||||
{
|
||||
"workspace_id": "workspace-123",
|
||||
"shell_id": "shell-1",
|
||||
"signal_name": "TERM",
|
||||
},
|
||||
),
|
||||
("close_shell", {"workspace_id": "workspace-123", "shell_id": "shell-1"}),
|
||||
(
|
||||
"start_service",
|
||||
{
|
||||
"workspace_id": "workspace-123",
|
||||
"service_name": "file",
|
||||
"command": "run-file",
|
||||
"cwd": "/workspace",
|
||||
"readiness": {"type": "file", "path": ".ready"},
|
||||
"ready_timeout_seconds": 30,
|
||||
"ready_interval_ms": 500,
|
||||
"secret_env": None,
|
||||
"published_ports": None,
|
||||
},
|
||||
),
|
||||
(
|
||||
"start_service",
|
||||
{
|
||||
"workspace_id": "workspace-123",
|
||||
"service_name": "tcp",
|
||||
"command": "run-tcp",
|
||||
"cwd": "/workspace",
|
||||
"readiness": {"type": "tcp", "address": "127.0.0.1:8080"},
|
||||
"ready_timeout_seconds": 30,
|
||||
"ready_interval_ms": 500,
|
||||
"secret_env": None,
|
||||
"published_ports": None,
|
||||
},
|
||||
),
|
||||
(
|
||||
"start_service",
|
||||
{
|
||||
"workspace_id": "workspace-123",
|
||||
"service_name": "http",
|
||||
"command": "run-http",
|
||||
"cwd": "/workspace",
|
||||
"readiness": {"type": "http", "url": "http://127.0.0.1:8080/"},
|
||||
"ready_timeout_seconds": 30,
|
||||
"ready_interval_ms": 500,
|
||||
"secret_env": None,
|
||||
"published_ports": None,
|
||||
},
|
||||
),
|
||||
(
|
||||
"start_service",
|
||||
{
|
||||
"workspace_id": "workspace-123",
|
||||
"service_name": "command",
|
||||
"command": "run-command",
|
||||
"cwd": "/workspace",
|
||||
"readiness": {"type": "command", "command": "test -f .ready"},
|
||||
"ready_timeout_seconds": 30,
|
||||
"ready_interval_ms": 500,
|
||||
"secret_env": None,
|
||||
"published_ports": None,
|
||||
},
|
||||
),
|
||||
]
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue