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
|
|
@ -72,6 +72,10 @@ def test_cli_subcommand_help_includes_examples_and_guidance() -> None:
|
|||
assert "pyro workspace exec WORKSPACE_ID" in workspace_help
|
||||
assert "pyro workspace diff WORKSPACE_ID" in workspace_help
|
||||
assert "pyro workspace export WORKSPACE_ID src/note.txt --output ./note.txt" in workspace_help
|
||||
assert "pyro workspace stop WORKSPACE_ID" in workspace_help
|
||||
assert "pyro workspace disk list WORKSPACE_ID" in workspace_help
|
||||
assert "pyro workspace disk export WORKSPACE_ID --output ./workspace.ext4" in workspace_help
|
||||
assert "pyro workspace start WORKSPACE_ID" in workspace_help
|
||||
assert "pyro workspace snapshot create WORKSPACE_ID checkpoint" in workspace_help
|
||||
assert "pyro workspace reset WORKSPACE_ID --snapshot checkpoint" in workspace_help
|
||||
assert "pyro workspace shell open WORKSPACE_ID" in workspace_help
|
||||
|
|
@ -112,6 +116,37 @@ def test_cli_subcommand_help_includes_examples_and_guidance() -> None:
|
|||
assert "--output" in workspace_export_help
|
||||
assert "Export one file or directory from `/workspace`" in workspace_export_help
|
||||
|
||||
workspace_stop_help = _subparser_choice(
|
||||
_subparser_choice(parser, "workspace"), "stop"
|
||||
).format_help()
|
||||
assert "Stop the backing sandbox" in workspace_stop_help
|
||||
|
||||
workspace_start_help = _subparser_choice(
|
||||
_subparser_choice(parser, "workspace"), "start"
|
||||
).format_help()
|
||||
assert "previously stopped workspace" in workspace_start_help
|
||||
|
||||
workspace_disk_help = _subparser_choice(
|
||||
_subparser_choice(parser, "workspace"), "disk"
|
||||
).format_help()
|
||||
assert "secondary stopped-workspace disk tools" in workspace_disk_help
|
||||
assert "pyro workspace disk read WORKSPACE_ID note.txt" in workspace_disk_help
|
||||
|
||||
workspace_disk_export_help = _subparser_choice(
|
||||
_subparser_choice(_subparser_choice(parser, "workspace"), "disk"), "export"
|
||||
).format_help()
|
||||
assert "--output" in workspace_disk_export_help
|
||||
|
||||
workspace_disk_list_help = _subparser_choice(
|
||||
_subparser_choice(_subparser_choice(parser, "workspace"), "disk"), "list"
|
||||
).format_help()
|
||||
assert "--recursive" in workspace_disk_list_help
|
||||
|
||||
workspace_disk_read_help = _subparser_choice(
|
||||
_subparser_choice(_subparser_choice(parser, "workspace"), "disk"), "read"
|
||||
).format_help()
|
||||
assert "--max-bytes" in workspace_disk_read_help
|
||||
|
||||
workspace_diff_help = _subparser_choice(
|
||||
_subparser_choice(parser, "workspace"), "diff"
|
||||
).format_help()
|
||||
|
|
@ -647,6 +682,193 @@ def test_cli_workspace_export_prints_human_output(
|
|||
assert "artifact_type=file" in output
|
||||
|
||||
|
||||
def test_cli_workspace_stop_and_start_print_human_output(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
capsys: pytest.CaptureFixture[str],
|
||||
) -> None:
|
||||
class StubPyro:
|
||||
def stop_workspace(self, workspace_id: str) -> dict[str, Any]:
|
||||
assert workspace_id == "workspace-123"
|
||||
return {
|
||||
"workspace_id": workspace_id,
|
||||
"environment": "debian:12",
|
||||
"state": "stopped",
|
||||
"workspace_path": "/workspace",
|
||||
"network_policy": "off",
|
||||
"execution_mode": "guest_vsock",
|
||||
"vcpu_count": 1,
|
||||
"mem_mib": 1024,
|
||||
"command_count": 2,
|
||||
"reset_count": 0,
|
||||
"service_count": 0,
|
||||
"running_service_count": 0,
|
||||
}
|
||||
|
||||
def start_workspace(self, workspace_id: str) -> dict[str, Any]:
|
||||
assert workspace_id == "workspace-123"
|
||||
return {
|
||||
"workspace_id": workspace_id,
|
||||
"environment": "debian:12",
|
||||
"state": "started",
|
||||
"workspace_path": "/workspace",
|
||||
"network_policy": "off",
|
||||
"execution_mode": "guest_vsock",
|
||||
"vcpu_count": 1,
|
||||
"mem_mib": 1024,
|
||||
"command_count": 2,
|
||||
"reset_count": 0,
|
||||
"service_count": 0,
|
||||
"running_service_count": 0,
|
||||
}
|
||||
|
||||
class StopParser:
|
||||
def parse_args(self) -> argparse.Namespace:
|
||||
return argparse.Namespace(
|
||||
command="workspace",
|
||||
workspace_command="stop",
|
||||
workspace_id="workspace-123",
|
||||
json=False,
|
||||
)
|
||||
|
||||
class StartParser:
|
||||
def parse_args(self) -> argparse.Namespace:
|
||||
return argparse.Namespace(
|
||||
command="workspace",
|
||||
workspace_command="start",
|
||||
workspace_id="workspace-123",
|
||||
json=False,
|
||||
)
|
||||
|
||||
monkeypatch.setattr(cli, "Pyro", StubPyro)
|
||||
monkeypatch.setattr(cli, "_build_parser", lambda: StopParser())
|
||||
cli.main()
|
||||
stopped_output = capsys.readouterr().out
|
||||
assert "Stopped workspace ID: workspace-123" in stopped_output
|
||||
|
||||
monkeypatch.setattr(cli, "_build_parser", lambda: StartParser())
|
||||
cli.main()
|
||||
started_output = capsys.readouterr().out
|
||||
assert "Started workspace ID: workspace-123" in started_output
|
||||
|
||||
|
||||
def test_cli_workspace_disk_commands_print_human_and_json(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
capsys: pytest.CaptureFixture[str],
|
||||
) -> None:
|
||||
class StubPyro:
|
||||
def export_workspace_disk(
|
||||
self,
|
||||
workspace_id: str,
|
||||
*,
|
||||
output_path: str,
|
||||
) -> dict[str, Any]:
|
||||
assert workspace_id == "workspace-123"
|
||||
assert output_path == "./workspace.ext4"
|
||||
return {
|
||||
"workspace_id": workspace_id,
|
||||
"output_path": "/tmp/workspace.ext4",
|
||||
"disk_format": "ext4",
|
||||
"bytes_written": 8192,
|
||||
}
|
||||
|
||||
def list_workspace_disk(
|
||||
self,
|
||||
workspace_id: str,
|
||||
*,
|
||||
path: str,
|
||||
recursive: bool,
|
||||
) -> dict[str, Any]:
|
||||
assert workspace_id == "workspace-123"
|
||||
assert path == "/workspace"
|
||||
assert recursive is True
|
||||
return {
|
||||
"workspace_id": workspace_id,
|
||||
"path": path,
|
||||
"recursive": recursive,
|
||||
"entries": [
|
||||
{
|
||||
"path": "/workspace/note.txt",
|
||||
"artifact_type": "file",
|
||||
"size_bytes": 6,
|
||||
"link_target": None,
|
||||
}
|
||||
],
|
||||
}
|
||||
|
||||
def read_workspace_disk(
|
||||
self,
|
||||
workspace_id: str,
|
||||
path: str,
|
||||
*,
|
||||
max_bytes: int,
|
||||
) -> dict[str, Any]:
|
||||
assert workspace_id == "workspace-123"
|
||||
assert path == "note.txt"
|
||||
assert max_bytes == 4096
|
||||
return {
|
||||
"workspace_id": workspace_id,
|
||||
"path": "/workspace/note.txt",
|
||||
"size_bytes": 6,
|
||||
"max_bytes": max_bytes,
|
||||
"content": "hello\n",
|
||||
"truncated": False,
|
||||
}
|
||||
|
||||
class ExportParser:
|
||||
def parse_args(self) -> argparse.Namespace:
|
||||
return argparse.Namespace(
|
||||
command="workspace",
|
||||
workspace_command="disk",
|
||||
workspace_disk_command="export",
|
||||
workspace_id="workspace-123",
|
||||
output="./workspace.ext4",
|
||||
json=False,
|
||||
)
|
||||
|
||||
class ListParser:
|
||||
def parse_args(self) -> argparse.Namespace:
|
||||
return argparse.Namespace(
|
||||
command="workspace",
|
||||
workspace_command="disk",
|
||||
workspace_disk_command="list",
|
||||
workspace_id="workspace-123",
|
||||
path="/workspace",
|
||||
recursive=True,
|
||||
json=False,
|
||||
)
|
||||
|
||||
class ReadParser:
|
||||
def parse_args(self) -> argparse.Namespace:
|
||||
return argparse.Namespace(
|
||||
command="workspace",
|
||||
workspace_command="disk",
|
||||
workspace_disk_command="read",
|
||||
workspace_id="workspace-123",
|
||||
path="note.txt",
|
||||
max_bytes=4096,
|
||||
json=True,
|
||||
)
|
||||
|
||||
monkeypatch.setattr(cli, "Pyro", StubPyro)
|
||||
|
||||
monkeypatch.setattr(cli, "_build_parser", lambda: ExportParser())
|
||||
cli.main()
|
||||
export_output = capsys.readouterr().out
|
||||
assert "[workspace-disk-export] workspace_id=workspace-123" in export_output
|
||||
|
||||
monkeypatch.setattr(cli, "_build_parser", lambda: ListParser())
|
||||
cli.main()
|
||||
list_output = capsys.readouterr().out
|
||||
assert "Workspace disk path: /workspace" in list_output
|
||||
assert "/workspace/note.txt [file]" in list_output
|
||||
|
||||
monkeypatch.setattr(cli, "_build_parser", lambda: ReadParser())
|
||||
cli.main()
|
||||
read_payload = json.loads(capsys.readouterr().out)
|
||||
assert read_payload["path"] == "/workspace/note.txt"
|
||||
assert read_payload["content"] == "hello\n"
|
||||
|
||||
|
||||
def test_cli_workspace_diff_prints_human_output(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
capsys: pytest.CaptureFixture[str],
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue