pyro-mcp/tests/test_workspace_disk.py
Thales Maciel 287f6d100f 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.
2026-03-12 20:57:16 -03:00

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,
)