atlas-iac/ci/scripts/hermes_oci_promote.py

194 lines
7.3 KiB
Python
Raw Normal View History

#!/usr/bin/env python3
"""Publish a validated Hermes candidate manifest under its Flux release tag."""
from __future__ import annotations
import argparse
import base64
import json
import os
import re
import urllib.error
import urllib.parse
import urllib.request
from pathlib import Path
from typing import Any, Callable
REGISTRY_ORIGIN = "https://registry.bstein.dev"
DESTINATION_PATTERN = re.compile(
r"^registry\.bstein\.dev/bstein/"
r"(?P<component>hermes-(?:agent|webui|chat-router|jetson-(?:stt|tts))):"
r"git-(?P<revision>[0-9a-f]{40})-build-(?P<build>[1-9][0-9]*)$"
)
DIGEST_PATTERN = re.compile(r"^sha256:[0-9a-f]{64}$")
MANIFEST_TYPES = {
"application/vnd.docker.distribution.manifest.v2+json",
"application/vnd.docker.distribution.manifest.list.v2+json",
"application/vnd.oci.image.manifest.v1+json",
"application/vnd.oci.image.index.v1+json",
}
MAX_MANIFEST_BYTES = 4 * 1024 * 1024
class _NoRedirect(urllib.request.HTTPRedirectHandler):
"""Never forward registry credentials to another origin."""
def redirect_request(self, _request, _file, _code, _message, _headers, _url):
return None
def _registry_request(request: urllib.request.Request, timeout: int) -> Any:
"""Return normal and HTTP error responses without following redirects."""
opener = urllib.request.build_opener(_NoRedirect())
try:
return opener.open(request, timeout=timeout)
except urllib.error.HTTPError as exc:
return exc
def _status(response: Any) -> int:
"""Normalize urllib response and HTTPError status fields."""
return int(getattr(response, "status", getattr(response, "code", 0)))
def _authorization(username: str, password: str) -> str:
"""Build a Basic authorization value without placing it in a URL."""
if not username or not password:
raise RuntimeError("Harbor credentials are unavailable")
encoded = base64.b64encode(f"{username}:{password}".encode()).decode("ascii")
return f"Basic {encoded}"
def _validated_release(
destination: str,
digest: str,
source_revision: str,
build_number: str,
) -> tuple[str, str, str]:
"""Bind the candidate and release tag to one reviewed source and build."""
match = DESTINATION_PATTERN.fullmatch(destination.strip())
if not match:
raise ValueError("invalid Hermes candidate destination")
if match.group("revision") != source_revision.strip():
raise ValueError("candidate source revision does not match evidence")
if match.group("build") != build_number.strip():
raise ValueError("candidate build number does not match evidence")
normalized_digest = digest.strip()
if not DIGEST_PATTERN.fullmatch(normalized_digest):
raise ValueError("invalid candidate digest")
candidate_tag = destination.rsplit(":", 1)[1]
return match.group("component"), candidate_tag, normalized_digest
def _manifest_url(component: str, tag: str) -> str:
"""Return one same-origin, path-escaped Docker Registry manifest URL."""
encoded_tag = urllib.parse.quote(tag, safe="")
return f"{REGISTRY_ORIGIN}/v2/bstein/{component}/manifests/{encoded_tag}"
def promote_candidate(
*,
destination: str,
digest: str,
source_revision: str,
build_number: str,
username: str,
password: str,
opener: Callable[[urllib.request.Request, int], Any] = _registry_request,
) -> dict[str, str]:
"""Copy an exact candidate manifest to the immutable ``-release`` tag."""
component, candidate_tag, normalized_digest = _validated_release(
destination, digest, source_revision, build_number
)
release_tag = f"{candidate_tag}-release"
authorization = _authorization(username, password)
accept = ", ".join(sorted(MANIFEST_TYPES))
candidate_request = urllib.request.Request(
_manifest_url(component, candidate_tag),
headers={"Accept": accept, "Authorization": authorization},
method="GET",
)
with opener(candidate_request, 30) as response:
if _status(response) != 200:
raise RuntimeError(f"candidate manifest returned HTTP {_status(response)}")
manifest = response.read(MAX_MANIFEST_BYTES + 1)
if len(manifest) > MAX_MANIFEST_BYTES:
raise RuntimeError("candidate manifest exceeded the size limit")
observed_digest = response.headers.get("Docker-Content-Digest", "")
content_type = response.headers.get("Content-Type", "").split(";", 1)[0]
if observed_digest != normalized_digest:
raise RuntimeError("candidate manifest digest does not match build evidence")
if content_type not in MANIFEST_TYPES:
raise RuntimeError("candidate manifest returned an unsupported content type")
release_url = _manifest_url(component, release_tag)
head_request = urllib.request.Request(
release_url,
headers={"Accept": accept, "Authorization": authorization},
method="HEAD",
)
with opener(head_request, 20) as response:
head_status = _status(response)
existing_digest = response.headers.get("Docker-Content-Digest", "")
if head_status == 200:
if existing_digest != normalized_digest:
raise RuntimeError("release tag already exists with another digest")
result = "already-present"
elif head_status == 404:
put_request = urllib.request.Request(
release_url,
data=manifest,
headers={
"Authorization": authorization,
"Content-Type": content_type,
},
method="PUT",
)
with opener(put_request, 30) as response:
put_status = _status(response)
promoted_digest = response.headers.get("Docker-Content-Digest", "")
if put_status not in {201, 202}:
raise RuntimeError(f"release manifest returned HTTP {put_status}")
if promoted_digest and promoted_digest != normalized_digest:
raise RuntimeError("release manifest digest changed during promotion")
result = "published"
else:
raise RuntimeError(f"release tag preflight returned HTTP {head_status}")
return {
"component": component,
"digest": normalized_digest,
"release_tag": release_tag,
"result": result,
"source_revision": source_revision,
}
def main() -> int:
"""Promote one evidence-bound candidate and print credential-free metadata."""
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--destination", required=True)
parser.add_argument("--digest-file", required=True, type=Path)
parser.add_argument("--source-revision", required=True)
parser.add_argument("--build-number", required=True)
args = parser.parse_args()
try:
result = promote_candidate(
destination=args.destination,
digest=args.digest_file.read_text(encoding="utf-8"),
source_revision=args.source_revision,
build_number=args.build_number,
username=os.environ.get("HARBOR_USER", ""),
password=os.environ.get("HARBOR_PASSWORD", ""),
)
except (OSError, ValueError, RuntimeError, urllib.error.URLError) as exc:
print(json.dumps({"error": str(exc)}, sort_keys=True))
return 1
print(json.dumps(result, indent=2, sort_keys=True))
return 0
if __name__ == "__main__": # pragma: no cover - exercised through main()
raise SystemExit(main())