Harden default environment pull behavior

Fix the default one-shot install path so empty bundled profile directories no longer win over OCI-backed environment pulls or leave broken cached symlinks behind.

Treat cached installs as valid only when the manifest and boot artifacts are all present, repair invalid installs on the next pull, and add human-mode phase markers for env pull and run without changing JSON output.

Align the Python lifecycle example and public docs with the current exec_vm/vm_exec auto-clean semantics, and validate the slice with focused pytest coverage, make check, make dist-check, and a real default-path pull/inspect/run smoke.
This commit is contained in:
Thales Maciel 2026-03-11 19:27:09 -03:00
parent 694be0730b
commit 6e16e74fd5
16 changed files with 384 additions and 91 deletions

View file

@ -160,11 +160,21 @@ def test_cli_run_prints_human_output(
capsys: pytest.CaptureFixture[str],
) -> None:
class StubPyro:
def run_in_vm(self, **kwargs: Any) -> dict[str, Any]:
def create_vm(self, **kwargs: Any) -> dict[str, Any]:
assert kwargs["vcpu_count"] == 1
assert kwargs["mem_mib"] == 1024
return {"vm_id": "vm-123"}
def start_vm(self, vm_id: str) -> dict[str, Any]:
assert vm_id == "vm-123"
return {"vm_id": vm_id, "state": "started"}
def exec_vm(self, vm_id: str, *, command: str, timeout_seconds: int) -> dict[str, Any]:
assert vm_id == "vm-123"
assert command == "echo hi"
assert timeout_seconds == 30
return {
"environment": kwargs["environment"],
"environment": "debian:12",
"execution_mode": "guest_vsock",
"exit_code": 0,
"duration_ms": 12,
@ -172,6 +182,10 @@ def test_cli_run_prints_human_output(
"stderr": "",
}
@property
def manager(self) -> Any:
raise AssertionError("manager cleanup should not be used on a successful run")
class StubParser:
def parse_args(self) -> argparse.Namespace:
return argparse.Namespace(
@ -194,6 +208,9 @@ def test_cli_run_prints_human_output(
captured = capsys.readouterr()
assert captured.out == "hi\n"
assert "[run] phase=create environment=debian:12" in captured.err
assert "[run] phase=start vm_id=vm-123" in captured.err
assert "[run] phase=execute vm_id=vm-123" in captured.err
assert "[run] environment=debian:12 execution_mode=guest_vsock exit_code=0" in captured.err
@ -202,8 +219,18 @@ def test_cli_run_exits_with_command_status(
capsys: pytest.CaptureFixture[str],
) -> None:
class StubPyro:
def run_in_vm(self, **kwargs: Any) -> dict[str, Any]:
def create_vm(self, **kwargs: Any) -> dict[str, Any]:
del kwargs
return {"vm_id": "vm-456"}
def start_vm(self, vm_id: str) -> dict[str, Any]:
assert vm_id == "vm-456"
return {"vm_id": vm_id, "state": "started"}
def exec_vm(self, vm_id: str, *, command: str, timeout_seconds: int) -> dict[str, Any]:
assert vm_id == "vm-456"
assert command == "false"
assert timeout_seconds == 30
return {
"environment": "debian:12",
"execution_mode": "guest_vsock",
@ -213,6 +240,10 @@ def test_cli_run_exits_with_command_status(
"stderr": "bad\n",
}
@property
def manager(self) -> Any:
raise AssertionError("manager cleanup should not be used when exec_vm returns normally")
class StubParser:
def parse_args(self) -> argparse.Namespace:
return argparse.Namespace(
@ -238,6 +269,50 @@ def test_cli_run_exits_with_command_status(
assert "bad\n" in captured.err
def test_cli_env_pull_prints_human_progress(
monkeypatch: pytest.MonkeyPatch,
capsys: pytest.CaptureFixture[str],
) -> None:
class StubPyro:
def pull_environment(self, environment: str) -> dict[str, Any]:
assert environment == "debian:12"
return {
"name": "debian:12",
"version": "1.0.0",
"distribution": "debian",
"distribution_version": "12",
"installed": True,
"cache_dir": "/tmp/cache",
"default_packages": ["bash", "git"],
"install_dir": "/tmp/cache/linux-x86_64/debian_12-1.0.0",
"install_manifest": "/tmp/cache/linux-x86_64/debian_12-1.0.0/environment.json",
"kernel_image": "/tmp/cache/linux-x86_64/debian_12-1.0.0/vmlinux",
"rootfs_image": "/tmp/cache/linux-x86_64/debian_12-1.0.0/rootfs.ext4",
"oci_registry": "registry-1.docker.io",
"oci_repository": "thalesmaciel/pyro-environment-debian-12",
"oci_reference": "1.0.0",
}
class StubParser:
def parse_args(self) -> argparse.Namespace:
return argparse.Namespace(
command="env",
env_command="pull",
environment="debian:12",
json=False,
)
monkeypatch.setattr(cli, "_build_parser", lambda: StubParser())
monkeypatch.setattr(cli, "Pyro", StubPyro)
cli.main()
captured = capsys.readouterr()
assert "[pull] phase=install environment=debian:12" in captured.err
assert "[pull] phase=ready environment=debian:12" in captured.err
assert "Pulled: debian:12" in captured.out
def test_cli_requires_run_command() -> None:
with pytest.raises(ValueError, match="command is required"):
cli._require_command([])

View file

@ -0,0 +1,53 @@
from __future__ import annotations
import importlib.util
from pathlib import Path
from types import ModuleType
from typing import Any, cast
import pytest
def _load_python_lifecycle_module() -> ModuleType:
path = Path("examples/python_lifecycle.py")
spec = importlib.util.spec_from_file_location("python_lifecycle", path)
if spec is None or spec.loader is None:
raise AssertionError("failed to load python_lifecycle example")
module = importlib.util.module_from_spec(spec)
spec.loader.exec_module(module)
return module
def test_python_lifecycle_example_does_not_delete_after_exec(
capsys: pytest.CaptureFixture[str],
) -> None:
module = _load_python_lifecycle_module()
calls: list[str] = []
class StubPyro:
def create_vm(self, **kwargs: object) -> dict[str, object]:
assert kwargs["environment"] == "debian:12"
calls.append("create_vm")
return {"vm_id": "vm-123"}
def start_vm(self, vm_id: str) -> dict[str, object]:
assert vm_id == "vm-123"
calls.append("start_vm")
return {"vm_id": vm_id, "state": "started"}
def exec_vm(self, vm_id: str, *, command: str, timeout_seconds: int) -> dict[str, object]:
assert vm_id == "vm-123"
assert command == "git --version"
assert timeout_seconds == 30
calls.append("exec_vm")
return {"vm_id": vm_id, "stdout": "git version 2.43.0\n"}
def delete_vm(self, vm_id: str) -> dict[str, object]:
raise AssertionError(f"unexpected delete_vm({vm_id}) call")
cast(Any, module).Pyro = StubPyro
module.main()
assert calls == ["create_vm", "start_vm", "exec_vm"]
captured = capsys.readouterr()
assert "git version 2.43.0" in captured.out

View file

@ -14,7 +14,7 @@ def test_resolve_runtime_paths_default_bundle() -> None:
assert paths.jailer_bin.exists()
assert paths.guest_agent_path is not None
assert paths.guest_agent_path.exists()
assert (paths.artifacts_dir / "debian-git" / "vmlinux").exists()
assert paths.artifacts_dir.exists()
assert paths.manifest.get("platform") == "linux-x86_64"
@ -57,13 +57,6 @@ def test_resolve_runtime_paths_checksum_mismatch(
guest_agent_path.read_text(encoding="utf-8"),
encoding="utf-8",
)
for profile in ("debian-base", "debian-git", "debian-build"):
profile_dir = copied_platform / "profiles" / profile
profile_dir.mkdir(parents=True, exist_ok=True)
for filename in ("vmlinux", "rootfs.ext4"):
source_file = source.artifacts_dir / profile / filename
(profile_dir / filename).write_bytes(source_file.read_bytes())
monkeypatch.setenv("PYRO_RUNTIME_BUNDLE_DIR", str(copied_bundle))
with pytest.raises(RuntimeError, match="checksum mismatch"):
resolve_runtime_paths()

View file

@ -68,6 +68,19 @@ def _fake_runtime_paths(tmp_path: Path) -> RuntimePaths:
)
def _write_local_profile(
runtime_paths: RuntimePaths,
profile_name: str,
*,
kernel: str = "kernel\n",
rootfs: str = "rootfs\n",
) -> None:
profile_dir = runtime_paths.artifacts_dir / profile_name
profile_dir.mkdir(parents=True, exist_ok=True)
(profile_dir / "vmlinux").write_text(kernel, encoding="utf-8")
(profile_dir / "rootfs.ext4").write_text(rootfs, encoding="utf-8")
def _sha256_digest(payload: bytes) -> str:
return f"sha256:{hashlib.sha256(payload).hexdigest()}"
@ -108,7 +121,9 @@ def test_get_environment_rejects_unknown() -> None:
def test_environment_store_installs_from_local_runtime_source(tmp_path: Path) -> None:
store = EnvironmentStore(runtime_paths=resolve_runtime_paths(), cache_dir=tmp_path / "cache")
runtime_paths = _fake_runtime_paths(tmp_path)
_write_local_profile(runtime_paths, "debian-git")
store = EnvironmentStore(runtime_paths=runtime_paths, cache_dir=tmp_path / "cache")
installed = store.ensure_installed("debian:12")
assert installed.kernel_image.exists()
@ -117,7 +132,9 @@ def test_environment_store_installs_from_local_runtime_source(tmp_path: Path) ->
def test_environment_store_pull_and_cached_inspect(tmp_path: Path) -> None:
store = EnvironmentStore(runtime_paths=resolve_runtime_paths(), cache_dir=tmp_path / "cache")
runtime_paths = _fake_runtime_paths(tmp_path)
_write_local_profile(runtime_paths, "debian-git")
store = EnvironmentStore(runtime_paths=runtime_paths, cache_dir=tmp_path / "cache")
before = store.inspect_environment("debian:12")
assert before["installed"] is False
@ -145,7 +162,7 @@ def test_environment_store_uses_env_override_for_default_cache_dir(
def test_environment_store_installs_from_archive_when_runtime_source_missing(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
runtime_paths = resolve_runtime_paths()
runtime_paths = _fake_runtime_paths(tmp_path)
source_environment = get_environment("debian:12-base", runtime_paths=runtime_paths)
archive_dir = tmp_path / "archive"
@ -157,30 +174,6 @@ def test_environment_store_installs_from_archive_when_runtime_source_missing(
archive.add(archive_dir / "vmlinux", arcname="vmlinux")
archive.add(archive_dir / "rootfs.ext4", arcname="rootfs.ext4")
missing_bundle = tmp_path / "bundle"
platform_root = missing_bundle / "linux-x86_64"
platform_root.mkdir(parents=True, exist_ok=True)
(missing_bundle / "NOTICE").write_text(
runtime_paths.notice_path.read_text(encoding="utf-8"),
encoding="utf-8",
)
(platform_root / "manifest.json").write_text(
runtime_paths.manifest_path.read_text(encoding="utf-8"),
encoding="utf-8",
)
(platform_root / "bin").mkdir(parents=True, exist_ok=True)
(platform_root / "bin" / "firecracker").write_bytes(runtime_paths.firecracker_bin.read_bytes())
(platform_root / "bin" / "jailer").write_bytes(runtime_paths.jailer_bin.read_bytes())
guest_agent_path = runtime_paths.guest_agent_path
if guest_agent_path is None:
raise AssertionError("expected guest agent path")
(platform_root / "guest").mkdir(parents=True, exist_ok=True)
(platform_root / "guest" / "pyro_guest_agent.py").write_text(
guest_agent_path.read_text(encoding="utf-8"),
encoding="utf-8",
)
monkeypatch.setenv("PYRO_RUNTIME_BUNDLE_DIR", str(missing_bundle))
monkeypatch.setattr(
"pyro_mcp.vm_environments.CATALOG",
{
@ -200,7 +193,7 @@ def test_environment_store_installs_from_archive_when_runtime_source_missing(
},
)
store = EnvironmentStore(
runtime_paths=resolve_runtime_paths(verify_checksums=False),
runtime_paths=runtime_paths,
cache_dir=tmp_path / "cache",
)
installed = store.ensure_installed("debian:12-base")
@ -209,6 +202,91 @@ def test_environment_store_installs_from_archive_when_runtime_source_missing(
assert installed.rootfs_image.read_text(encoding="utf-8") == "rootfs\n"
def test_environment_store_skips_empty_local_source_dir_and_uses_archive(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
runtime_paths = _fake_runtime_paths(tmp_path)
source_environment = get_environment("debian:12-base", runtime_paths=runtime_paths)
(runtime_paths.artifacts_dir / source_environment.source_profile).mkdir(
parents=True,
exist_ok=True,
)
archive_dir = tmp_path / "archive"
archive_dir.mkdir(parents=True, exist_ok=True)
(archive_dir / "vmlinux").write_text("kernel\n", encoding="utf-8")
(archive_dir / "rootfs.ext4").write_text("rootfs\n", encoding="utf-8")
archive_path = tmp_path / "environment.tgz"
with tarfile.open(archive_path, "w:gz") as archive:
archive.add(archive_dir / "vmlinux", arcname="vmlinux")
archive.add(archive_dir / "rootfs.ext4", arcname="rootfs.ext4")
monkeypatch.setattr(
"pyro_mcp.vm_environments.CATALOG",
{
"debian:12-base": source_environment.__class__(
name=source_environment.name,
version=source_environment.version,
description=source_environment.description,
default_packages=source_environment.default_packages,
distribution=source_environment.distribution,
distribution_version=source_environment.distribution_version,
source_profile=source_environment.source_profile,
platform=source_environment.platform,
source_url=archive_path.resolve().as_uri(),
source_digest=source_environment.source_digest,
compatibility=source_environment.compatibility,
)
},
)
store = EnvironmentStore(runtime_paths=runtime_paths, cache_dir=tmp_path / "cache")
installed = store.ensure_installed("debian:12-base")
assert installed.kernel_image.read_text(encoding="utf-8") == "kernel\n"
assert installed.rootfs_image.read_text(encoding="utf-8") == "rootfs\n"
def test_environment_store_marks_broken_symlink_install_uninstalled_and_repairs_it(
tmp_path: Path,
) -> None:
runtime_paths = _fake_runtime_paths(tmp_path)
_write_local_profile(
runtime_paths,
"debian-git",
kernel="kernel-fixed\n",
rootfs="rootfs-fixed\n",
)
store = EnvironmentStore(runtime_paths=runtime_paths, cache_dir=tmp_path / "cache")
spec = get_environment("debian:12", runtime_paths=runtime_paths)
install_dir = store.cache_dir / "linux-x86_64" / "debian_12-1.0.0"
install_dir.mkdir(parents=True, exist_ok=True)
(install_dir / "environment.json").write_text(
json.dumps(
{
"catalog_version": "2.0.0",
"name": spec.name,
"version": spec.version,
"source": "bundled-runtime-source",
"source_digest": spec.source_digest,
"installed_at": 0,
}
),
encoding="utf-8",
)
(install_dir / "vmlinux").symlink_to("missing-vmlinux")
(install_dir / "rootfs.ext4").symlink_to("missing-rootfs.ext4")
inspected_before = store.inspect_environment("debian:12")
assert inspected_before["installed"] is False
pulled = store.pull_environment("debian:12")
assert pulled["installed"] is True
assert Path(str(pulled["kernel_image"])).read_text(encoding="utf-8") == "kernel-fixed\n"
assert Path(str(pulled["rootfs_image"])).read_text(encoding="utf-8") == "rootfs-fixed\n"
def test_environment_store_prunes_stale_entries(tmp_path: Path) -> None:
store = EnvironmentStore(runtime_paths=resolve_runtime_paths(), cache_dir=tmp_path / "cache")
platform_dir = store.cache_dir / "linux-x86_64"