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:
Thales Maciel 2026-03-12 20:57:16 -03:00
parent f2d20ef30a
commit 287f6d100f
26 changed files with 2585 additions and 34 deletions

View file

@ -21,6 +21,7 @@ from pyro_mcp.vm_manager import (
DEFAULT_SERVICE_READY_INTERVAL_MS,
DEFAULT_SERVICE_READY_TIMEOUT_SECONDS,
DEFAULT_VCPU_COUNT,
DEFAULT_WORKSPACE_DISK_READ_MAX_BYTES,
WORKSPACE_GUEST_PATH,
WORKSPACE_SHELL_SIGNAL_NAMES,
)
@ -253,6 +254,52 @@ def _print_workspace_export_human(payload: dict[str, Any]) -> None:
)
def _print_workspace_disk_export_human(payload: dict[str, Any]) -> None:
print(
"[workspace-disk-export] "
f"workspace_id={str(payload.get('workspace_id', 'unknown'))} "
f"output_path={str(payload.get('output_path', 'unknown'))} "
f"disk_format={str(payload.get('disk_format', 'unknown'))} "
f"bytes_written={int(payload.get('bytes_written', 0))}"
)
def _print_workspace_disk_list_human(payload: dict[str, Any]) -> None:
print(
f"Workspace disk path: {str(payload.get('path', WORKSPACE_GUEST_PATH))} "
f"(recursive={'yes' if bool(payload.get('recursive')) else 'no'})"
)
entries = payload.get("entries")
if not isinstance(entries, list) or not entries:
print("No workspace disk entries found.")
return
for entry in entries:
if not isinstance(entry, dict):
continue
line = (
f"{str(entry.get('path', 'unknown'))} "
f"[{str(entry.get('artifact_type', 'unknown'))}] "
f"size={int(entry.get('size_bytes', 0))}"
)
link_target = entry.get("link_target")
if isinstance(link_target, str) and link_target != "":
line += f" -> {link_target}"
print(line)
def _print_workspace_disk_read_human(payload: dict[str, Any]) -> None:
_write_stream(str(payload.get("content", "")), stream=sys.stdout)
print(
"[workspace-disk-read] "
f"workspace_id={str(payload.get('workspace_id', 'unknown'))} "
f"path={str(payload.get('path', 'unknown'))} "
f"size_bytes={int(payload.get('size_bytes', 0))} "
f"truncated={'yes' if bool(payload.get('truncated', False)) else 'no'}",
file=sys.stderr,
flush=True,
)
def _print_workspace_diff_human(payload: dict[str, Any]) -> None:
if not bool(payload.get("changed")):
print("No workspace changes.")
@ -687,6 +734,10 @@ def _build_parser() -> argparse.ArgumentParser:
pyro workspace create debian:12 --seed-path ./repo
pyro workspace sync push WORKSPACE_ID ./repo --dest src
pyro workspace exec WORKSPACE_ID -- sh -lc 'printf "hello\\n" > note.txt'
pyro workspace stop WORKSPACE_ID
pyro workspace disk list WORKSPACE_ID
pyro workspace disk export WORKSPACE_ID --output ./workspace.ext4
pyro workspace start WORKSPACE_ID
pyro workspace snapshot create WORKSPACE_ID checkpoint
pyro workspace reset WORKSPACE_ID --snapshot checkpoint
pyro workspace diff WORKSPACE_ID
@ -1039,6 +1090,141 @@ def _build_parser() -> argparse.ArgumentParser:
action="store_true",
help="Print structured JSON instead of human-readable output.",
)
workspace_stop_parser = workspace_subparsers.add_parser(
"stop",
help="Stop one workspace without resetting it.",
description=(
"Stop the backing sandbox, close shells, stop services, and preserve the "
"workspace filesystem, history, and snapshots."
),
epilog="Example:\n pyro workspace stop WORKSPACE_ID",
formatter_class=_HelpFormatter,
)
workspace_stop_parser.add_argument(
"workspace_id",
metavar="WORKSPACE_ID",
help="Persistent workspace identifier.",
)
workspace_stop_parser.add_argument(
"--json",
action="store_true",
help="Print structured JSON instead of human-readable output.",
)
workspace_start_parser = workspace_subparsers.add_parser(
"start",
help="Start one stopped workspace without resetting it.",
description=(
"Start a previously stopped workspace from its preserved rootfs and "
"workspace state."
),
epilog="Example:\n pyro workspace start WORKSPACE_ID",
formatter_class=_HelpFormatter,
)
workspace_start_parser.add_argument(
"workspace_id",
metavar="WORKSPACE_ID",
help="Persistent workspace identifier.",
)
workspace_start_parser.add_argument(
"--json",
action="store_true",
help="Print structured JSON instead of human-readable output.",
)
workspace_disk_parser = workspace_subparsers.add_parser(
"disk",
help="Inspect or export a stopped workspace disk.",
description=(
"Use secondary stopped-workspace disk tools for raw ext4 export and offline "
"inspection without booting the guest."
),
epilog=dedent(
"""
Examples:
pyro workspace stop WORKSPACE_ID
pyro workspace disk list WORKSPACE_ID
pyro workspace disk read WORKSPACE_ID note.txt
pyro workspace disk export WORKSPACE_ID --output ./workspace.ext4
Disk tools are secondary to `workspace export` and require a stopped, guest-backed
workspace.
"""
),
formatter_class=_HelpFormatter,
)
workspace_disk_subparsers = workspace_disk_parser.add_subparsers(
dest="workspace_disk_command",
required=True,
metavar="DISK",
)
workspace_disk_export_parser = workspace_disk_subparsers.add_parser(
"export",
help="Export the raw stopped workspace rootfs image.",
description="Copy the raw stopped workspace rootfs ext4 image to an explicit host path.",
epilog="Example:\n pyro workspace disk export WORKSPACE_ID --output ./workspace.ext4",
formatter_class=_HelpFormatter,
)
workspace_disk_export_parser.add_argument("workspace_id", metavar="WORKSPACE_ID")
workspace_disk_export_parser.add_argument(
"--output",
required=True,
help="Exact host path to create for the exported raw ext4 image.",
)
workspace_disk_export_parser.add_argument(
"--json",
action="store_true",
help="Print structured JSON instead of human-readable output.",
)
workspace_disk_list_parser = workspace_disk_subparsers.add_parser(
"list",
help="List files from a stopped workspace rootfs path.",
description=(
"Inspect one stopped workspace rootfs path without booting the guest. Relative "
"paths resolve inside `/workspace`; absolute paths inspect any guest path."
),
epilog="Example:\n pyro workspace disk list WORKSPACE_ID src --recursive",
formatter_class=_HelpFormatter,
)
workspace_disk_list_parser.add_argument("workspace_id", metavar="WORKSPACE_ID")
workspace_disk_list_parser.add_argument(
"path",
nargs="?",
default=WORKSPACE_GUEST_PATH,
metavar="PATH",
help="Guest path to inspect. Defaults to `/workspace`.",
)
workspace_disk_list_parser.add_argument(
"--recursive",
action="store_true",
help="Recurse into nested directories.",
)
workspace_disk_list_parser.add_argument(
"--json",
action="store_true",
help="Print structured JSON instead of human-readable output.",
)
workspace_disk_read_parser = workspace_disk_subparsers.add_parser(
"read",
help="Read one regular file from a stopped workspace rootfs.",
description=(
"Read one regular file from a stopped workspace rootfs without booting the guest. "
"Relative paths resolve inside `/workspace`; absolute paths inspect any guest path."
),
epilog="Example:\n pyro workspace disk read WORKSPACE_ID note.txt --max-bytes 4096",
formatter_class=_HelpFormatter,
)
workspace_disk_read_parser.add_argument("workspace_id", metavar="WORKSPACE_ID")
workspace_disk_read_parser.add_argument("path", metavar="PATH")
workspace_disk_read_parser.add_argument(
"--max-bytes",
type=int,
default=DEFAULT_WORKSPACE_DISK_READ_MAX_BYTES,
help="Maximum number of decoded UTF-8 bytes to return.",
)
workspace_disk_read_parser.add_argument(
"--json",
action="store_true",
help="Print structured JSON instead of human-readable output.",
)
workspace_shell_parser = workspace_subparsers.add_parser(
"shell",
help="Open and manage persistent interactive shells.",
@ -1885,6 +2071,88 @@ def main() -> None:
else:
_print_workspace_reset_human(payload)
return
if args.workspace_command == "stop":
try:
payload = pyro.stop_workspace(args.workspace_id)
except Exception as exc: # noqa: BLE001
if bool(args.json):
_print_json({"ok": False, "error": str(exc)})
else:
print(f"[error] {exc}", file=sys.stderr, flush=True)
raise SystemExit(1) from exc
if bool(args.json):
_print_json(payload)
else:
_print_workspace_summary_human(payload, action="Stopped workspace")
return
if args.workspace_command == "start":
try:
payload = pyro.start_workspace(args.workspace_id)
except Exception as exc: # noqa: BLE001
if bool(args.json):
_print_json({"ok": False, "error": str(exc)})
else:
print(f"[error] {exc}", file=sys.stderr, flush=True)
raise SystemExit(1) from exc
if bool(args.json):
_print_json(payload)
else:
_print_workspace_summary_human(payload, action="Started workspace")
return
if args.workspace_command == "disk":
if args.workspace_disk_command == "export":
try:
payload = pyro.export_workspace_disk(
args.workspace_id,
output_path=args.output,
)
except Exception as exc: # noqa: BLE001
if bool(args.json):
_print_json({"ok": False, "error": str(exc)})
else:
print(f"[error] {exc}", file=sys.stderr, flush=True)
raise SystemExit(1) from exc
if bool(args.json):
_print_json(payload)
else:
_print_workspace_disk_export_human(payload)
return
if args.workspace_disk_command == "list":
try:
payload = pyro.list_workspace_disk(
args.workspace_id,
path=args.path,
recursive=bool(args.recursive),
)
except Exception as exc: # noqa: BLE001
if bool(args.json):
_print_json({"ok": False, "error": str(exc)})
else:
print(f"[error] {exc}", file=sys.stderr, flush=True)
raise SystemExit(1) from exc
if bool(args.json):
_print_json(payload)
else:
_print_workspace_disk_list_human(payload)
return
if args.workspace_disk_command == "read":
try:
payload = pyro.read_workspace_disk(
args.workspace_id,
args.path,
max_bytes=args.max_bytes,
)
except Exception as exc: # noqa: BLE001
if bool(args.json):
_print_json({"ok": False, "error": str(exc)})
else:
print(f"[error] {exc}", file=sys.stderr, flush=True)
raise SystemExit(1) from exc
if bool(args.json):
_print_json(payload)
else:
_print_workspace_disk_read_human(payload)
return
if args.workspace_command == "shell":
if args.workspace_shell_command == "open":
secret_env = _parse_workspace_secret_env_options(getattr(args, "secret_env", []))