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.
258 lines
8.5 KiB
Python
258 lines
8.5 KiB
Python
from __future__ import annotations
|
|
|
|
import subprocess
|
|
from pathlib import Path
|
|
from types import SimpleNamespace
|
|
from typing import Any, cast
|
|
|
|
import pytest
|
|
|
|
import pyro_mcp.workspace_disk as workspace_disk_module
|
|
from pyro_mcp.workspace_disk import (
|
|
_artifact_type_from_mode,
|
|
_debugfs_ls_entries,
|
|
_debugfs_stat,
|
|
_run_debugfs,
|
|
export_workspace_disk_image,
|
|
list_workspace_disk,
|
|
read_workspace_disk_file,
|
|
scrub_workspace_runtime_paths,
|
|
)
|
|
|
|
|
|
def _run_debugfs_write(rootfs_image: Path, command: str) -> None:
|
|
proc = subprocess.run( # noqa: S603
|
|
["debugfs", "-w", "-R", command, str(rootfs_image)],
|
|
text=True,
|
|
capture_output=True,
|
|
check=False,
|
|
)
|
|
if proc.returncode != 0:
|
|
message = proc.stderr.strip() or proc.stdout.strip() or command
|
|
raise RuntimeError(message)
|
|
|
|
|
|
def _create_rootfs_image(tmp_path: Path) -> Path:
|
|
rootfs_image = tmp_path / "workspace-rootfs.ext4"
|
|
with rootfs_image.open("wb") as handle:
|
|
handle.truncate(16 * 1024 * 1024)
|
|
proc = subprocess.run( # noqa: S603
|
|
["mkfs.ext4", "-F", str(rootfs_image)],
|
|
text=True,
|
|
capture_output=True,
|
|
check=False,
|
|
)
|
|
if proc.returncode != 0:
|
|
message = proc.stderr.strip() or proc.stdout.strip() or "mkfs.ext4 failed"
|
|
raise RuntimeError(message)
|
|
for directory in (
|
|
"/workspace",
|
|
"/workspace/src",
|
|
"/run",
|
|
"/run/pyro-secrets",
|
|
"/run/pyro-services",
|
|
):
|
|
_run_debugfs_write(rootfs_image, f"mkdir {directory}")
|
|
note_path = tmp_path / "note.txt"
|
|
note_path.write_text("hello from disk\n", encoding="utf-8")
|
|
child_path = tmp_path / "child.txt"
|
|
child_path.write_text("nested child\n", encoding="utf-8")
|
|
secret_path = tmp_path / "secret.txt"
|
|
secret_path.write_text("super-secret\n", encoding="utf-8")
|
|
service_path = tmp_path / "service.log"
|
|
service_path.write_text("service runtime\n", encoding="utf-8")
|
|
_run_debugfs_write(rootfs_image, f"write {note_path} /workspace/note.txt")
|
|
_run_debugfs_write(rootfs_image, f"write {child_path} /workspace/src/child.txt")
|
|
_run_debugfs_write(rootfs_image, "symlink /workspace/link note.txt")
|
|
_run_debugfs_write(rootfs_image, f"write {secret_path} /run/pyro-secrets/TOKEN")
|
|
_run_debugfs_write(rootfs_image, f"write {service_path} /run/pyro-services/app.log")
|
|
return rootfs_image
|
|
|
|
|
|
def test_workspace_disk_list_read_export_and_scrub(tmp_path: Path) -> None:
|
|
rootfs_image = _create_rootfs_image(tmp_path)
|
|
|
|
listing = list_workspace_disk(rootfs_image, guest_path="/workspace", recursive=True)
|
|
assert listing == [
|
|
{
|
|
"path": "/workspace/link",
|
|
"artifact_type": "symlink",
|
|
"size_bytes": 8,
|
|
"link_target": "note.txt",
|
|
},
|
|
{
|
|
"path": "/workspace/note.txt",
|
|
"artifact_type": "file",
|
|
"size_bytes": 16,
|
|
"link_target": None,
|
|
},
|
|
{
|
|
"path": "/workspace/src",
|
|
"artifact_type": "directory",
|
|
"size_bytes": 0,
|
|
"link_target": None,
|
|
},
|
|
{
|
|
"path": "/workspace/src/child.txt",
|
|
"artifact_type": "file",
|
|
"size_bytes": 13,
|
|
"link_target": None,
|
|
},
|
|
]
|
|
|
|
single = list_workspace_disk(rootfs_image, guest_path="/workspace/note.txt", recursive=False)
|
|
assert single == [
|
|
{
|
|
"path": "/workspace/note.txt",
|
|
"artifact_type": "file",
|
|
"size_bytes": 16,
|
|
"link_target": None,
|
|
}
|
|
]
|
|
|
|
read_payload = read_workspace_disk_file(
|
|
rootfs_image,
|
|
guest_path="/workspace/note.txt",
|
|
max_bytes=5,
|
|
)
|
|
assert read_payload == {
|
|
"path": "/workspace/note.txt",
|
|
"size_bytes": 16,
|
|
"max_bytes": 5,
|
|
"content": "hello",
|
|
"truncated": True,
|
|
}
|
|
|
|
output_path = tmp_path / "workspace.ext4"
|
|
exported = export_workspace_disk_image(rootfs_image, output_path=output_path)
|
|
assert exported["output_path"] == str(output_path)
|
|
assert exported["disk_format"] == "ext4"
|
|
assert int(exported["bytes_written"]) == output_path.stat().st_size
|
|
|
|
scrub_workspace_runtime_paths(rootfs_image)
|
|
run_listing = list_workspace_disk(rootfs_image, guest_path="/run", recursive=True)
|
|
assert run_listing == []
|
|
|
|
|
|
def test_workspace_disk_rejects_invalid_inputs(tmp_path: Path) -> None:
|
|
rootfs_image = _create_rootfs_image(tmp_path)
|
|
|
|
with pytest.raises(RuntimeError, match="workspace disk path does not exist"):
|
|
list_workspace_disk(rootfs_image, guest_path="/missing", recursive=False)
|
|
|
|
with pytest.raises(RuntimeError, match="workspace disk path does not exist"):
|
|
read_workspace_disk_file(
|
|
rootfs_image,
|
|
guest_path="/missing.txt",
|
|
max_bytes=4096,
|
|
)
|
|
|
|
with pytest.raises(RuntimeError, match="regular files"):
|
|
read_workspace_disk_file(
|
|
rootfs_image,
|
|
guest_path="/workspace/src",
|
|
max_bytes=4096,
|
|
)
|
|
|
|
with pytest.raises(ValueError, match="max_bytes must be positive"):
|
|
read_workspace_disk_file(
|
|
rootfs_image,
|
|
guest_path="/workspace/note.txt",
|
|
max_bytes=0,
|
|
)
|
|
|
|
output_path = tmp_path / "existing.ext4"
|
|
output_path.write_text("present\n", encoding="utf-8")
|
|
with pytest.raises(RuntimeError, match="output_path already exists"):
|
|
export_workspace_disk_image(rootfs_image, output_path=output_path)
|
|
|
|
|
|
def test_workspace_disk_internal_error_paths(
|
|
tmp_path: Path,
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
rootfs_image = tmp_path / "dummy.ext4"
|
|
rootfs_image.write_bytes(b"rootfs")
|
|
|
|
monkeypatch.setattr(cast(Any, workspace_disk_module).shutil, "which", lambda _name: None)
|
|
with pytest.raises(RuntimeError, match="debugfs is required"):
|
|
_run_debugfs(rootfs_image, "stat /workspace")
|
|
|
|
monkeypatch.setattr(
|
|
cast(Any, workspace_disk_module).shutil,
|
|
"which",
|
|
lambda _name: "/usr/bin/debugfs",
|
|
)
|
|
monkeypatch.setattr(
|
|
cast(Any, workspace_disk_module).subprocess,
|
|
"run",
|
|
lambda *args, **kwargs: SimpleNamespace( # noqa: ARG005
|
|
returncode=1,
|
|
stdout="",
|
|
stderr="",
|
|
),
|
|
)
|
|
with pytest.raises(RuntimeError, match="debugfs command failed: stat /workspace"):
|
|
_run_debugfs(rootfs_image, "stat /workspace")
|
|
|
|
assert _artifact_type_from_mode("00000") is None
|
|
|
|
monkeypatch.setattr(workspace_disk_module, "_run_debugfs", lambda *_args, **_kwargs: "noise")
|
|
with pytest.raises(RuntimeError, match="failed to inspect workspace disk path"):
|
|
_debugfs_stat(rootfs_image, "/workspace/bad")
|
|
|
|
monkeypatch.setattr(
|
|
workspace_disk_module,
|
|
"_run_debugfs",
|
|
lambda *_args, **_kwargs: "Type: fifo\nSize: 1\n",
|
|
)
|
|
with pytest.raises(RuntimeError, match="unsupported workspace disk path type"):
|
|
_debugfs_stat(rootfs_image, "/workspace/fifo")
|
|
|
|
monkeypatch.setattr(
|
|
workspace_disk_module,
|
|
"_run_debugfs",
|
|
lambda *_args, **_kwargs: "File not found by ext2_lookup",
|
|
)
|
|
with pytest.raises(RuntimeError, match="workspace disk path does not exist"):
|
|
_debugfs_ls_entries(rootfs_image, "/workspace/missing")
|
|
|
|
monkeypatch.setattr(
|
|
workspace_disk_module,
|
|
"_debugfs_stat",
|
|
lambda *_args, **_kwargs: workspace_disk_module._DebugfsStat( # noqa: SLF001
|
|
path="/workspace",
|
|
artifact_type="directory",
|
|
size_bytes=0,
|
|
),
|
|
)
|
|
monkeypatch.setattr(
|
|
workspace_disk_module,
|
|
"_debugfs_ls_entries",
|
|
lambda *_args, **_kwargs: [
|
|
workspace_disk_module._DebugfsDirEntry( # noqa: SLF001
|
|
name="special",
|
|
path="/workspace/special",
|
|
artifact_type=None,
|
|
size_bytes=0,
|
|
)
|
|
],
|
|
)
|
|
assert list_workspace_disk(rootfs_image, guest_path="/workspace", recursive=True) == []
|
|
|
|
monkeypatch.setattr(
|
|
workspace_disk_module,
|
|
"_debugfs_stat",
|
|
lambda *_args, **_kwargs: workspace_disk_module._DebugfsStat( # noqa: SLF001
|
|
path="/workspace/note.txt",
|
|
artifact_type="file",
|
|
size_bytes=12,
|
|
),
|
|
)
|
|
monkeypatch.setattr(workspace_disk_module, "_run_debugfs", lambda *_args, **_kwargs: "")
|
|
with pytest.raises(RuntimeError, match="failed to dump workspace disk file"):
|
|
read_workspace_disk_file(
|
|
rootfs_image,
|
|
guest_path="/workspace/note.txt",
|
|
max_bytes=16,
|
|
)
|