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 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BvMSXH8VH2tMWXanb8SJdf
This commit is contained in:
parent
35f650ef41
commit
2c91aea01d
@ -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(
|
||||
|
||||
@ -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(
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user