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:
parent
694be0730b
commit
6e16e74fd5
16 changed files with 384 additions and 91 deletions
|
|
@ -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([])
|
||||
|
|
|
|||
53
tests/test_python_lifecycle_example.py
Normal file
53
tests/test_python_lifecycle_example.py
Normal 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
|
||||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue