feat(release): define commit-addressed bundle index
This commit is contained in:
@@ -0,0 +1,123 @@
|
|||||||
|
"""External, commit-addressed release bundle metadata."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import hashlib
|
||||||
|
import json
|
||||||
|
import re
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from .release_manifest import ReleaseManifestError, parse_manifest
|
||||||
|
|
||||||
|
_COMMIT_RE = re.compile(r"^[0-9a-f]{40}$")
|
||||||
|
_SHA_RE = re.compile(r"^[0-9a-f]{64}$")
|
||||||
|
|
||||||
|
|
||||||
|
def sha256_file(path: Path) -> str:
|
||||||
|
digest = hashlib.sha256()
|
||||||
|
with path.open("rb") as stream:
|
||||||
|
for chunk in iter(lambda: stream.read(1 << 20), b""):
|
||||||
|
digest.update(chunk)
|
||||||
|
return digest.hexdigest()
|
||||||
|
|
||||||
|
|
||||||
|
def build_bundle_index(
|
||||||
|
manifest_data: Any,
|
||||||
|
*,
|
||||||
|
wheel: Path,
|
||||||
|
wheel_url: str,
|
||||||
|
workflow_run: str,
|
||||||
|
published_at: str,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
"""Build and validate the immutable external index for one source commit."""
|
||||||
|
manifest = parse_manifest(manifest_data)
|
||||||
|
if not wheel.is_file() or wheel.suffix != ".whl":
|
||||||
|
raise ReleaseManifestError("release bundle wheel must be one .whl file")
|
||||||
|
if not wheel_url.startswith("https://") or not wheel_url.endswith(wheel.name):
|
||||||
|
raise ReleaseManifestError(
|
||||||
|
"release bundle wheel URL must be HTTPS and end with the wheel filename")
|
||||||
|
if not workflow_run.strip() or not published_at.strip():
|
||||||
|
raise ReleaseManifestError(
|
||||||
|
"release bundle provenance requires workflow_run and published_at")
|
||||||
|
return {
|
||||||
|
"schema": 1,
|
||||||
|
"source_commit": manifest.source_commit,
|
||||||
|
"wheel": {
|
||||||
|
"filename": wheel.name,
|
||||||
|
"url": wheel_url,
|
||||||
|
"sha256": sha256_file(wheel),
|
||||||
|
},
|
||||||
|
"oci": {
|
||||||
|
"orchestrator": manifest.orchestrator_image,
|
||||||
|
"gateway": manifest.gateway_image,
|
||||||
|
},
|
||||||
|
"firecracker": {
|
||||||
|
"orchestrator": {
|
||||||
|
"version": manifest.firecracker_orchestrator.version,
|
||||||
|
"sha256": manifest.firecracker_orchestrator.sha256,
|
||||||
|
},
|
||||||
|
"gateway": {
|
||||||
|
"version": manifest.firecracker_gateway.version,
|
||||||
|
"sha256": manifest.firecracker_gateway.sha256,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"provenance": {
|
||||||
|
"workflow_run": workflow_run.strip(),
|
||||||
|
"published_at": published_at.strip(),
|
||||||
|
},
|
||||||
|
"qualifications": [],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def parse_bundle_index(data: Any) -> dict[str, Any]:
|
||||||
|
"""Validate an index before publication or installer consumption."""
|
||||||
|
if not isinstance(data, dict) or data.get("schema") != 1:
|
||||||
|
raise ReleaseManifestError("release bundle index must use schema 1")
|
||||||
|
commit = data.get("source_commit")
|
||||||
|
if not isinstance(commit, str) or _COMMIT_RE.fullmatch(commit) is None:
|
||||||
|
raise ReleaseManifestError(
|
||||||
|
"release bundle source_commit must be 40 lowercase hex")
|
||||||
|
wheel = data.get("wheel")
|
||||||
|
if not isinstance(wheel, dict):
|
||||||
|
raise ReleaseManifestError("release bundle wheel must be an object")
|
||||||
|
filename, url, digest = (
|
||||||
|
wheel.get("filename"), wheel.get("url"), wheel.get("sha256"))
|
||||||
|
if not isinstance(filename, str) or not filename.endswith(".whl"):
|
||||||
|
raise ReleaseManifestError("release bundle wheel filename must end in .whl")
|
||||||
|
if not isinstance(url, str) or not url.startswith("https://") or not url.endswith(filename):
|
||||||
|
raise ReleaseManifestError(
|
||||||
|
"release bundle wheel URL must be HTTPS and match its filename")
|
||||||
|
if not isinstance(digest, str) or _SHA_RE.fullmatch(digest) is None:
|
||||||
|
raise ReleaseManifestError(
|
||||||
|
"release bundle wheel sha256 must be 64 lowercase hex")
|
||||||
|
manifest_data = {
|
||||||
|
"schema": 1,
|
||||||
|
"source_commit": commit,
|
||||||
|
"oci": data.get("oci"),
|
||||||
|
"firecracker": data.get("firecracker"),
|
||||||
|
}
|
||||||
|
parse_manifest(manifest_data)
|
||||||
|
provenance = data.get("provenance")
|
||||||
|
if not isinstance(provenance, dict) or not all(
|
||||||
|
isinstance(provenance.get(key), str) and provenance[key].strip()
|
||||||
|
for key in ("workflow_run", "published_at")
|
||||||
|
):
|
||||||
|
raise ReleaseManifestError("release bundle provenance is incomplete")
|
||||||
|
qualifications = data.get("qualifications")
|
||||||
|
if not isinstance(qualifications, list):
|
||||||
|
raise ReleaseManifestError("release bundle qualifications must be an array")
|
||||||
|
return data
|
||||||
|
|
||||||
|
|
||||||
|
def canonical_bytes(data: Any) -> bytes:
|
||||||
|
parse_bundle_index(data)
|
||||||
|
return (json.dumps(data, indent=2, sort_keys=True) + "\n").encode("utf-8")
|
||||||
|
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"build_bundle_index",
|
||||||
|
"canonical_bytes",
|
||||||
|
"parse_bundle_index",
|
||||||
|
"sha256_file",
|
||||||
|
]
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
"""Generate the external commit-addressed release bundle index."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import json
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from bot_bottle.release_bundle import build_bundle_index, canonical_bytes
|
||||||
|
|
||||||
|
|
||||||
|
def main(argv: list[str] | None = None) -> int:
|
||||||
|
parser = argparse.ArgumentParser()
|
||||||
|
parser.add_argument("--manifest", required=True, type=Path)
|
||||||
|
parser.add_argument("--wheel", required=True, type=Path)
|
||||||
|
parser.add_argument("--wheel-url", required=True)
|
||||||
|
parser.add_argument("--workflow-run", required=True)
|
||||||
|
parser.add_argument("--published-at", required=True)
|
||||||
|
parser.add_argument("--output", required=True, type=Path)
|
||||||
|
args = parser.parse_args(argv)
|
||||||
|
manifest = json.loads(args.manifest.read_text(encoding="utf-8"))
|
||||||
|
index = build_bundle_index(
|
||||||
|
manifest,
|
||||||
|
wheel=args.wheel,
|
||||||
|
wheel_url=args.wheel_url,
|
||||||
|
workflow_run=args.workflow_run,
|
||||||
|
published_at=args.published_at,
|
||||||
|
)
|
||||||
|
args.output.write_bytes(canonical_bytes(index))
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
raise SystemExit(main())
|
||||||
@@ -0,0 +1,60 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import tempfile
|
||||||
|
import unittest
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from bot_bottle.release_bundle import (
|
||||||
|
build_bundle_index,
|
||||||
|
canonical_bytes,
|
||||||
|
parse_bundle_index,
|
||||||
|
)
|
||||||
|
from bot_bottle.release_manifest import ReleaseManifestError
|
||||||
|
from tests.unit.test_release_manifest import manifest
|
||||||
|
|
||||||
|
|
||||||
|
class TestReleaseBundle(unittest.TestCase):
|
||||||
|
def test_builds_commit_addressed_index(self) -> None:
|
||||||
|
with tempfile.TemporaryDirectory() as tmp:
|
||||||
|
wheel = Path(tmp) / "bot_bottle-0.1.0-py3-none-any.whl"
|
||||||
|
wheel.write_bytes(b"wheel")
|
||||||
|
index = build_bundle_index(
|
||||||
|
manifest(),
|
||||||
|
wheel=wheel,
|
||||||
|
wheel_url=f"https://packages.example/{wheel.name}",
|
||||||
|
workflow_run="actions/123",
|
||||||
|
published_at="2026-07-27T00:00:00Z",
|
||||||
|
)
|
||||||
|
self.assertEqual("1" * 40, index["source_commit"])
|
||||||
|
self.assertEqual(64, len(index["wheel"]["sha256"]))
|
||||||
|
self.assertEqual([], index["qualifications"])
|
||||||
|
self.assertIs(index, parse_bundle_index(index))
|
||||||
|
self.assertTrue(canonical_bytes(index).endswith(b"\n"))
|
||||||
|
|
||||||
|
def test_rejects_mutable_or_mismatched_wheel_url(self) -> None:
|
||||||
|
with tempfile.TemporaryDirectory() as tmp:
|
||||||
|
wheel = Path(tmp) / "release.whl"
|
||||||
|
wheel.write_bytes(b"wheel")
|
||||||
|
with self.assertRaisesRegex(ReleaseManifestError, "HTTPS"):
|
||||||
|
build_bundle_index(
|
||||||
|
manifest(),
|
||||||
|
wheel=wheel,
|
||||||
|
wheel_url="http://packages.example/other.whl",
|
||||||
|
workflow_run="actions/123",
|
||||||
|
published_at="now",
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_rejects_index_with_changed_runtime_identity(self) -> None:
|
||||||
|
with tempfile.TemporaryDirectory() as tmp:
|
||||||
|
wheel = Path(tmp) / "release.whl"
|
||||||
|
wheel.write_bytes(b"wheel")
|
||||||
|
index = build_bundle_index(
|
||||||
|
manifest(),
|
||||||
|
wheel=wheel,
|
||||||
|
wheel_url=f"https://packages.example/{wheel.name}",
|
||||||
|
workflow_run="actions/123",
|
||||||
|
published_at="now",
|
||||||
|
)
|
||||||
|
index["oci"]["gateway"] = "registry/gateway:latest"
|
||||||
|
with self.assertRaisesRegex(ReleaseManifestError, "digest-pinned"):
|
||||||
|
parse_bundle_index(index)
|
||||||
Reference in New Issue
Block a user