316 lines
12 KiB
Python
316 lines
12 KiB
Python
from __future__ import annotations
|
|
|
|
import argparse
|
|
import hashlib
|
|
import json
|
|
import tarfile
|
|
from pathlib import Path
|
|
|
|
import pytest
|
|
|
|
from pyro_mcp.runtime_build import (
|
|
_build_paths,
|
|
_load_lock,
|
|
build_bundle,
|
|
export_environment_oci_layout,
|
|
generate_manifest,
|
|
main,
|
|
materialize_binaries,
|
|
stage_agent,
|
|
stage_binaries,
|
|
stage_kernel,
|
|
stage_rootfs,
|
|
validate_sources,
|
|
)
|
|
|
|
|
|
def _write_text(path: Path, content: str) -> None:
|
|
path.parent.mkdir(parents=True, exist_ok=True)
|
|
path.write_text(content, encoding="utf-8")
|
|
|
|
|
|
def _make_source_tree(tmp_path: Path) -> tuple[Path, Path, Path]:
|
|
source_dir = tmp_path / "runtime_sources"
|
|
platform_root = source_dir / "linux-x86_64"
|
|
_write_text(source_dir / "NOTICE", "notice\n")
|
|
_write_text(platform_root / "bin/firecracker", "firecracker\n")
|
|
_write_text(platform_root / "bin/jailer", "jailer\n")
|
|
_write_text(platform_root / "guest/pyro_guest_agent.py", "#!/usr/bin/env python3\n")
|
|
_write_text(platform_root / "profiles/debian-base/vmlinux", "kernel-base\n")
|
|
_write_text(platform_root / "profiles/debian-base/rootfs.ext4", "rootfs-base\n")
|
|
_write_text(platform_root / "profiles/debian-git/vmlinux", "kernel-git\n")
|
|
_write_text(platform_root / "profiles/debian-git/rootfs.ext4", "rootfs-git\n")
|
|
_write_text(platform_root / "profiles/debian-build/vmlinux", "kernel-build\n")
|
|
_write_text(platform_root / "profiles/debian-build/rootfs.ext4", "rootfs-build\n")
|
|
lock = {
|
|
"bundle_version": "9.9.9",
|
|
"platform": "linux-x86_64",
|
|
"component_versions": {
|
|
"firecracker": "1.0.0",
|
|
"jailer": "1.0.0",
|
|
"kernel": "6.0.0",
|
|
"guest_agent": "0.2.0",
|
|
"base_distro": "debian-12",
|
|
},
|
|
"capabilities": {"vm_boot": True, "guest_exec": True, "guest_network": True},
|
|
"binaries": {"firecracker": "bin/firecracker", "jailer": "bin/jailer"},
|
|
"guest": {"agent": {"path": "guest/pyro_guest_agent.py"}},
|
|
"profiles": {
|
|
"debian-base": {
|
|
"description": "base",
|
|
"kernel": "profiles/debian-base/vmlinux",
|
|
"rootfs": "profiles/debian-base/rootfs.ext4",
|
|
},
|
|
"debian-git": {
|
|
"description": "git",
|
|
"kernel": "profiles/debian-git/vmlinux",
|
|
"rootfs": "profiles/debian-git/rootfs.ext4",
|
|
},
|
|
"debian-build": {
|
|
"description": "build",
|
|
"kernel": "profiles/debian-build/vmlinux",
|
|
"rootfs": "profiles/debian-build/rootfs.ext4",
|
|
},
|
|
},
|
|
}
|
|
(platform_root / "runtime.lock.json").write_text(
|
|
json.dumps(lock, indent=2) + "\n", encoding="utf-8"
|
|
)
|
|
build_dir = tmp_path / "build/runtime_bundle"
|
|
bundle_dir = tmp_path / "bundle_out"
|
|
return source_dir, build_dir, bundle_dir
|
|
|
|
|
|
def test_runtime_build_stages_and_manifest(tmp_path: Path) -> None:
|
|
source_dir, build_dir, bundle_dir = _make_source_tree(tmp_path)
|
|
paths = _build_paths(
|
|
source_dir=source_dir,
|
|
build_dir=build_dir,
|
|
bundle_dir=bundle_dir,
|
|
materialized_dir=tmp_path / "materialized_sources",
|
|
platform="linux-x86_64",
|
|
)
|
|
lock = _load_lock(paths)
|
|
|
|
paths.build_platform_root.mkdir(parents=True, exist_ok=True)
|
|
stage_binaries(paths, lock)
|
|
stage_kernel(paths, lock)
|
|
stage_rootfs(paths, lock)
|
|
stage_agent(paths, lock)
|
|
manifest = generate_manifest(paths, lock)
|
|
|
|
assert manifest["bundle_version"] == "9.9.9"
|
|
assert manifest["capabilities"]["guest_exec"] is True
|
|
assert manifest["component_versions"]["guest_agent"] == "0.2.0"
|
|
assert (paths.build_platform_root / "guest/pyro_guest_agent.py").exists()
|
|
|
|
|
|
def test_runtime_build_bundle_syncs_output(tmp_path: Path) -> None:
|
|
source_dir, build_dir, bundle_dir = _make_source_tree(tmp_path)
|
|
paths = _build_paths(
|
|
source_dir=source_dir,
|
|
build_dir=build_dir,
|
|
bundle_dir=bundle_dir,
|
|
materialized_dir=tmp_path / "materialized_sources",
|
|
platform="linux-x86_64",
|
|
)
|
|
|
|
manifest = build_bundle(paths, sync=True)
|
|
|
|
assert manifest["profiles"]["debian-git"]["description"] == "git"
|
|
assert (bundle_dir / "NOTICE").exists()
|
|
assert (bundle_dir / "linux-x86_64/manifest.json").exists()
|
|
assert (bundle_dir / "linux-x86_64/guest/pyro_guest_agent.py").exists()
|
|
|
|
|
|
def test_runtime_build_rejects_guest_capabilities_for_placeholder_sources(tmp_path: Path) -> None:
|
|
source_dir, build_dir, bundle_dir = _make_source_tree(tmp_path)
|
|
paths = _build_paths(
|
|
source_dir=source_dir,
|
|
build_dir=build_dir,
|
|
bundle_dir=bundle_dir,
|
|
materialized_dir=tmp_path / "materialized_sources",
|
|
platform="linux-x86_64",
|
|
)
|
|
lock_path = source_dir / "linux-x86_64/runtime.lock.json"
|
|
_write_text(
|
|
source_dir / "linux-x86_64/bin/firecracker",
|
|
"#!/usr/bin/env bash\n"
|
|
"echo 'bundled firecracker shim'\n",
|
|
)
|
|
_write_text(
|
|
source_dir / "linux-x86_64/profiles/debian-base/rootfs.ext4",
|
|
"placeholder-rootfs\n",
|
|
)
|
|
lock = json.loads(lock_path.read_text(encoding="utf-8"))
|
|
lock["capabilities"] = {"vm_boot": True, "guest_exec": True, "guest_network": True}
|
|
lock_path.write_text(json.dumps(lock, indent=2) + "\n", encoding="utf-8")
|
|
|
|
with pytest.raises(RuntimeError, match="guest-capable features"):
|
|
validate_sources(paths, _load_lock(paths))
|
|
|
|
|
|
def test_runtime_build_materializes_firecracker_release(tmp_path: Path) -> None:
|
|
source_dir, build_dir, bundle_dir = _make_source_tree(tmp_path)
|
|
archive_path = tmp_path / "firecracker-v1.12.1-x86_64.tgz"
|
|
release_dir = "release-v1.12.1-x86_64"
|
|
with tarfile.open(archive_path, "w:gz") as archive:
|
|
firecracker_path = tmp_path / "firecracker-bin"
|
|
jailer_path = tmp_path / "jailer-bin"
|
|
firecracker_path.write_text("real-firecracker\n", encoding="utf-8")
|
|
jailer_path.write_text("real-jailer\n", encoding="utf-8")
|
|
archive.add(firecracker_path, arcname=f"{release_dir}/firecracker-v1.12.1-x86_64")
|
|
archive.add(jailer_path, arcname=f"{release_dir}/jailer-v1.12.1-x86_64")
|
|
|
|
digest = hashlib.sha256(archive_path.read_bytes()).hexdigest()
|
|
lock_path = source_dir / "linux-x86_64/runtime.lock.json"
|
|
lock = json.loads(lock_path.read_text(encoding="utf-8"))
|
|
lock["upstream"] = {
|
|
"firecracker_release": {
|
|
"archive_url": archive_path.resolve().as_uri(),
|
|
"archive_sha256": digest,
|
|
"firecracker_member": f"{release_dir}/firecracker-v1.12.1-x86_64",
|
|
"jailer_member": f"{release_dir}/jailer-v1.12.1-x86_64",
|
|
}
|
|
}
|
|
lock_path.write_text(json.dumps(lock, indent=2) + "\n", encoding="utf-8")
|
|
|
|
paths = _build_paths(
|
|
source_dir=source_dir,
|
|
build_dir=build_dir,
|
|
bundle_dir=bundle_dir,
|
|
materialized_dir=tmp_path / "materialized_sources",
|
|
platform="linux-x86_64",
|
|
)
|
|
materialize_binaries(paths, _load_lock(paths))
|
|
|
|
assert (paths.materialized_platform_root / "bin/firecracker").read_text(encoding="utf-8") == (
|
|
"real-firecracker\n"
|
|
)
|
|
assert (paths.materialized_platform_root / "bin/jailer").read_text(encoding="utf-8") == (
|
|
"real-jailer\n"
|
|
)
|
|
|
|
|
|
def test_export_environment_oci_layout_writes_boot_artifacts(tmp_path: Path) -> None:
|
|
source_dir, build_dir, bundle_dir = _make_source_tree(tmp_path)
|
|
paths = _build_paths(
|
|
source_dir=source_dir,
|
|
build_dir=build_dir,
|
|
bundle_dir=bundle_dir,
|
|
materialized_dir=tmp_path / "materialized_sources",
|
|
platform="linux-x86_64",
|
|
)
|
|
|
|
result = export_environment_oci_layout(
|
|
paths,
|
|
environment="debian:12-base",
|
|
output_dir=tmp_path / "oci_layouts",
|
|
)
|
|
|
|
layout_dir = Path(str(result["layout_dir"]))
|
|
assert layout_dir == tmp_path / "oci_layouts" / "debian_12-base"
|
|
assert json.loads((layout_dir / "oci-layout").read_text(encoding="utf-8")) == {
|
|
"imageLayoutVersion": "1.0.0"
|
|
}
|
|
index = json.loads((layout_dir / "index.json").read_text(encoding="utf-8"))
|
|
manifest_descriptor = index["manifests"][0]
|
|
assert manifest_descriptor["annotations"]["org.opencontainers.image.ref.name"] == "1.0.0"
|
|
assert manifest_descriptor["platform"] == {"os": "linux", "architecture": "amd64"}
|
|
|
|
manifest_digest = str(result["manifest_digest"]).split(":", 1)[1]
|
|
manifest = json.loads(
|
|
(layout_dir / "blobs" / "sha256" / manifest_digest).read_text(encoding="utf-8")
|
|
)
|
|
assert manifest["config"]["mediaType"] == "application/vnd.oci.image.config.v1+json"
|
|
assert len(manifest["layers"]) == 3
|
|
|
|
extracted_files: dict[str, str] = {}
|
|
for layer in manifest["layers"]:
|
|
layer_digest = str(layer["digest"]).split(":", 1)[1]
|
|
layer_path = layout_dir / "blobs" / "sha256" / layer_digest
|
|
with tarfile.open(layer_path, "r:") as archive:
|
|
members = archive.getmembers()
|
|
assert len(members) == 1
|
|
member = members[0]
|
|
extracted = archive.extractfile(member)
|
|
if extracted is None:
|
|
raise AssertionError("expected layer member content")
|
|
extracted_files[member.name] = extracted.read().decode("utf-8")
|
|
|
|
assert extracted_files["vmlinux"] == "kernel-base\n"
|
|
assert extracted_files["rootfs.ext4"] == "rootfs-base\n"
|
|
metadata = json.loads(extracted_files["environment.json"])
|
|
assert metadata["environment"] == "debian:12-base"
|
|
assert metadata["source_profile"] == "debian-base"
|
|
|
|
|
|
def test_export_environment_oci_layout_rejects_missing_profile(tmp_path: Path) -> None:
|
|
source_dir, build_dir, bundle_dir = _make_source_tree(tmp_path)
|
|
paths = _build_paths(
|
|
source_dir=source_dir,
|
|
build_dir=build_dir,
|
|
bundle_dir=bundle_dir,
|
|
materialized_dir=tmp_path / "materialized_sources",
|
|
platform="linux-x86_64",
|
|
)
|
|
lock_path = source_dir / "linux-x86_64/runtime.lock.json"
|
|
lock = json.loads(lock_path.read_text(encoding="utf-8"))
|
|
del lock["profiles"]["debian-base"]
|
|
lock_path.write_text(json.dumps(lock, indent=2) + "\n", encoding="utf-8")
|
|
|
|
with pytest.raises(RuntimeError, match="does not define source profile"):
|
|
export_environment_oci_layout(
|
|
paths,
|
|
environment="debian:12-base",
|
|
output_dir=tmp_path / "oci_layouts",
|
|
)
|
|
|
|
|
|
def test_runtime_build_main_dispatches_export(
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
capsys: pytest.CaptureFixture[str],
|
|
tmp_path: Path,
|
|
) -> None:
|
|
source_dir, build_dir, bundle_dir = _make_source_tree(tmp_path)
|
|
runtime_paths = _build_paths(
|
|
source_dir=source_dir,
|
|
build_dir=build_dir,
|
|
bundle_dir=bundle_dir,
|
|
materialized_dir=tmp_path / "materialized",
|
|
platform="linux-x86_64",
|
|
)
|
|
|
|
class StubParser:
|
|
def parse_args(self) -> argparse.Namespace:
|
|
return argparse.Namespace(
|
|
command="export-environment-oci",
|
|
platform="linux-x86_64",
|
|
source_dir=str(source_dir),
|
|
build_dir=str(build_dir),
|
|
bundle_dir=str(bundle_dir),
|
|
materialized_dir=str(tmp_path / "materialized"),
|
|
environment="debian:12-base",
|
|
output_dir=str(tmp_path / "oci_layouts"),
|
|
reference="1.0.0",
|
|
)
|
|
|
|
monkeypatch.setattr("pyro_mcp.runtime_build._build_parser", lambda: StubParser())
|
|
monkeypatch.setattr(
|
|
"pyro_mcp.runtime_build._load_lock",
|
|
lambda paths: _load_lock(runtime_paths),
|
|
)
|
|
monkeypatch.setattr(
|
|
"pyro_mcp.runtime_build.export_environment_oci_layout",
|
|
lambda paths, **kwargs: {
|
|
"environment": kwargs["environment"],
|
|
"layout_dir": str(Path(kwargs["output_dir"]) / "debian_12-base"),
|
|
"manifest_digest": "sha256:test",
|
|
},
|
|
)
|
|
|
|
main()
|
|
|
|
payload = json.loads(capsys.readouterr().out)
|
|
assert payload["environment"] == "debian:12-base"
|
|
assert payload["manifest_digest"] == "sha256:test"
|