Add workspace snapshots and full reset
Implement the 2.8.0 workspace milestone with named snapshots and full-sandbox reset across the CLI, Python SDK, and MCP server. Persist the immutable baseline plus named snapshot archives under each workspace, add workspace reset metadata, and make reset recreate the sandbox while clearing command history, shells, and services without changing the workspace identity or diff baseline. Refresh the 2.8.0 docs, roadmap, and Python example around reset-over-repair, then validate with uv lock, UV_CACHE_DIR=.uv-cache make check, UV_CACHE_DIR=.uv-cache make dist-check, and a real guest-backed create/snapshot/reset/diff smoke test outside the sandbox.
This commit is contained in:
parent
f504f0a331
commit
18b8fd2a7d
20 changed files with 1429 additions and 29 deletions
|
|
@ -52,6 +52,10 @@ def test_pyro_create_server_registers_vm_run(tmp_path: Path) -> None:
|
|||
assert "workspace_diff" in tool_names
|
||||
assert "workspace_sync_push" in tool_names
|
||||
assert "workspace_export" in tool_names
|
||||
assert "snapshot_create" in tool_names
|
||||
assert "snapshot_list" in tool_names
|
||||
assert "snapshot_delete" in tool_names
|
||||
assert "workspace_reset" in tool_names
|
||||
assert "shell_open" in tool_names
|
||||
assert "shell_read" in tool_names
|
||||
assert "shell_write" in tool_names
|
||||
|
|
@ -143,6 +147,8 @@ def test_pyro_workspace_methods_delegate_to_manager(tmp_path: Path) -> None:
|
|||
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)
|
||||
snapshot = pyro.create_snapshot(workspace_id, "checkpoint")
|
||||
snapshots = pyro.list_snapshots(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(
|
||||
|
|
@ -155,6 +161,8 @@ def test_pyro_workspace_methods_delegate_to_manager(tmp_path: Path) -> None:
|
|||
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")
|
||||
reset = pyro.reset_workspace(workspace_id, snapshot="checkpoint")
|
||||
deleted_snapshot = pyro.delete_snapshot(workspace_id, "checkpoint")
|
||||
status = pyro.status_workspace(workspace_id)
|
||||
logs = pyro.logs_workspace(workspace_id)
|
||||
deleted = pyro.delete_workspace(workspace_id)
|
||||
|
|
@ -163,6 +171,8 @@ def test_pyro_workspace_methods_delegate_to_manager(tmp_path: Path) -> None:
|
|||
assert created["workspace_seed"]["mode"] == "directory"
|
||||
assert synced["workspace_sync"]["destination"] == "/workspace/subdir"
|
||||
assert diff_payload["changed"] is True
|
||||
assert snapshot["snapshot"]["snapshot_name"] == "checkpoint"
|
||||
assert snapshots["count"] == 2
|
||||
assert exported["output_path"] == str(export_path)
|
||||
assert export_path.read_text(encoding="utf-8") == "ok\n"
|
||||
assert service["state"] == "running"
|
||||
|
|
@ -170,7 +180,9 @@ def test_pyro_workspace_methods_delegate_to_manager(tmp_path: Path) -> None:
|
|||
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 reset["workspace_reset"]["snapshot_name"] == "checkpoint"
|
||||
assert deleted_snapshot["deleted"] is True
|
||||
assert status["command_count"] == 0
|
||||
assert status["service_count"] == 0
|
||||
assert logs["count"] == 0
|
||||
assert deleted["deleted"] is True
|
||||
|
|
|
|||
|
|
@ -66,6 +66,8 @@ 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 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
|
||||
|
||||
workspace_create_help = _subparser_choice(
|
||||
|
|
@ -107,6 +109,34 @@ def test_cli_subcommand_help_includes_examples_and_guidance() -> None:
|
|||
assert "immutable workspace baseline" in workspace_diff_help
|
||||
assert "workspace export" in workspace_diff_help
|
||||
|
||||
workspace_snapshot_help = _subparser_choice(
|
||||
_subparser_choice(parser, "workspace"),
|
||||
"snapshot",
|
||||
).format_help()
|
||||
assert "pyro workspace snapshot create WORKSPACE_ID checkpoint" in workspace_snapshot_help
|
||||
assert "baseline" in workspace_snapshot_help
|
||||
|
||||
workspace_snapshot_create_help = _subparser_choice(
|
||||
_subparser_choice(_subparser_choice(parser, "workspace"), "snapshot"), "create"
|
||||
).format_help()
|
||||
assert "Capture the current `/workspace` tree" in workspace_snapshot_create_help
|
||||
|
||||
workspace_snapshot_list_help = _subparser_choice(
|
||||
_subparser_choice(_subparser_choice(parser, "workspace"), "snapshot"), "list"
|
||||
).format_help()
|
||||
assert "baseline snapshot plus any named snapshots" in workspace_snapshot_list_help
|
||||
|
||||
workspace_snapshot_delete_help = _subparser_choice(
|
||||
_subparser_choice(_subparser_choice(parser, "workspace"), "snapshot"), "delete"
|
||||
).format_help()
|
||||
assert "leaving the implicit baseline intact" in workspace_snapshot_delete_help
|
||||
|
||||
workspace_reset_help = _subparser_choice(
|
||||
_subparser_choice(parser, "workspace"), "reset"
|
||||
).format_help()
|
||||
assert "--snapshot" in workspace_reset_help
|
||||
assert "reset over repair" in workspace_reset_help
|
||||
|
||||
workspace_shell_help = _subparser_choice(
|
||||
_subparser_choice(parser, "workspace"),
|
||||
"shell",
|
||||
|
|
@ -651,6 +681,359 @@ def test_cli_workspace_diff_prints_human_output(
|
|||
assert "--- a/note.txt" in output
|
||||
|
||||
|
||||
def test_cli_workspace_snapshot_create_list_delete_and_reset_prints_json(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
capsys: pytest.CaptureFixture[str],
|
||||
) -> None:
|
||||
class StubPyro:
|
||||
def create_snapshot(self, workspace_id: str, snapshot_name: str) -> dict[str, Any]:
|
||||
assert workspace_id == "workspace-123"
|
||||
assert snapshot_name == "checkpoint"
|
||||
return {
|
||||
"workspace_id": workspace_id,
|
||||
"snapshot": {
|
||||
"snapshot_name": snapshot_name,
|
||||
"kind": "named",
|
||||
"entry_count": 3,
|
||||
"bytes_written": 42,
|
||||
},
|
||||
}
|
||||
|
||||
def list_snapshots(self, workspace_id: str) -> dict[str, Any]:
|
||||
assert workspace_id == "workspace-123"
|
||||
return {
|
||||
"workspace_id": workspace_id,
|
||||
"count": 2,
|
||||
"snapshots": [
|
||||
{
|
||||
"snapshot_name": "baseline",
|
||||
"kind": "baseline",
|
||||
"entry_count": 1,
|
||||
"bytes_written": 10,
|
||||
"deletable": False,
|
||||
},
|
||||
{
|
||||
"snapshot_name": "checkpoint",
|
||||
"kind": "named",
|
||||
"entry_count": 3,
|
||||
"bytes_written": 42,
|
||||
"deletable": True,
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
def reset_workspace(self, workspace_id: str, *, snapshot: str) -> dict[str, Any]:
|
||||
assert workspace_id == "workspace-123"
|
||||
assert snapshot == "checkpoint"
|
||||
return {
|
||||
"workspace_id": workspace_id,
|
||||
"state": "started",
|
||||
"workspace_path": "/workspace",
|
||||
"reset_count": 2,
|
||||
"workspace_reset": {
|
||||
"snapshot_name": snapshot,
|
||||
"kind": "named",
|
||||
"destination": "/workspace",
|
||||
"entry_count": 3,
|
||||
"bytes_written": 42,
|
||||
},
|
||||
}
|
||||
|
||||
def delete_snapshot(self, workspace_id: str, snapshot_name: str) -> dict[str, Any]:
|
||||
assert workspace_id == "workspace-123"
|
||||
assert snapshot_name == "checkpoint"
|
||||
return {
|
||||
"workspace_id": workspace_id,
|
||||
"snapshot_name": snapshot_name,
|
||||
"deleted": True,
|
||||
}
|
||||
|
||||
class SnapshotCreateParser:
|
||||
def parse_args(self) -> argparse.Namespace:
|
||||
return argparse.Namespace(
|
||||
command="workspace",
|
||||
workspace_command="snapshot",
|
||||
workspace_snapshot_command="create",
|
||||
workspace_id="workspace-123",
|
||||
snapshot_name="checkpoint",
|
||||
json=True,
|
||||
)
|
||||
|
||||
monkeypatch.setattr(cli, "_build_parser", lambda: SnapshotCreateParser())
|
||||
monkeypatch.setattr(cli, "Pyro", StubPyro)
|
||||
cli.main()
|
||||
created = json.loads(capsys.readouterr().out)
|
||||
assert created["snapshot"]["snapshot_name"] == "checkpoint"
|
||||
|
||||
class SnapshotListParser:
|
||||
def parse_args(self) -> argparse.Namespace:
|
||||
return argparse.Namespace(
|
||||
command="workspace",
|
||||
workspace_command="snapshot",
|
||||
workspace_snapshot_command="list",
|
||||
workspace_id="workspace-123",
|
||||
json=True,
|
||||
)
|
||||
|
||||
monkeypatch.setattr(cli, "_build_parser", lambda: SnapshotListParser())
|
||||
cli.main()
|
||||
listed = json.loads(capsys.readouterr().out)
|
||||
assert listed["count"] == 2
|
||||
|
||||
class ResetParser:
|
||||
def parse_args(self) -> argparse.Namespace:
|
||||
return argparse.Namespace(
|
||||
command="workspace",
|
||||
workspace_command="reset",
|
||||
workspace_id="workspace-123",
|
||||
snapshot="checkpoint",
|
||||
json=True,
|
||||
)
|
||||
|
||||
monkeypatch.setattr(cli, "_build_parser", lambda: ResetParser())
|
||||
cli.main()
|
||||
reset = json.loads(capsys.readouterr().out)
|
||||
assert reset["workspace_reset"]["snapshot_name"] == "checkpoint"
|
||||
|
||||
class SnapshotDeleteParser:
|
||||
def parse_args(self) -> argparse.Namespace:
|
||||
return argparse.Namespace(
|
||||
command="workspace",
|
||||
workspace_command="snapshot",
|
||||
workspace_snapshot_command="delete",
|
||||
workspace_id="workspace-123",
|
||||
snapshot_name="checkpoint",
|
||||
json=True,
|
||||
)
|
||||
|
||||
monkeypatch.setattr(cli, "_build_parser", lambda: SnapshotDeleteParser())
|
||||
cli.main()
|
||||
deleted = json.loads(capsys.readouterr().out)
|
||||
assert deleted["deleted"] is True
|
||||
|
||||
|
||||
def test_cli_workspace_reset_prints_human_output(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
capsys: pytest.CaptureFixture[str],
|
||||
) -> None:
|
||||
class StubPyro:
|
||||
def reset_workspace(self, workspace_id: str, *, snapshot: str) -> dict[str, Any]:
|
||||
assert workspace_id == "workspace-123"
|
||||
assert snapshot == "baseline"
|
||||
return {
|
||||
"workspace_id": workspace_id,
|
||||
"state": "started",
|
||||
"environment": "debian:12",
|
||||
"workspace_path": "/workspace",
|
||||
"workspace_seed": {
|
||||
"mode": "directory",
|
||||
"seed_path": "/tmp/repo",
|
||||
"destination": "/workspace",
|
||||
"entry_count": 1,
|
||||
"bytes_written": 4,
|
||||
},
|
||||
"execution_mode": "guest_vsock",
|
||||
"command_count": 0,
|
||||
"service_count": 0,
|
||||
"running_service_count": 0,
|
||||
"reset_count": 3,
|
||||
"last_reset_at": 123.0,
|
||||
"workspace_reset": {
|
||||
"snapshot_name": "baseline",
|
||||
"kind": "baseline",
|
||||
"destination": "/workspace",
|
||||
"entry_count": 1,
|
||||
"bytes_written": 4,
|
||||
},
|
||||
}
|
||||
|
||||
class StubParser:
|
||||
def parse_args(self) -> argparse.Namespace:
|
||||
return argparse.Namespace(
|
||||
command="workspace",
|
||||
workspace_command="reset",
|
||||
workspace_id="workspace-123",
|
||||
snapshot="baseline",
|
||||
json=False,
|
||||
)
|
||||
|
||||
monkeypatch.setattr(cli, "_build_parser", lambda: StubParser())
|
||||
monkeypatch.setattr(cli, "Pyro", StubPyro)
|
||||
cli.main()
|
||||
output = capsys.readouterr().out
|
||||
assert "Reset source: baseline (baseline)" in output
|
||||
assert "Reset count: 3" in output
|
||||
|
||||
|
||||
def test_cli_workspace_snapshot_prints_human_output(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
capsys: pytest.CaptureFixture[str],
|
||||
) -> None:
|
||||
class StubPyro:
|
||||
def create_snapshot(self, workspace_id: str, snapshot_name: str) -> dict[str, Any]:
|
||||
assert workspace_id == "workspace-123"
|
||||
assert snapshot_name == "checkpoint"
|
||||
return {
|
||||
"workspace_id": workspace_id,
|
||||
"snapshot": {
|
||||
"snapshot_name": snapshot_name,
|
||||
"kind": "named",
|
||||
"entry_count": 3,
|
||||
"bytes_written": 42,
|
||||
},
|
||||
}
|
||||
|
||||
def list_snapshots(self, workspace_id: str) -> dict[str, Any]:
|
||||
assert workspace_id == "workspace-123"
|
||||
return {
|
||||
"workspace_id": workspace_id,
|
||||
"count": 2,
|
||||
"snapshots": [
|
||||
{
|
||||
"snapshot_name": "baseline",
|
||||
"kind": "baseline",
|
||||
"entry_count": 1,
|
||||
"bytes_written": 10,
|
||||
"deletable": False,
|
||||
},
|
||||
{
|
||||
"snapshot_name": "checkpoint",
|
||||
"kind": "named",
|
||||
"entry_count": 3,
|
||||
"bytes_written": 42,
|
||||
"deletable": True,
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
def delete_snapshot(self, workspace_id: str, snapshot_name: str) -> dict[str, Any]:
|
||||
assert workspace_id == "workspace-123"
|
||||
assert snapshot_name == "checkpoint"
|
||||
return {
|
||||
"workspace_id": workspace_id,
|
||||
"snapshot_name": snapshot_name,
|
||||
"deleted": True,
|
||||
}
|
||||
|
||||
class SnapshotCreateParser:
|
||||
def parse_args(self) -> argparse.Namespace:
|
||||
return argparse.Namespace(
|
||||
command="workspace",
|
||||
workspace_command="snapshot",
|
||||
workspace_snapshot_command="create",
|
||||
workspace_id="workspace-123",
|
||||
snapshot_name="checkpoint",
|
||||
json=False,
|
||||
)
|
||||
|
||||
monkeypatch.setattr(cli, "_build_parser", lambda: SnapshotCreateParser())
|
||||
monkeypatch.setattr(cli, "Pyro", StubPyro)
|
||||
cli.main()
|
||||
create_output = capsys.readouterr().out
|
||||
assert "[workspace-snapshot-create] workspace_id=workspace-123" in create_output
|
||||
assert "snapshot_name=checkpoint kind=named" in create_output
|
||||
|
||||
class SnapshotListParser:
|
||||
def parse_args(self) -> argparse.Namespace:
|
||||
return argparse.Namespace(
|
||||
command="workspace",
|
||||
workspace_command="snapshot",
|
||||
workspace_snapshot_command="list",
|
||||
workspace_id="workspace-123",
|
||||
json=False,
|
||||
)
|
||||
|
||||
monkeypatch.setattr(cli, "_build_parser", lambda: SnapshotListParser())
|
||||
cli.main()
|
||||
list_output = capsys.readouterr().out
|
||||
assert "baseline [baseline]" in list_output
|
||||
assert "checkpoint [named]" in list_output
|
||||
|
||||
class SnapshotDeleteParser:
|
||||
def parse_args(self) -> argparse.Namespace:
|
||||
return argparse.Namespace(
|
||||
command="workspace",
|
||||
workspace_command="snapshot",
|
||||
workspace_snapshot_command="delete",
|
||||
workspace_id="workspace-123",
|
||||
snapshot_name="checkpoint",
|
||||
json=False,
|
||||
)
|
||||
|
||||
monkeypatch.setattr(cli, "_build_parser", lambda: SnapshotDeleteParser())
|
||||
cli.main()
|
||||
delete_output = capsys.readouterr().out
|
||||
assert "Deleted workspace snapshot: checkpoint" in delete_output
|
||||
|
||||
|
||||
def test_cli_workspace_snapshot_error_paths(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
capsys: pytest.CaptureFixture[str],
|
||||
) -> None:
|
||||
class StubPyro:
|
||||
def create_snapshot(self, workspace_id: str, snapshot_name: str) -> dict[str, Any]:
|
||||
del workspace_id, snapshot_name
|
||||
raise RuntimeError("create boom")
|
||||
|
||||
def list_snapshots(self, workspace_id: str) -> dict[str, Any]:
|
||||
del workspace_id
|
||||
raise RuntimeError("list boom")
|
||||
|
||||
def delete_snapshot(self, workspace_id: str, snapshot_name: str) -> dict[str, Any]:
|
||||
del workspace_id, snapshot_name
|
||||
raise RuntimeError("delete boom")
|
||||
|
||||
def _run(args: argparse.Namespace) -> tuple[str, str]:
|
||||
class StubParser:
|
||||
def parse_args(self) -> argparse.Namespace:
|
||||
return args
|
||||
|
||||
monkeypatch.setattr(cli, "_build_parser", lambda: StubParser())
|
||||
monkeypatch.setattr(cli, "Pyro", StubPyro)
|
||||
with pytest.raises(SystemExit, match="1"):
|
||||
cli.main()
|
||||
captured = capsys.readouterr()
|
||||
return captured.out, captured.err
|
||||
|
||||
out, err = _run(
|
||||
argparse.Namespace(
|
||||
command="workspace",
|
||||
workspace_command="snapshot",
|
||||
workspace_snapshot_command="create",
|
||||
workspace_id="workspace-123",
|
||||
snapshot_name="checkpoint",
|
||||
json=True,
|
||||
)
|
||||
)
|
||||
assert json.loads(out)["error"] == "create boom"
|
||||
assert err == ""
|
||||
|
||||
out, err = _run(
|
||||
argparse.Namespace(
|
||||
command="workspace",
|
||||
workspace_command="snapshot",
|
||||
workspace_snapshot_command="list",
|
||||
workspace_id="workspace-123",
|
||||
json=False,
|
||||
)
|
||||
)
|
||||
assert out == ""
|
||||
assert "[error] list boom" in err
|
||||
|
||||
out, err = _run(
|
||||
argparse.Namespace(
|
||||
command="workspace",
|
||||
workspace_command="snapshot",
|
||||
workspace_snapshot_command="delete",
|
||||
workspace_id="workspace-123",
|
||||
snapshot_name="checkpoint",
|
||||
json=False,
|
||||
)
|
||||
)
|
||||
assert out == ""
|
||||
assert "[error] delete boom" in err
|
||||
|
||||
|
||||
def test_cli_workspace_sync_push_prints_json(
|
||||
monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str]
|
||||
) -> None:
|
||||
|
|
|
|||
|
|
@ -20,6 +20,7 @@ from pyro_mcp.contract import (
|
|||
PUBLIC_CLI_WORKSPACE_CREATE_FLAGS,
|
||||
PUBLIC_CLI_WORKSPACE_DIFF_FLAGS,
|
||||
PUBLIC_CLI_WORKSPACE_EXPORT_FLAGS,
|
||||
PUBLIC_CLI_WORKSPACE_RESET_FLAGS,
|
||||
PUBLIC_CLI_WORKSPACE_SERVICE_LIST_FLAGS,
|
||||
PUBLIC_CLI_WORKSPACE_SERVICE_LOGS_FLAGS,
|
||||
PUBLIC_CLI_WORKSPACE_SERVICE_START_FLAGS,
|
||||
|
|
@ -32,6 +33,10 @@ from pyro_mcp.contract import (
|
|||
PUBLIC_CLI_WORKSPACE_SHELL_SIGNAL_FLAGS,
|
||||
PUBLIC_CLI_WORKSPACE_SHELL_SUBCOMMANDS,
|
||||
PUBLIC_CLI_WORKSPACE_SHELL_WRITE_FLAGS,
|
||||
PUBLIC_CLI_WORKSPACE_SNAPSHOT_CREATE_FLAGS,
|
||||
PUBLIC_CLI_WORKSPACE_SNAPSHOT_DELETE_FLAGS,
|
||||
PUBLIC_CLI_WORKSPACE_SNAPSHOT_LIST_FLAGS,
|
||||
PUBLIC_CLI_WORKSPACE_SNAPSHOT_SUBCOMMANDS,
|
||||
PUBLIC_CLI_WORKSPACE_SUBCOMMANDS,
|
||||
PUBLIC_CLI_WORKSPACE_SYNC_PUSH_FLAGS,
|
||||
PUBLIC_CLI_WORKSPACE_SYNC_SUBCOMMANDS,
|
||||
|
|
@ -110,6 +115,35 @@ def test_public_cli_help_lists_commands_and_run_flags() -> None:
|
|||
).format_help()
|
||||
for flag in PUBLIC_CLI_WORKSPACE_DIFF_FLAGS:
|
||||
assert flag in workspace_diff_help_text
|
||||
workspace_snapshot_help_text = _subparser_choice(
|
||||
_subparser_choice(parser, "workspace"),
|
||||
"snapshot",
|
||||
).format_help()
|
||||
for subcommand_name in PUBLIC_CLI_WORKSPACE_SNAPSHOT_SUBCOMMANDS:
|
||||
assert subcommand_name in workspace_snapshot_help_text
|
||||
workspace_snapshot_create_help_text = _subparser_choice(
|
||||
_subparser_choice(_subparser_choice(parser, "workspace"), "snapshot"),
|
||||
"create",
|
||||
).format_help()
|
||||
for flag in PUBLIC_CLI_WORKSPACE_SNAPSHOT_CREATE_FLAGS:
|
||||
assert flag in workspace_snapshot_create_help_text
|
||||
workspace_snapshot_list_help_text = _subparser_choice(
|
||||
_subparser_choice(_subparser_choice(parser, "workspace"), "snapshot"),
|
||||
"list",
|
||||
).format_help()
|
||||
for flag in PUBLIC_CLI_WORKSPACE_SNAPSHOT_LIST_FLAGS:
|
||||
assert flag in workspace_snapshot_list_help_text
|
||||
workspace_snapshot_delete_help_text = _subparser_choice(
|
||||
_subparser_choice(_subparser_choice(parser, "workspace"), "snapshot"),
|
||||
"delete",
|
||||
).format_help()
|
||||
for flag in PUBLIC_CLI_WORKSPACE_SNAPSHOT_DELETE_FLAGS:
|
||||
assert flag in workspace_snapshot_delete_help_text
|
||||
workspace_reset_help_text = _subparser_choice(
|
||||
_subparser_choice(parser, "workspace"), "reset"
|
||||
).format_help()
|
||||
for flag in PUBLIC_CLI_WORKSPACE_RESET_FLAGS:
|
||||
assert flag in workspace_reset_help_text
|
||||
workspace_shell_help_text = _subparser_choice(
|
||||
_subparser_choice(parser, "workspace"),
|
||||
"shell",
|
||||
|
|
|
|||
|
|
@ -46,6 +46,10 @@ def test_create_server_registers_vm_tools(tmp_path: Path) -> None:
|
|||
assert "service_status" in tool_names
|
||||
assert "service_logs" in tool_names
|
||||
assert "service_stop" in tool_names
|
||||
assert "snapshot_create" in tool_names
|
||||
assert "snapshot_delete" in tool_names
|
||||
assert "snapshot_list" in tool_names
|
||||
assert "workspace_reset" in tool_names
|
||||
|
||||
|
||||
def test_vm_run_round_trip(tmp_path: Path) -> None:
|
||||
|
|
@ -234,6 +238,15 @@ def test_workspace_tools_round_trip(tmp_path: Path) -> None:
|
|||
diffed = _extract_structured(
|
||||
await server.call_tool("workspace_diff", {"workspace_id": workspace_id})
|
||||
)
|
||||
snapshot = _extract_structured(
|
||||
await server.call_tool(
|
||||
"snapshot_create",
|
||||
{"workspace_id": workspace_id, "snapshot_name": "checkpoint"},
|
||||
)
|
||||
)
|
||||
snapshots = _extract_structured(
|
||||
await server.call_tool("snapshot_list", {"workspace_id": workspace_id})
|
||||
)
|
||||
export_path = tmp_path / "exported-more.txt"
|
||||
exported = _extract_structured(
|
||||
await server.call_tool(
|
||||
|
|
@ -287,6 +300,18 @@ def test_workspace_tools_round_trip(tmp_path: Path) -> None:
|
|||
},
|
||||
)
|
||||
)
|
||||
reset = _extract_structured(
|
||||
await server.call_tool(
|
||||
"workspace_reset",
|
||||
{"workspace_id": workspace_id, "snapshot": "checkpoint"},
|
||||
)
|
||||
)
|
||||
deleted_snapshot = _extract_structured(
|
||||
await server.call_tool(
|
||||
"snapshot_delete",
|
||||
{"workspace_id": workspace_id, "snapshot_name": "checkpoint"},
|
||||
)
|
||||
)
|
||||
logs = _extract_structured(
|
||||
await server.call_tool("workspace_logs", {"workspace_id": workspace_id})
|
||||
)
|
||||
|
|
@ -298,12 +323,16 @@ def test_workspace_tools_round_trip(tmp_path: Path) -> None:
|
|||
synced,
|
||||
executed,
|
||||
diffed,
|
||||
snapshot,
|
||||
snapshots,
|
||||
exported,
|
||||
service,
|
||||
services,
|
||||
service_status,
|
||||
service_logs,
|
||||
service_stopped,
|
||||
reset,
|
||||
deleted_snapshot,
|
||||
logs,
|
||||
deleted,
|
||||
)
|
||||
|
|
@ -313,12 +342,16 @@ def test_workspace_tools_round_trip(tmp_path: Path) -> None:
|
|||
synced,
|
||||
executed,
|
||||
diffed,
|
||||
snapshot,
|
||||
snapshots,
|
||||
exported,
|
||||
service,
|
||||
services,
|
||||
service_status,
|
||||
service_logs,
|
||||
service_stopped,
|
||||
reset,
|
||||
deleted_snapshot,
|
||||
logs,
|
||||
deleted,
|
||||
) = asyncio.run(_run())
|
||||
|
|
@ -327,6 +360,11 @@ def test_workspace_tools_round_trip(tmp_path: Path) -> None:
|
|||
assert synced["workspace_sync"]["destination"] == "/workspace/subdir"
|
||||
assert executed["stdout"] == "more\n"
|
||||
assert diffed["changed"] is True
|
||||
assert snapshot["snapshot"]["snapshot_name"] == "checkpoint"
|
||||
assert [entry["snapshot_name"] for entry in snapshots["snapshots"]] == [
|
||||
"baseline",
|
||||
"checkpoint",
|
||||
]
|
||||
assert exported["artifact_type"] == "file"
|
||||
assert Path(str(exported["output_path"])).read_text(encoding="utf-8") == "more\n"
|
||||
assert service["state"] == "running"
|
||||
|
|
@ -334,5 +372,9 @@ def test_workspace_tools_round_trip(tmp_path: Path) -> None:
|
|||
assert service_status["state"] == "running"
|
||||
assert service_logs["tail_lines"] is None
|
||||
assert service_stopped["state"] == "stopped"
|
||||
assert logs["count"] == 1
|
||||
assert reset["workspace_reset"]["snapshot_name"] == "checkpoint"
|
||||
assert reset["command_count"] == 0
|
||||
assert reset["service_count"] == 0
|
||||
assert deleted_snapshot["deleted"] is True
|
||||
assert logs["count"] == 0
|
||||
assert deleted["deleted"] is True
|
||||
|
|
|
|||
|
|
@ -545,10 +545,212 @@ def test_workspace_diff_requires_create_time_baseline(tmp_path: Path) -> None:
|
|||
baseline_path = tmp_path / "vms" / "workspaces" / workspace_id / "baseline" / "workspace.tar"
|
||||
baseline_path.unlink()
|
||||
|
||||
with pytest.raises(RuntimeError, match="requires a baseline snapshot"):
|
||||
with pytest.raises(RuntimeError, match="require[s]? a baseline snapshot"):
|
||||
manager.diff_workspace(workspace_id)
|
||||
|
||||
|
||||
def test_workspace_snapshots_and_reset_round_trip(tmp_path: Path) -> None:
|
||||
seed_dir = tmp_path / "seed"
|
||||
seed_dir.mkdir()
|
||||
(seed_dir / "note.txt").write_text("seed\n", encoding="utf-8")
|
||||
|
||||
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,
|
||||
seed_path=seed_dir,
|
||||
)["workspace_id"]
|
||||
)
|
||||
|
||||
manager.exec_workspace(
|
||||
workspace_id,
|
||||
command="printf 'checkpoint\\n' > note.txt",
|
||||
timeout_seconds=30,
|
||||
)
|
||||
created_snapshot = manager.create_snapshot(workspace_id, "checkpoint")
|
||||
assert created_snapshot["snapshot"]["snapshot_name"] == "checkpoint"
|
||||
|
||||
listed = manager.list_snapshots(workspace_id)
|
||||
assert listed["count"] == 2
|
||||
assert [snapshot["snapshot_name"] for snapshot in listed["snapshots"]] == [
|
||||
"baseline",
|
||||
"checkpoint",
|
||||
]
|
||||
|
||||
manager.exec_workspace(
|
||||
workspace_id,
|
||||
command="printf 'after\\n' > note.txt",
|
||||
timeout_seconds=30,
|
||||
)
|
||||
manager.start_service(
|
||||
workspace_id,
|
||||
"app",
|
||||
command="sh -lc 'touch .ready; while true; do sleep 60; done'",
|
||||
readiness={"type": "file", "path": ".ready"},
|
||||
)
|
||||
|
||||
reset_to_snapshot = manager.reset_workspace(workspace_id, snapshot="checkpoint")
|
||||
assert reset_to_snapshot["workspace_reset"]["snapshot_name"] == "checkpoint"
|
||||
assert reset_to_snapshot["reset_count"] == 1
|
||||
assert reset_to_snapshot["last_command"] is None
|
||||
assert reset_to_snapshot["command_count"] == 0
|
||||
assert reset_to_snapshot["service_count"] == 0
|
||||
assert reset_to_snapshot["running_service_count"] == 0
|
||||
|
||||
checkpoint_result = manager.exec_workspace(
|
||||
workspace_id,
|
||||
command="cat note.txt",
|
||||
timeout_seconds=30,
|
||||
)
|
||||
assert checkpoint_result["stdout"] == "checkpoint\n"
|
||||
logs_after_snapshot_reset = manager.logs_workspace(workspace_id)
|
||||
assert logs_after_snapshot_reset["count"] == 1
|
||||
|
||||
reset_to_baseline = manager.reset_workspace(workspace_id)
|
||||
assert reset_to_baseline["workspace_reset"]["snapshot_name"] == "baseline"
|
||||
assert reset_to_baseline["reset_count"] == 2
|
||||
assert reset_to_baseline["command_count"] == 0
|
||||
assert reset_to_baseline["service_count"] == 0
|
||||
assert manager.logs_workspace(workspace_id)["count"] == 0
|
||||
|
||||
baseline_result = manager.exec_workspace(
|
||||
workspace_id,
|
||||
command="cat note.txt",
|
||||
timeout_seconds=30,
|
||||
)
|
||||
assert baseline_result["stdout"] == "seed\n"
|
||||
diff_payload = manager.diff_workspace(workspace_id)
|
||||
assert diff_payload["changed"] is False
|
||||
|
||||
deleted_snapshot = manager.delete_snapshot(workspace_id, "checkpoint")
|
||||
assert deleted_snapshot["deleted"] is True
|
||||
listed_after_delete = manager.list_snapshots(workspace_id)
|
||||
assert [snapshot["snapshot_name"] for snapshot in listed_after_delete["snapshots"]] == [
|
||||
"baseline"
|
||||
]
|
||||
|
||||
|
||||
def test_workspace_snapshot_and_reset_require_baseline(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"]
|
||||
)
|
||||
baseline_path = tmp_path / "vms" / "workspaces" / workspace_id / "baseline" / "workspace.tar"
|
||||
baseline_path.unlink()
|
||||
|
||||
with pytest.raises(RuntimeError, match="require[s]? a baseline snapshot"):
|
||||
manager.list_snapshots(workspace_id)
|
||||
with pytest.raises(RuntimeError, match="require[s]? a baseline snapshot"):
|
||||
manager.create_snapshot(workspace_id, "checkpoint")
|
||||
with pytest.raises(RuntimeError, match="require[s]? a baseline snapshot"):
|
||||
manager.delete_snapshot(workspace_id, "checkpoint")
|
||||
with pytest.raises(RuntimeError, match="require[s]? a baseline snapshot"):
|
||||
manager.reset_workspace(workspace_id)
|
||||
|
||||
|
||||
def test_workspace_delete_baseline_snapshot_is_rejected(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"]
|
||||
)
|
||||
|
||||
with pytest.raises(ValueError, match="cannot delete the baseline snapshot"):
|
||||
manager.delete_snapshot(workspace_id, "baseline")
|
||||
|
||||
|
||||
def test_workspace_reset_recreates_stopped_workspace(tmp_path: Path) -> None:
|
||||
seed_dir = tmp_path / "seed"
|
||||
seed_dir.mkdir()
|
||||
(seed_dir / "note.txt").write_text("seed\n", encoding="utf-8")
|
||||
|
||||
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,
|
||||
seed_path=seed_dir,
|
||||
)["workspace_id"]
|
||||
)
|
||||
|
||||
with manager._lock: # noqa: SLF001
|
||||
workspace = manager._load_workspace_locked(workspace_id) # noqa: SLF001
|
||||
workspace.state = "stopped"
|
||||
workspace.firecracker_pid = None
|
||||
manager._save_workspace_locked(workspace) # noqa: SLF001
|
||||
|
||||
reset_payload = manager.reset_workspace(workspace_id)
|
||||
|
||||
assert reset_payload["state"] == "started"
|
||||
assert reset_payload["workspace_reset"]["snapshot_name"] == "baseline"
|
||||
result = manager.exec_workspace(workspace_id, command="cat note.txt", timeout_seconds=30)
|
||||
assert result["stdout"] == "seed\n"
|
||||
|
||||
|
||||
def test_workspace_reset_failure_leaves_workspace_stopped(tmp_path: Path) -> None:
|
||||
seed_dir = tmp_path / "seed"
|
||||
seed_dir.mkdir()
|
||||
(seed_dir / "note.txt").write_text("seed\n", encoding="utf-8")
|
||||
|
||||
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,
|
||||
seed_path=seed_dir,
|
||||
)["workspace_id"]
|
||||
)
|
||||
manager.create_snapshot(workspace_id, "checkpoint")
|
||||
|
||||
def _failing_import_archive(*args: Any, **kwargs: Any) -> dict[str, Any]:
|
||||
del args, kwargs
|
||||
raise RuntimeError("boom")
|
||||
|
||||
manager._backend.import_archive = _failing_import_archive # type: ignore[method-assign] # noqa: SLF001
|
||||
|
||||
with pytest.raises(RuntimeError, match="boom"):
|
||||
manager.reset_workspace(workspace_id, snapshot="checkpoint")
|
||||
|
||||
with manager._lock: # noqa: SLF001
|
||||
workspace = manager._load_workspace_locked(workspace_id) # noqa: SLF001
|
||||
assert workspace.state == "stopped"
|
||||
assert workspace.firecracker_pid is None
|
||||
assert workspace.reset_count == 0
|
||||
|
||||
listed = manager.list_snapshots(workspace_id)
|
||||
assert [snapshot["snapshot_name"] for snapshot in listed["snapshots"]] == [
|
||||
"baseline",
|
||||
"checkpoint",
|
||||
]
|
||||
|
||||
|
||||
def test_workspace_export_helpers_preserve_directory_symlinks(tmp_path: Path) -> None:
|
||||
workspace_dir = tmp_path / "workspace"
|
||||
workspace_dir.mkdir()
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue