Add direct GHCR environment pulls
This commit is contained in:
parent
5d5243df23
commit
75082467f9
5 changed files with 346 additions and 29 deletions
|
|
@ -1,12 +1,99 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import io
|
||||
import json
|
||||
import tarfile
|
||||
import urllib.error
|
||||
import urllib.request
|
||||
from email.message import Message
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from pyro_mcp.runtime import resolve_runtime_paths
|
||||
from pyro_mcp.vm_environments import EnvironmentStore, get_environment, list_environments
|
||||
from pyro_mcp.runtime import RuntimePaths, resolve_runtime_paths
|
||||
from pyro_mcp.vm_environments import (
|
||||
EnvironmentStore,
|
||||
VmEnvironment,
|
||||
get_environment,
|
||||
list_environments,
|
||||
)
|
||||
|
||||
|
||||
class FakeResponse:
|
||||
def __init__(self, payload: bytes, *, headers: dict[str, str] | None = None) -> None:
|
||||
self._buffer = io.BytesIO(payload)
|
||||
self.headers = headers or {}
|
||||
|
||||
def read(self, size: int = -1) -> bytes:
|
||||
return self._buffer.read(size)
|
||||
|
||||
def __enter__(self) -> FakeResponse:
|
||||
return self
|
||||
|
||||
def __exit__(self, exc_type: object, exc: object, tb: object) -> None:
|
||||
del exc_type, exc, tb
|
||||
|
||||
|
||||
def _fake_runtime_paths(tmp_path: Path) -> RuntimePaths:
|
||||
bundle_parent = tmp_path / "runtime"
|
||||
bundle_root = bundle_parent / "linux-x86_64"
|
||||
manifest_path = bundle_root / "manifest.json"
|
||||
firecracker_bin = bundle_root / "bin" / "firecracker"
|
||||
jailer_bin = bundle_root / "bin" / "jailer"
|
||||
guest_agent_path = bundle_root / "guest" / "pyro_guest_agent.py"
|
||||
artifacts_dir = bundle_root / "profiles"
|
||||
notice_path = bundle_parent / "NOTICE"
|
||||
|
||||
artifacts_dir.mkdir(parents=True, exist_ok=True)
|
||||
firecracker_bin.parent.mkdir(parents=True, exist_ok=True)
|
||||
jailer_bin.parent.mkdir(parents=True, exist_ok=True)
|
||||
guest_agent_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
manifest_path.write_text('{"platform": "linux-x86_64"}\n', encoding="utf-8")
|
||||
firecracker_bin.write_text("firecracker\n", encoding="utf-8")
|
||||
jailer_bin.write_text("jailer\n", encoding="utf-8")
|
||||
guest_agent_path.write_text("print('guest')\n", encoding="utf-8")
|
||||
notice_path.write_text("notice\n", encoding="utf-8")
|
||||
|
||||
return RuntimePaths(
|
||||
bundle_root=bundle_root,
|
||||
manifest_path=manifest_path,
|
||||
firecracker_bin=firecracker_bin,
|
||||
jailer_bin=jailer_bin,
|
||||
guest_agent_path=guest_agent_path,
|
||||
artifacts_dir=artifacts_dir,
|
||||
notice_path=notice_path,
|
||||
manifest={"platform": "linux-x86_64"},
|
||||
)
|
||||
|
||||
|
||||
def _sha256_digest(payload: bytes) -> str:
|
||||
return f"sha256:{hashlib.sha256(payload).hexdigest()}"
|
||||
|
||||
|
||||
def _layer_archive(filename: str, content: bytes) -> bytes:
|
||||
archive_buffer = io.BytesIO()
|
||||
with tarfile.open(fileobj=archive_buffer, mode="w:gz") as archive:
|
||||
info = tarfile.TarInfo(name=filename)
|
||||
info.size = len(content)
|
||||
archive.addfile(info, io.BytesIO(content))
|
||||
return archive_buffer.getvalue()
|
||||
|
||||
|
||||
def _authorization_header(request: object) -> str | None:
|
||||
if isinstance(request, urllib.request.Request):
|
||||
for key, value in request.header_items():
|
||||
if key.lower() == "authorization":
|
||||
return value
|
||||
return None
|
||||
|
||||
|
||||
def _http_headers(headers: dict[str, str]) -> Message:
|
||||
message = Message()
|
||||
for key, value in headers.items():
|
||||
message[key] = value
|
||||
return message
|
||||
|
||||
|
||||
def test_list_environments_includes_expected_entries() -> None:
|
||||
|
|
@ -151,3 +238,185 @@ def test_environment_store_prunes_stale_entries(tmp_path: Path) -> None:
|
|||
result = store.prune_environments()
|
||||
|
||||
assert result["count"] == 5
|
||||
|
||||
|
||||
def test_fetch_oci_manifest_resolves_linux_amd64_index_with_bearer_auth(
|
||||
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
runtime_paths = _fake_runtime_paths(tmp_path)
|
||||
store = EnvironmentStore(runtime_paths=runtime_paths, cache_dir=tmp_path / "cache")
|
||||
spec = VmEnvironment(
|
||||
name="debian:12-ghcr",
|
||||
version="1.0.0",
|
||||
description="OCI-backed environment",
|
||||
default_packages=("bash", "git"),
|
||||
distribution="debian",
|
||||
distribution_version="12",
|
||||
source_profile="missing-profile",
|
||||
oci_registry="ghcr.io",
|
||||
oci_repository="thaloco/pyro-environments/debian-12",
|
||||
oci_reference="1.0.0",
|
||||
)
|
||||
|
||||
child_manifest = {
|
||||
"schemaVersion": 2,
|
||||
"mediaType": "application/vnd.oci.image.manifest.v1+json",
|
||||
"layers": [],
|
||||
}
|
||||
child_payload = json.dumps(child_manifest).encode("utf-8")
|
||||
child_digest = _sha256_digest(child_payload)
|
||||
index_manifest = {
|
||||
"schemaVersion": 2,
|
||||
"mediaType": "application/vnd.oci.image.index.v1+json",
|
||||
"manifests": [
|
||||
{
|
||||
"digest": "sha256:arm64digest",
|
||||
"mediaType": "application/vnd.oci.image.manifest.v1+json",
|
||||
"platform": {"os": "linux", "architecture": "arm64"},
|
||||
},
|
||||
{
|
||||
"digest": child_digest,
|
||||
"mediaType": "application/vnd.oci.image.manifest.v1+json",
|
||||
"platform": {"os": "linux", "architecture": "amd64"},
|
||||
},
|
||||
],
|
||||
}
|
||||
index_payload = json.dumps(index_manifest).encode("utf-8")
|
||||
index_digest = _sha256_digest(index_payload)
|
||||
authorized_urls: list[str] = []
|
||||
|
||||
def fake_urlopen(request: object, timeout: int = 90) -> FakeResponse:
|
||||
del timeout
|
||||
url = request.full_url if isinstance(request, urllib.request.Request) else str(request)
|
||||
if url.startswith("https://ghcr.io/token?"):
|
||||
return FakeResponse(b'{"token":"secret-token"}')
|
||||
authorization = _authorization_header(request)
|
||||
if url.endswith("/manifests/1.0.0"):
|
||||
if authorization is None:
|
||||
raise urllib.error.HTTPError(
|
||||
url,
|
||||
401,
|
||||
"Unauthorized",
|
||||
_http_headers(
|
||||
{
|
||||
"WWW-Authenticate": (
|
||||
'Bearer realm="https://ghcr.io/token",'
|
||||
'service="ghcr.io",'
|
||||
'scope="repository:thaloco/pyro-environments/debian-12:pull"'
|
||||
)
|
||||
}
|
||||
),
|
||||
io.BytesIO(b""),
|
||||
)
|
||||
authorized_urls.append(url)
|
||||
return FakeResponse(
|
||||
index_payload,
|
||||
headers={"Docker-Content-Digest": index_digest},
|
||||
)
|
||||
if url.endswith(f"/manifests/{child_digest}"):
|
||||
if authorization is None:
|
||||
raise urllib.error.HTTPError(
|
||||
url,
|
||||
401,
|
||||
"Unauthorized",
|
||||
_http_headers(
|
||||
{
|
||||
"WWW-Authenticate": (
|
||||
'Bearer realm="https://ghcr.io/token",'
|
||||
'service="ghcr.io",'
|
||||
'scope="repository:thaloco/pyro-environments/debian-12:pull"'
|
||||
)
|
||||
}
|
||||
),
|
||||
io.BytesIO(b""),
|
||||
)
|
||||
authorized_urls.append(url)
|
||||
assert authorization == "Bearer secret-token"
|
||||
return FakeResponse(
|
||||
child_payload,
|
||||
headers={"Docker-Content-Digest": child_digest},
|
||||
)
|
||||
raise AssertionError(f"unexpected OCI request: {url}")
|
||||
|
||||
monkeypatch.setattr(urllib.request, "urlopen", fake_urlopen)
|
||||
|
||||
manifest, resolved_digest = store._fetch_oci_manifest(spec) # noqa: SLF001
|
||||
|
||||
assert manifest == child_manifest
|
||||
assert resolved_digest == child_digest
|
||||
assert authorized_urls == [
|
||||
"https://ghcr.io/v2/thaloco/pyro-environments/debian-12/manifests/1.0.0",
|
||||
f"https://ghcr.io/v2/thaloco/pyro-environments/debian-12/manifests/{child_digest}",
|
||||
]
|
||||
|
||||
|
||||
def test_environment_store_installs_from_oci_when_runtime_source_missing(
|
||||
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
runtime_paths = _fake_runtime_paths(tmp_path)
|
||||
kernel_layer = _layer_archive("vmlinux", b"kernel\n")
|
||||
rootfs_layer = _layer_archive("rootfs.ext4", b"rootfs\n")
|
||||
kernel_digest = _sha256_digest(kernel_layer)
|
||||
rootfs_digest = _sha256_digest(rootfs_layer)
|
||||
manifest = {
|
||||
"schemaVersion": 2,
|
||||
"mediaType": "application/vnd.oci.image.manifest.v1+json",
|
||||
"layers": [
|
||||
{
|
||||
"mediaType": "application/vnd.oci.image.layer.v1.tar+gzip",
|
||||
"digest": kernel_digest,
|
||||
"size": len(kernel_layer),
|
||||
},
|
||||
{
|
||||
"mediaType": "application/vnd.oci.image.layer.v1.tar+gzip",
|
||||
"digest": rootfs_digest,
|
||||
"size": len(rootfs_layer),
|
||||
},
|
||||
],
|
||||
}
|
||||
manifest_payload = json.dumps(manifest).encode("utf-8")
|
||||
manifest_digest = _sha256_digest(manifest_payload)
|
||||
environment = VmEnvironment(
|
||||
name="debian:12-ghcr",
|
||||
version="1.0.0",
|
||||
description="OCI-backed environment",
|
||||
default_packages=("bash", "git"),
|
||||
distribution="debian",
|
||||
distribution_version="12",
|
||||
source_profile="missing-profile",
|
||||
oci_registry="ghcr.io",
|
||||
oci_repository="thaloco/pyro-environments/debian-12",
|
||||
oci_reference="1.0.0",
|
||||
)
|
||||
|
||||
def fake_urlopen(request: object, timeout: int = 90) -> FakeResponse:
|
||||
del timeout
|
||||
url = request.full_url if isinstance(request, urllib.request.Request) else str(request)
|
||||
if url.endswith("/manifests/1.0.0"):
|
||||
return FakeResponse(
|
||||
manifest_payload,
|
||||
headers={"Docker-Content-Digest": manifest_digest},
|
||||
)
|
||||
if url.endswith(f"/blobs/{kernel_digest}"):
|
||||
return FakeResponse(kernel_layer)
|
||||
if url.endswith(f"/blobs/{rootfs_digest}"):
|
||||
return FakeResponse(rootfs_layer)
|
||||
raise AssertionError(f"unexpected OCI request: {url}")
|
||||
|
||||
monkeypatch.setattr(urllib.request, "urlopen", fake_urlopen)
|
||||
monkeypatch.setattr(
|
||||
"pyro_mcp.vm_environments.CATALOG",
|
||||
{environment.name: environment},
|
||||
)
|
||||
store = EnvironmentStore(runtime_paths=runtime_paths, cache_dir=tmp_path / "cache")
|
||||
|
||||
installed = store.ensure_installed(environment.name)
|
||||
|
||||
assert installed.kernel_image.read_text(encoding="utf-8") == "kernel\n"
|
||||
assert installed.rootfs_image.read_text(encoding="utf-8") == "rootfs\n"
|
||||
assert installed.source == (
|
||||
"oci://ghcr.io/thaloco/pyro-environments/debian-12"
|
||||
f"@{manifest_digest}"
|
||||
)
|
||||
metadata = json.loads((installed.install_dir / "environment.json").read_text(encoding="utf-8"))
|
||||
assert metadata["source_digest"] == manifest_digest
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue