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
11
CHANGELOG.md
11
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
|
||||
|
|
|
|||
17
README.md
17
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:
|
||||
|
||||
|
|
|
|||
|
|
@ -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 ...
|
||||
```
|
||||
|
|
|
|||
|
|
@ -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 ...
|
||||
```
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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__":
|
||||
|
|
|
|||
|
|
@ -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" }
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
2
uv.lock
generated
2
uv.lock
generated
|
|
@ -706,7 +706,7 @@ crypto = [
|
|||
|
||||
[[package]]
|
||||
name = "pyro-mcp"
|
||||
version = "2.0.0"
|
||||
version = "2.0.1"
|
||||
source = { editable = "." }
|
||||
dependencies = [
|
||||
{ name = "mcp" },
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue