2026-08-23 13:41:58 -03:00
|
|
|
#!/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(
|
2026-08-23 15:54:56 -03:00
|
|
|
r"^registry\.bstein\.dev/bstein/"
|
2026-08-24 02:46:36 -03:00
|
|
|
r"(?P<component>hermes-(?:agent|webui|chat-router|jetson-(?:stt|tts))):"
|
2026-08-23 13:41:58 -03:00
|
|
|
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())
|