From 2c91aea01d6b874e247c2fc3528e5b4bb580ffe4 Mon Sep 17 00:00:00 2001 From: jenkins Date: Tue, 25 Aug 2026 19:37:27 -0300 Subject: [PATCH] fix(hermes-webui): verify OCI revision label on multi-arch index children The webui release handoff verified org.opencontainers.image.revision on the Harbor artifact's own extra_attrs.config.Labels. That works for a single-arch image, but a multi-arch manifest list has no top-level config, so Harbor reports the label on each per-arch child. build-38 built + published the index fine, then failed post-publish with 'Harbor artifact omitted OCI image labels'. verify_registry_digest now checks the top-level config labels when present (single-arch, unchanged) and otherwise walks the index references, fetching each child artifact by digest and asserting its revision label. Mirrors how the agent image lane already tolerates a multi-arch index, without dropping the supply-chain label check. Adds multi-arch pass/reject tests. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01BvMSXH8VH2tMWXanb8SJdf --- ci/scripts/hermes_webui_release.py | 90 +++++++++++++++++- testing/tests/test_hermes_webui_release.py | 105 +++++++++++++++++++++ 2 files changed, 190 insertions(+), 5 deletions(-) diff --git a/ci/scripts/hermes_webui_release.py b/ci/scripts/hermes_webui_release.py index 6f8ad274..4f082917 100755 --- a/ci/scripts/hermes_webui_release.py +++ b/ci/scripts/hermes_webui_release.py @@ -231,6 +231,84 @@ def assert_tag_absent( raise RuntimeError(f"Harbor destination preflight returned HTTP {status}") +OCI_REVISION_LABEL = "org.opencontainers.image.revision" + + +def _child_artifact_response( + child_digest: str, + *, + username: str, + password: str, + opener: Callable[[urllib.request.Request, int], Any] = _registry_request, +) -> tuple[int, bytes]: + """Read one per-arch child artifact of a multi-arch index by its digest.""" + if not username or not password: + raise RuntimeError("Harbor credentials are empty") + encoded = urllib.parse.quote(child_digest, safe="") + auth = base64.b64encode(f"{username}:{password}".encode()).decode("ascii") + request = urllib.request.Request( + f"{HARBOR_API_ORIGIN}/projects/{HARBOR_PROJECT}/repositories/" + f"{HARBOR_REPOSITORY}/artifacts/{encoded}" + "?with_immutable_status=true", + headers={"Accept": "application/json", "Authorization": f"Basic {auth}"}, + method="GET", + ) + with opener(request, 20) as response: + body = response.read(1_048_577) + if len(body) > 1_048_576: + raise RuntimeError("Harbor child artifact response exceeded the size limit") + return int(response.status), body + + +def _config_labels(artifact: dict[str, Any]) -> Any: + """Extract the OCI config labels Harbor reports for one artifact, if any.""" + return ((artifact.get("extra_attrs") or {}).get("config") or {}).get("Labels") + + +def _verify_source_revision_label( + artifact: dict[str, Any], + source_revision: str, + *, + username: str, + password: str, + opener: Callable[[urllib.request.Request, int], Any], +) -> None: + """Assert the published image carries the reviewed source revision label. + + A single-arch image exposes ``org.opencontainers.image.revision`` on its own + config. A multi-arch manifest list has no top-level config, so Harbor reports + the label on each per-arch child instead; verify every child in that case. + """ + labels = _config_labels(artifact) + if isinstance(labels, dict): + if labels.get(OCI_REVISION_LABEL) != source_revision: + raise RuntimeError("Harbor OCI source-revision label does not match") + return + references = artifact.get("references") + if not isinstance(references, list) or not references: + raise RuntimeError("Harbor artifact omitted OCI image labels") + for reference in references: + child_digest = str((reference or {}).get("child_digest") or "").strip() + if not DIGEST_PATTERN.fullmatch(child_digest): + raise RuntimeError("Harbor index reference omitted a valid child digest") + status, body = _child_artifact_response( + child_digest, username=username, password=password, opener=opener + ) + if status != 200: + raise RuntimeError( + f"Harbor child artifact verification returned HTTP {status}" + ) + try: + child = json.loads(body.decode("utf-8")) + except (UnicodeDecodeError, json.JSONDecodeError) as exc: + raise RuntimeError("Harbor returned invalid child artifact JSON") from exc + child_labels = _config_labels(child) + if not isinstance(child_labels, dict): + raise RuntimeError("Harbor artifact omitted OCI image labels") + if child_labels.get(OCI_REVISION_LABEL) != source_revision: + raise RuntimeError("Harbor OCI source-revision label does not match") + + def verify_registry_digest( destination: str, digest: str, @@ -269,11 +347,13 @@ def verify_registry_digest( raise RuntimeError("Harbor artifact does not contain the expected tag") if matching_tags[0].get("immutable") is not True: raise RuntimeError("Harbor did not enforce the expected tag as immutable") - labels = ((artifact.get("extra_attrs") or {}).get("config") or {}).get("Labels") - if not isinstance(labels, dict): - raise RuntimeError("Harbor artifact omitted OCI image labels") - if labels.get("org.opencontainers.image.revision") != source_revision: - raise RuntimeError("Harbor OCI source-revision label does not match") + _verify_source_revision_label( + artifact, + source_revision, + username=username, + password=password, + opener=opener, + ) def validate_release_artifacts( diff --git a/testing/tests/test_hermes_webui_release.py b/testing/tests/test_hermes_webui_release.py index 506b67f9..f6e13f1f 100644 --- a/testing/tests/test_hermes_webui_release.py +++ b/testing/tests/test_hermes_webui_release.py @@ -446,6 +446,111 @@ def test_release_rejects_missing_or_wrong_harbor_source_revision(labels) -> None ) +def test_release_verifies_multiarch_index_child_source_revision() -> None: + """A multi-arch index carries the revision label on each per-arch child.""" + module = _load(RELEASE, "webui_registry_multiarch") + revision = "a" * 40 + destination = f"{module.DEFAULT_IMAGE}:git-{revision}-build-9" + digest = "sha256:" + "b" * 64 + child_amd64 = "sha256:" + "1" * 64 + child_arm64 = "sha256:" + "2" * 64 + seen = [] + + def opener(request, _timeout): + url = request.full_url + seen.append(url) + if "1" * 64 in url or "2" * 64 in url: + return _Response( + json.dumps( + { + "digest": child_amd64 if "1" * 64 in url else child_arm64, + "extra_attrs": { + "config": { + "Labels": { + "org.opencontainers.image.revision": revision, + } + } + }, + } + ).encode() + ) + return _Response( + json.dumps( + { + "digest": digest, + "tags": [{"name": f"git-{revision}-build-9", "immutable": True}], + "extra_attrs": {"config": {}}, + "references": [ + { + "child_digest": child_amd64, + "platform": {"architecture": "amd64", "os": "linux"}, + }, + { + "child_digest": child_arm64, + "platform": {"architecture": "arm64", "os": "linux"}, + }, + ], + } + ).encode() + ) + + module.verify_registry_digest( + destination, + digest, + revision, + username="robot", + password="private", + opener=opener, + ) + assert sum(("1" * 64 in u or "2" * 64 in u) for u in seen) == 2 + + +def test_release_rejects_multiarch_child_missing_source_revision() -> None: + """A multi-arch child that lacks the reviewed revision label is rejected.""" + module = _load(RELEASE, "webui_registry_multiarch_bad") + revision = "a" * 40 + destination = f"{module.DEFAULT_IMAGE}:git-{revision}-build-9" + digest = "sha256:" + "b" * 64 + child_arm64 = "sha256:" + "2" * 64 + + def opener(request, _timeout): + url = request.full_url + if "2" * 64 in url: + return _Response( + json.dumps( + { + "digest": child_arm64, + "extra_attrs": {"config": {"Labels": {}}}, + } + ).encode() + ) + return _Response( + json.dumps( + { + "digest": digest, + "tags": [{"name": f"git-{revision}-build-9", "immutable": True}], + "extra_attrs": {"config": {}}, + "references": [ + { + "child_digest": child_arm64, + "platform": {"architecture": "arm64", "os": "linux"}, + }, + ], + } + ).encode() + ) + + with pytest.raises(RuntimeError, match="OCI (image labels|source-revision label)"): + module.verify_registry_digest( + destination, + digest, + revision, + username="robot", + password="private", + opener=opener, + ) + + def test_flux_tracks_webui_policy_before_jenkins() -> None: """The immutable Harbor rule is reviewed desired state, not a pipeline wish.""" harbor = yaml.safe_load(