diff --git a/CHANGELOG.md b/CHANGELOG.md index d588825..6cfcb11 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,17 @@ All notable user-visible changes to `pyro-mcp` are documented here. +## 2.0.1 + +- Fixed the default `pyro env pull` path so empty local profile directories no longer produce + broken cached installs or contradictory "Pulled" / "not installed" states. +- Hardened cache inspection and repair so broken environment symlinks are treated as uninstalled + and repaired on the next pull. +- Added human-mode phase markers for `pyro env pull` and `pyro run` to make longer guest flows + easier to follow from the CLI. +- Corrected the Python lifecycle example and docs to match the current `exec_vm` / `vm_exec` + auto-clean semantics. + ## 2.0.0 - Made guest execution fail closed by default; host compatibility execution now requires diff --git a/README.md b/README.md index a815f32..11f1e58 100644 --- a/README.md +++ b/README.md @@ -18,7 +18,7 @@ It exposes the same runtime in three public forms: - First run transcript: [docs/first-run.md](docs/first-run.md) - Terminal walkthrough GIF: [docs/assets/first-run.gif](docs/assets/first-run.gif) - PyPI package: [pypi.org/project/pyro-mcp](https://pypi.org/project/pyro-mcp/) -- What's new in 2.0: [CHANGELOG.md#200](CHANGELOG.md#200) +- What's new in 2.0.1: [CHANGELOG.md#201](CHANGELOG.md#201) - Host requirements: [docs/host-requirements.md](docs/host-requirements.md) - Integration targets: [docs/integrations.md](docs/integrations.md) - Public contract: [docs/public-contract.md](docs/public-contract.md) @@ -57,8 +57,13 @@ Platform: linux-x86_64 Runtime: PASS Catalog version: 2.0.0 ... +[pull] phase=install environment=debian:12 +[pull] phase=ready environment=debian:12 Pulled: debian:12 ... +[run] phase=create environment=debian:12 +[run] phase=start vm_id=... +[run] phase=execute vm_id=... [run] environment=debian:12 execution_mode=guest_vsock exit_code=0 duration_ms=... git version ... ``` @@ -308,6 +313,8 @@ result = pyro.exec_vm(vm_id, command="git --version", timeout_seconds=30) print(result["stdout"]) ``` +`exec_vm()` is a one-command auto-cleaning call. After it returns, the VM is already deleted. + Environment management is also available through the SDK: ```python @@ -329,7 +336,7 @@ Advanced lifecycle tools: - `vm_list_environments()` - `vm_create(environment, vcpu_count=1, mem_mib=1024, ttl_seconds=600, network=false, allow_host_compat=false)` - `vm_start(vm_id)` -- `vm_exec(vm_id, command, timeout_seconds=30)` +- `vm_exec(vm_id, command, timeout_seconds=30)` auto-cleans the VM after that command - `vm_stop(vm_id)` - `vm_delete(vm_id)` - `vm_status(vm_id)` @@ -371,7 +378,11 @@ make check make dist-check ``` -Contributor runtime source artifacts are still maintained under `src/pyro_mcp/runtime_bundle/` and `runtime_sources/`. +Contributor runtime sources live under `runtime_sources/`. The packaged runtime bundle under +`src/pyro_mcp/runtime_bundle/` contains the embedded boot/runtime assets plus manifest metadata; +end-user environment installs pull OCI-published environments by default. Use +`PYRO_RUNTIME_BUNDLE_DIR=build/runtime_bundle` only when you are explicitly validating a locally +built contributor runtime bundle. Official environment publication is performed locally against Docker Hub: diff --git a/docs/first-run.md b/docs/first-run.md index eb2da54..f76167d 100644 --- a/docs/first-run.md +++ b/docs/first-run.md @@ -36,6 +36,8 @@ access to `registry-1.docker.io`, and needs local cache space for the guest imag ```bash $ uvx --from pyro-mcp pyro env pull debian:12 +[pull] phase=install environment=debian:12 +[pull] phase=ready environment=debian:12 Pulled: debian:12 Version: 1.0.0 Distribution: debian 12 @@ -53,6 +55,9 @@ OCI source: registry-1.docker.io/thalesmaciel/pyro-environment-debian-12:1.0.0 ```bash $ uvx --from pyro-mcp pyro run debian:12 -- git --version +[run] phase=create environment=debian:12 +[run] phase=start vm_id=... +[run] phase=execute vm_id=... [run] environment=debian:12 execution_mode=guest_vsock exit_code=0 duration_ms=... git version ... ``` diff --git a/docs/install.md b/docs/install.md index c8618e3..977fc56 100644 --- a/docs/install.md +++ b/docs/install.md @@ -99,6 +99,15 @@ The first pull downloads an OCI environment from public Docker Hub, requires out access to `registry-1.docker.io`, and needs local cache space for the guest image. See [host-requirements.md](host-requirements.md) for the full host requirements. +Expected success signals: + +```bash +[pull] phase=install environment=debian:12 +[pull] phase=ready environment=debian:12 +Pulled: debian:12 +... +``` + ### 4. Run one command in a guest ```bash @@ -108,6 +117,9 @@ uvx --from pyro-mcp pyro run debian:12 -- git --version Expected success signals: ```bash +[run] phase=create environment=debian:12 +[run] phase=start vm_id=... +[run] phase=execute vm_id=... [run] environment=debian:12 execution_mode=guest_vsock exit_code=0 duration_ms=... git version ... ``` diff --git a/docs/integrations.md b/docs/integrations.md index 1239fdd..5501508 100644 --- a/docs/integrations.md +++ b/docs/integrations.md @@ -64,6 +64,12 @@ Recommended default: - `Pyro.run_in_vm(...)` +Lifecycle note: + +- `Pyro.exec_vm(...)` runs one command and auto-cleans the VM afterward +- use `create_vm(...)` + `start_vm(...)` only when you need pre-exec inspection or status before + that final exec + Examples: - [examples/python_run.py](../examples/python_run.py) diff --git a/docs/public-contract.md b/docs/public-contract.md index 4c36289..b0150b1 100644 --- a/docs/public-contract.md +++ b/docs/public-contract.md @@ -86,6 +86,7 @@ Behavioral defaults: - `Pyro.create_vm(...)` and `Pyro.run_in_vm(...)` default to `vcpu_count=1` and `mem_mib=1024`. - `allow_host_compat` defaults to `False` on `create_vm(...)` and `run_in_vm(...)`. +- `Pyro.exec_vm(...)` runs one command and auto-cleans that VM after the exec completes. ## MCP Contract @@ -109,6 +110,7 @@ Behavioral defaults: - `vm_run` and `vm_create` default to `vcpu_count=1` and `mem_mib=1024`. - `vm_run` and `vm_create` expose `allow_host_compat`, which defaults to `false`. +- `vm_exec` runs one command and auto-cleans that VM after the exec completes. ## Versioning Rule diff --git a/docs/troubleshooting.md b/docs/troubleshooting.md index a8225bd..1b4c125 100644 --- a/docs/troubleshooting.md +++ b/docs/troubleshooting.md @@ -20,6 +20,9 @@ pyro env pull debian:12 If you are validating a freshly published official environment, also verify that the corresponding Docker Hub repository is public. +`PYRO_RUNTIME_BUNDLE_DIR` is a contributor override for validating a locally built runtime bundle. +End-user `pyro env pull` should work without setting it. + ## `pyro run` fails closed before the command executes Cause: diff --git a/examples/python_lifecycle.py b/examples/python_lifecycle.py index 880d683..6f6a82a 100644 --- a/examples/python_lifecycle.py +++ b/examples/python_lifecycle.py @@ -15,13 +15,9 @@ def main() -> None: network=False, ) vm_id = str(created["vm_id"]) - - try: - pyro.start_vm(vm_id) - result = pyro.exec_vm(vm_id, command="git --version", timeout_seconds=30) - print(json.dumps(result, indent=2, sort_keys=True)) - finally: - pyro.delete_vm(vm_id) + pyro.start_vm(vm_id) + result = pyro.exec_vm(vm_id, command="git --version", timeout_seconds=30) + print(json.dumps(result, indent=2, sort_keys=True)) if __name__ == "__main__": diff --git a/pyproject.toml b/pyproject.toml index 7cde7e1..c13a4e7 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "pyro-mcp" -version = "2.0.0" +version = "2.0.1" description = "Curated Linux environments for ephemeral Firecracker-backed VM execution." readme = "README.md" license = { file = "LICENSE" } diff --git a/src/pyro_mcp/cli.py b/src/pyro_mcp/cli.py index 96f0118..39c2434 100644 --- a/src/pyro_mcp/cli.py +++ b/src/pyro_mcp/cli.py @@ -47,6 +47,12 @@ def _print_run_human(payload: dict[str, Any]) -> None: ) +def _print_phase(prefix: str, *, phase: str, **fields: object) -> None: + details = " ".join(f"{key}={value}" for key, value in fields.items()) + suffix = f" {details}" if details else "" + print(f"[{prefix}] phase={phase}{suffix}", file=sys.stderr, flush=True) + + def _print_env_list_human(payload: dict[str, Any]) -> None: print(f"Catalog version: {payload.get('catalog_version', 'unknown')}") environments = payload.get("environments") @@ -464,10 +470,13 @@ def main() -> None: _print_env_list_human(list_payload) return if args.env_command == "pull": - pull_payload = pyro.pull_environment(args.environment) if bool(args.json): + pull_payload = pyro.pull_environment(args.environment) _print_json(pull_payload) else: + _print_phase("pull", phase="install", environment=args.environment) + pull_payload = pyro.pull_environment(args.environment) + _print_phase("pull", phase="ready", environment=args.environment) _print_env_detail_human(pull_payload, action="Pulled") return if args.env_command == "inspect": @@ -489,26 +498,47 @@ def main() -> None: return if args.command == "run": command = _require_command(args.command_args) - try: - result = pyro.run_in_vm( - environment=args.environment, - command=command, - vcpu_count=args.vcpu_count, - mem_mib=args.mem_mib, - timeout_seconds=args.timeout_seconds, - ttl_seconds=args.ttl_seconds, - network=args.network, - allow_host_compat=args.allow_host_compat, - ) - 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): + try: + result = pyro.run_in_vm( + environment=args.environment, + command=command, + vcpu_count=args.vcpu_count, + mem_mib=args.mem_mib, + timeout_seconds=args.timeout_seconds, + ttl_seconds=args.ttl_seconds, + network=args.network, + allow_host_compat=args.allow_host_compat, + ) + except Exception as exc: # noqa: BLE001 + _print_json({"ok": False, "error": str(exc)}) + raise SystemExit(1) from exc _print_json(result) else: + vm_id: str | None = None + try: + _print_phase("run", phase="create", environment=args.environment) + created = pyro.create_vm( + environment=args.environment, + vcpu_count=args.vcpu_count, + mem_mib=args.mem_mib, + ttl_seconds=args.ttl_seconds, + network=args.network, + allow_host_compat=args.allow_host_compat, + ) + vm_id = str(created["vm_id"]) + _print_phase("run", phase="start", vm_id=vm_id) + pyro.start_vm(vm_id) + _print_phase("run", phase="execute", vm_id=vm_id) + result = pyro.exec_vm(vm_id, command=command, timeout_seconds=args.timeout_seconds) + except Exception as exc: # noqa: BLE001 + if vm_id is not None: + try: + pyro.manager.delete_vm(vm_id, reason="run_vm_error_cleanup") + except ValueError: + pass + print(f"[error] {exc}", file=sys.stderr, flush=True) + raise SystemExit(1) from exc _print_run_human(result) exit_code = int(result.get("exit_code", 1)) if exit_code != 0: diff --git a/src/pyro_mcp/vm_environments.py b/src/pyro_mcp/vm_environments.py index 6c2a235..801789a 100644 --- a/src/pyro_mcp/vm_environments.py +++ b/src/pyro_mcp/vm_environments.py @@ -185,6 +185,10 @@ def _serialize_environment(environment: VmEnvironment) -> dict[str, object]: } +def _artifacts_ready(root: Path) -> bool: + return (root / "vmlinux").is_file() and (root / "rootfs.ext4").is_file() + + class EnvironmentStore: """Install and inspect curated environments in a local cache.""" @@ -228,7 +232,7 @@ class EnvironmentStore: spec = get_environment(name, runtime_paths=self._runtime_paths) install_dir = self._install_dir(spec) metadata_path = install_dir / "environment.json" - installed = metadata_path.exists() and (install_dir / "vmlinux").exists() + installed = self._load_installed_environment(spec) is not None payload = _serialize_environment(spec) payload.update( { @@ -245,29 +249,12 @@ class EnvironmentStore: def ensure_installed(self, name: str) -> InstalledEnvironment: spec = get_environment(name, runtime_paths=self._runtime_paths) self._platform_dir.mkdir(parents=True, exist_ok=True) - install_dir = self._install_dir(spec) - metadata_path = install_dir / "environment.json" - if metadata_path.exists(): - kernel_image = install_dir / "vmlinux" - rootfs_image = install_dir / "rootfs.ext4" - if kernel_image.exists() and rootfs_image.exists(): - metadata = json.loads(metadata_path.read_text(encoding="utf-8")) - source = str(metadata.get("source", "cache")) - raw_digest = metadata.get("source_digest") - digest = raw_digest if isinstance(raw_digest, str) else None - return InstalledEnvironment( - name=spec.name, - version=spec.version, - install_dir=install_dir, - kernel_image=kernel_image, - rootfs_image=rootfs_image, - source=source, - source_digest=digest, - installed=True, - ) + installed = self._load_installed_environment(spec) + if installed is not None: + return installed source_dir = self._runtime_paths.artifacts_dir / spec.source_profile - if source_dir.exists(): + if _artifacts_ready(source_dir): return self._install_from_local_source(spec, source_dir) if ( spec.oci_registry is not None @@ -313,6 +300,10 @@ class EnvironmentStore: if spec.version != raw_version: shutil.rmtree(child, ignore_errors=True) deleted.append(child.name) + continue + if self._load_installed_environment(spec, install_dir=child) is None: + shutil.rmtree(child, ignore_errors=True) + deleted.append(child.name) return {"deleted_environment_dirs": sorted(deleted), "count": len(deleted)} def _install_dir(self, spec: VmEnvironment) -> Path: @@ -349,6 +340,33 @@ class EnvironmentStore: installed=True, ) + def _load_installed_environment( + self, spec: VmEnvironment, *, install_dir: Path | None = None + ) -> InstalledEnvironment | None: + resolved_install_dir = install_dir or self._install_dir(spec) + metadata_path = resolved_install_dir / "environment.json" + if not metadata_path.is_file() or not _artifacts_ready(resolved_install_dir): + return None + try: + metadata = json.loads(metadata_path.read_text(encoding="utf-8")) + except (OSError, json.JSONDecodeError): + return None + if not isinstance(metadata, dict): + return None + source = str(metadata.get("source", "cache")) + raw_digest = metadata.get("source_digest") + digest = raw_digest if isinstance(raw_digest, str) else None + return InstalledEnvironment( + name=spec.name, + version=spec.version, + install_dir=resolved_install_dir, + kernel_image=resolved_install_dir / "vmlinux", + rootfs_image=resolved_install_dir / "rootfs.ext4", + source=source, + source_digest=digest, + installed=True, + ) + def _install_from_archive(self, spec: VmEnvironment, archive_url: str) -> InstalledEnvironment: install_dir = self._install_dir(spec) temp_dir = Path(tempfile.mkdtemp(prefix=".partial-", dir=self._platform_dir)) diff --git a/tests/test_cli.py b/tests/test_cli.py index 4c2002c..69643d6 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -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([]) diff --git a/tests/test_python_lifecycle_example.py b/tests/test_python_lifecycle_example.py new file mode 100644 index 0000000..8a54298 --- /dev/null +++ b/tests/test_python_lifecycle_example.py @@ -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 diff --git a/tests/test_runtime.py b/tests/test_runtime.py index 8956a99..930ef4f 100644 --- a/tests/test_runtime.py +++ b/tests/test_runtime.py @@ -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() diff --git a/tests/test_vm_environments.py b/tests/test_vm_environments.py index a5cd6d9..c9ab1e6 100644 --- a/tests/test_vm_environments.py +++ b/tests/test_vm_environments.py @@ -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" diff --git a/uv.lock b/uv.lock index b0df50c..754b94e 100644 --- a/uv.lock +++ b/uv.lock @@ -706,7 +706,7 @@ crypto = [ [[package]] name = "pyro-mcp" -version = "2.0.0" +version = "2.0.1" source = { editable = "." } dependencies = [ { name = "mcp" },