build(hermes-agent): multi-arch image via two native kaniko legs
Repoint the hermes-agent base FROM at the upstream multi-arch OCI INDEX
digest (tag v2026.7.7.2, revision 9de9c25f) whose arm64 leaf is byte-for-byte
the previously pinned single-arch base, so the arm64 build is unchanged while
the same reviewed version now also resolves an amd64 leaf. Kaniko selects the
matching leaf per build platform.
Rework the release pipeline to build both arches natively and promote a
multi-arch image without switching off kaniko or weakening any existing
security assertion:
- Keep the arm64 kaniko leg on the unchanged rpi5 coordinating pod; it now
pushes an arch-suffixed candidate tag (...-build-<N>-arm64).
- Add a second native amd64 kaniko leg on a titan-24-pinned, tolerating,
resource-capped pod (ceiling strictly below the arm64 leg) that
independently re-verifies the reviewed revision and stashes its leaf
evidence (...-build-<N>-amd64).
- Add ci/scripts/hermes_multiarch_combine.py: a pure-python, fail-closed
combiner that re-reads each per-arch leaf from the registry, proves its
digest AND its config architecture, assembles a Docker manifest LIST
(already inside the promote allow-list), refuses to overwrite an existing
final tag, publishes the arch-less ...-build-<N> tag, and re-verifies the
registry resolved the exact index referencing exactly the two leaves. It
emits the index digest in the SAME digest-file/image-file format the
single-arch step produced, so render/verify-evidence/hermes_oci_promote.py
promote the INDEX with no change to those scripts.
Tests: add test_hermes_multiarch_combine.py (full hash/verification chain);
strengthen the image-builder suites for the two-arch topology (both kaniko
legs carry the reviewed heredoc-compat build-arg; amd64 leg pinned+capped+
boundary-checked; combine stage wiring; expanded evidence archive) without
weakening the arm64-leg assertions.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BvMSXH8VH2tMWXanb8SJdf
2026-08-25 11:05:29 -03:00
|
|
|
#!/usr/bin/env python3
|
|
|
|
|
"""Assemble a fail-closed multi-arch manifest list from two per-arch leaves.
|
|
|
|
|
|
|
|
|
|
Kaniko builds one native image per architecture (arm64 on an rpi5 pod, amd64 on
|
|
|
|
|
titan-24) and pushes each under an arch-suffixed candidate tag
|
|
|
|
|
``...-build-<N>-<arch>``. Kaniko cannot combine, so this step:
|
|
|
|
|
|
|
|
|
|
1. Independently re-reads each per-arch candidate manifest from the registry and
|
|
|
|
|
binds it to the exact Kaniko digest evidence.
|
|
|
|
|
2. Proves each leaf really is the architecture it claims by reading its image
|
|
|
|
|
config (a swapped or cross-built leaf fails closed here).
|
|
|
|
|
3. Builds a Docker manifest *list* (not an OCI index) from the two verified
|
|
|
|
|
leaves -- Docker manifest lists are already inside the promotion allow-list,
|
|
|
|
|
so this keeps the security surface of ``hermes_oci_promote.py`` unchanged.
|
|
|
|
|
4. Refuses to overwrite an existing final tag, PUTs the list to the final
|
|
|
|
|
``...-build-<N>`` tag, and re-reads it to confirm the registry resolved the
|
|
|
|
|
exact index digest referencing exactly the two expected leaves.
|
|
|
|
|
|
|
|
|
|
The output ``--digest-file``/``--image-file`` deliberately use the SAME format
|
|
|
|
|
the single-arch Kaniko step produced (``<digest>`` and ``<destination>@<digest>``
|
|
|
|
|
for the final, arch-less tag). The whole downstream evidence chain --
|
|
|
|
|
``hermes_image_release.py`` render/verify-evidence and ``hermes_oci_promote.py``
|
|
|
|
|
-- therefore promotes the multi-arch INDEX with no further change.
|
|
|
|
|
"""
|
|
|
|
|
|
|
|
|
|
from __future__ import annotations
|
|
|
|
|
|
|
|
|
|
import argparse
|
|
|
|
|
import base64
|
|
|
|
|
import hashlib
|
|
|
|
|
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"
|
|
|
|
|
# The final (arch-less) Flux-visible tag; identical contract to the promoter.
|
build(hermes-webui): multi-arch image (arm64 + amd64)
Make registry.bstein.dev/bstein/hermes-webui a linux/amd64 + linux/arm64
manifest list so the agent pod's `hux` sidecar (which runs the webui image)
can schedule onto the amd64 node titan-22. Reuses the hermes-agent multi-arch
pattern already on main.
- Dockerfile.hermes-webui: repoint both FROMs to multi-arch, internal sources.
The upstream WebUI base (ghcr sha256:a83a3893..., already a multi-arch OCI
index) is now pulled from the in-cluster Harbor mirror; the agent base moves
from the retired arm64-only leaf (81970563) to the multi-arch agent index
(a68d1c4d). Kaniko selects the matching arch leaf per build node.
- services/harbor/hermes-webui-base-mirror-job.yaml: new suspended, operator-run
skopeo `copy --all` Job mirroring the upstream WebUI base index into Harbor's
`mirror` project (modeled on hermes-agent-base-mirror-job.yaml; reuses the
generic ensure-project helper). Wired into the harbor kustomization.
- Jenkinsfile.hermes-webui-image: arm64 leg (titan-20) + amd64 leg (titan-24,
hostname+arch pin, toleration Exists, resource-capped, own checkout scm) +
Combine multi-arch index stage; per-arch evidence archived alongside the index.
- hermes_multiarch_combine.py: generalize the destination pattern/component to
serve both hermes-agent and hermes-webui (fail-closed to just those two).
- Tests updated to the two-arch topology (two legs, combine, both FROM bases,
the mirror Job, twelve archived evidence files).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BvMSXH8VH2tMWXanb8SJdf
2026-08-25 18:17:35 -03:00
|
|
|
# Both Hermes images that the multi-arch pipelines publish share the exact same
|
|
|
|
|
# arch-less final-tag contract; the repository name is the only difference and is
|
|
|
|
|
# captured here so the combiner stays fail-closed to just these two components.
|
build(hermes-agent): multi-arch image via two native kaniko legs
Repoint the hermes-agent base FROM at the upstream multi-arch OCI INDEX
digest (tag v2026.7.7.2, revision 9de9c25f) whose arm64 leaf is byte-for-byte
the previously pinned single-arch base, so the arm64 build is unchanged while
the same reviewed version now also resolves an amd64 leaf. Kaniko selects the
matching leaf per build platform.
Rework the release pipeline to build both arches natively and promote a
multi-arch image without switching off kaniko or weakening any existing
security assertion:
- Keep the arm64 kaniko leg on the unchanged rpi5 coordinating pod; it now
pushes an arch-suffixed candidate tag (...-build-<N>-arm64).
- Add a second native amd64 kaniko leg on a titan-24-pinned, tolerating,
resource-capped pod (ceiling strictly below the arm64 leg) that
independently re-verifies the reviewed revision and stashes its leaf
evidence (...-build-<N>-amd64).
- Add ci/scripts/hermes_multiarch_combine.py: a pure-python, fail-closed
combiner that re-reads each per-arch leaf from the registry, proves its
digest AND its config architecture, assembles a Docker manifest LIST
(already inside the promote allow-list), refuses to overwrite an existing
final tag, publishes the arch-less ...-build-<N> tag, and re-verifies the
registry resolved the exact index referencing exactly the two leaves. It
emits the index digest in the SAME digest-file/image-file format the
single-arch step produced, so render/verify-evidence/hermes_oci_promote.py
promote the INDEX with no change to those scripts.
Tests: add test_hermes_multiarch_combine.py (full hash/verification chain);
strengthen the image-builder suites for the two-arch topology (both kaniko
legs carry the reviewed heredoc-compat build-arg; amd64 leg pinned+capped+
boundary-checked; combine stage wiring; expanded evidence archive) without
weakening the arm64-leg assertions.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BvMSXH8VH2tMWXanb8SJdf
2026-08-25 11:05:29 -03:00
|
|
|
DESTINATION_PATTERN = re.compile(
|
build(hermes-webui): multi-arch image (arm64 + amd64)
Make registry.bstein.dev/bstein/hermes-webui a linux/amd64 + linux/arm64
manifest list so the agent pod's `hux` sidecar (which runs the webui image)
can schedule onto the amd64 node titan-22. Reuses the hermes-agent multi-arch
pattern already on main.
- Dockerfile.hermes-webui: repoint both FROMs to multi-arch, internal sources.
The upstream WebUI base (ghcr sha256:a83a3893..., already a multi-arch OCI
index) is now pulled from the in-cluster Harbor mirror; the agent base moves
from the retired arm64-only leaf (81970563) to the multi-arch agent index
(a68d1c4d). Kaniko selects the matching arch leaf per build node.
- services/harbor/hermes-webui-base-mirror-job.yaml: new suspended, operator-run
skopeo `copy --all` Job mirroring the upstream WebUI base index into Harbor's
`mirror` project (modeled on hermes-agent-base-mirror-job.yaml; reuses the
generic ensure-project helper). Wired into the harbor kustomization.
- Jenkinsfile.hermes-webui-image: arm64 leg (titan-20) + amd64 leg (titan-24,
hostname+arch pin, toleration Exists, resource-capped, own checkout scm) +
Combine multi-arch index stage; per-arch evidence archived alongside the index.
- hermes_multiarch_combine.py: generalize the destination pattern/component to
serve both hermes-agent and hermes-webui (fail-closed to just those two).
- Tests updated to the two-arch topology (two legs, combine, both FROM bases,
the mirror Job, twelve archived evidence files).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BvMSXH8VH2tMWXanb8SJdf
2026-08-25 18:17:35 -03:00
|
|
|
r"^registry\.bstein\.dev/bstein/(?P<component>hermes-agent|hermes-webui):"
|
build(hermes-agent): multi-arch image via two native kaniko legs
Repoint the hermes-agent base FROM at the upstream multi-arch OCI INDEX
digest (tag v2026.7.7.2, revision 9de9c25f) whose arm64 leaf is byte-for-byte
the previously pinned single-arch base, so the arm64 build is unchanged while
the same reviewed version now also resolves an amd64 leaf. Kaniko selects the
matching leaf per build platform.
Rework the release pipeline to build both arches natively and promote a
multi-arch image without switching off kaniko or weakening any existing
security assertion:
- Keep the arm64 kaniko leg on the unchanged rpi5 coordinating pod; it now
pushes an arch-suffixed candidate tag (...-build-<N>-arm64).
- Add a second native amd64 kaniko leg on a titan-24-pinned, tolerating,
resource-capped pod (ceiling strictly below the arm64 leg) that
independently re-verifies the reviewed revision and stashes its leaf
evidence (...-build-<N>-amd64).
- Add ci/scripts/hermes_multiarch_combine.py: a pure-python, fail-closed
combiner that re-reads each per-arch leaf from the registry, proves its
digest AND its config architecture, assembles a Docker manifest LIST
(already inside the promote allow-list), refuses to overwrite an existing
final tag, publishes the arch-less ...-build-<N> tag, and re-verifies the
registry resolved the exact index referencing exactly the two leaves. It
emits the index digest in the SAME digest-file/image-file format the
single-arch step produced, so render/verify-evidence/hermes_oci_promote.py
promote the INDEX with no change to those scripts.
Tests: add test_hermes_multiarch_combine.py (full hash/verification chain);
strengthen the image-builder suites for the two-arch topology (both kaniko
legs carry the reviewed heredoc-compat build-arg; amd64 leg pinned+capped+
boundary-checked; combine stage wiring; expanded evidence archive) without
weakening the arm64-leg assertions.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BvMSXH8VH2tMWXanb8SJdf
2026-08-25 11:05:29 -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}$")
|
|
|
|
|
DOCKER_MANIFEST_LIST = "application/vnd.docker.distribution.manifest.list.v2+json"
|
|
|
|
|
# A per-arch leaf must be a single-image manifest, never itself a list/index.
|
|
|
|
|
LEAF_MANIFEST_TYPES = {
|
|
|
|
|
"application/vnd.docker.distribution.manifest.v2+json",
|
|
|
|
|
"application/vnd.oci.image.manifest.v1+json",
|
|
|
|
|
}
|
|
|
|
|
IMAGE_CONFIG_TYPES = {
|
|
|
|
|
"application/vnd.docker.container.image.v1+json",
|
|
|
|
|
"application/vnd.oci.image.config.v1+json",
|
|
|
|
|
}
|
|
|
|
|
# Deterministic architecture order -> deterministic manifest-list bytes/digest.
|
|
|
|
|
ARCHITECTURES = ("amd64", "arm64")
|
|
|
|
|
MAX_MANIFEST_BYTES = 4 * 1024 * 1024
|
|
|
|
|
MAX_CONFIG_BYTES = 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 _manifest_url(component: str, reference: str) -> str:
|
|
|
|
|
"""Return one same-origin, path-escaped Docker Registry manifest URL."""
|
|
|
|
|
encoded = urllib.parse.quote(reference, safe="")
|
|
|
|
|
return f"{REGISTRY_ORIGIN}/v2/bstein/{component}/manifests/{encoded}"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _blob_url(component: str, digest: str) -> str:
|
|
|
|
|
"""Return one same-origin, path-escaped Docker Registry blob URL."""
|
|
|
|
|
encoded = urllib.parse.quote(digest, safe="")
|
|
|
|
|
return f"{REGISTRY_ORIGIN}/v2/bstein/{component}/blobs/{encoded}"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _read_evidence_pair(
|
|
|
|
|
*, digest_text: str, image_text: str, per_arch_tag_ref: str
|
|
|
|
|
) -> str:
|
|
|
|
|
"""Cross-check both Kaniko output files for one arch against its tag."""
|
|
|
|
|
digest_lines = digest_text.splitlines()
|
|
|
|
|
image_lines = image_text.splitlines()
|
|
|
|
|
if len(digest_lines) != 1:
|
|
|
|
|
raise ValueError("Kaniko digest evidence must contain exactly one line")
|
|
|
|
|
if len(image_lines) != 1:
|
|
|
|
|
raise ValueError("Kaniko image evidence must contain exactly one line")
|
|
|
|
|
digest = digest_lines[0].strip()
|
|
|
|
|
if not DIGEST_PATTERN.fullmatch(digest):
|
|
|
|
|
raise ValueError("invalid per-arch image digest")
|
|
|
|
|
if image_lines[0].strip() != f"{per_arch_tag_ref}@{digest}":
|
|
|
|
|
raise ValueError("per-arch image evidence does not match tag and digest")
|
|
|
|
|
return digest
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _verified_leaf(
|
|
|
|
|
*,
|
|
|
|
|
component: str,
|
|
|
|
|
per_arch_tag: str,
|
|
|
|
|
architecture: str,
|
|
|
|
|
expected_digest: str,
|
|
|
|
|
authorization: str,
|
|
|
|
|
opener: Callable[[urllib.request.Request, int], Any],
|
|
|
|
|
) -> dict[str, Any]:
|
|
|
|
|
"""Re-read one per-arch leaf and prove its digest, type, and architecture."""
|
|
|
|
|
accept = ", ".join(sorted(LEAF_MANIFEST_TYPES))
|
|
|
|
|
request = urllib.request.Request(
|
|
|
|
|
_manifest_url(component, per_arch_tag),
|
|
|
|
|
headers={"Accept": accept, "Authorization": authorization},
|
|
|
|
|
method="GET",
|
|
|
|
|
)
|
|
|
|
|
with opener(request, 30) as response:
|
|
|
|
|
if _status(response) != 200:
|
|
|
|
|
raise RuntimeError(
|
|
|
|
|
f"{architecture} leaf manifest returned HTTP {_status(response)}"
|
|
|
|
|
)
|
|
|
|
|
body = response.read(MAX_MANIFEST_BYTES + 1)
|
|
|
|
|
if len(body) > MAX_MANIFEST_BYTES:
|
|
|
|
|
raise RuntimeError(f"{architecture} leaf manifest exceeded the size limit")
|
|
|
|
|
observed_digest = response.headers.get("Docker-Content-Digest", "")
|
|
|
|
|
content_type = response.headers.get("Content-Type", "").split(";", 1)[0].strip()
|
|
|
|
|
if observed_digest != expected_digest:
|
|
|
|
|
raise RuntimeError(f"{architecture} leaf digest does not match build evidence")
|
|
|
|
|
# Defence in depth: the digest header is registry-asserted; recompute it too.
|
|
|
|
|
if f"sha256:{hashlib.sha256(body).hexdigest()}" != expected_digest:
|
|
|
|
|
raise RuntimeError(f"{architecture} leaf bytes do not hash to its digest")
|
|
|
|
|
if content_type not in LEAF_MANIFEST_TYPES:
|
|
|
|
|
raise RuntimeError(f"{architecture} leaf is not a single-image manifest")
|
|
|
|
|
try:
|
|
|
|
|
manifest = json.loads(body.decode("utf-8"))
|
|
|
|
|
except (UnicodeDecodeError, json.JSONDecodeError) as exc:
|
|
|
|
|
raise RuntimeError(f"{architecture} leaf manifest is not valid JSON") from exc
|
|
|
|
|
config = manifest.get("config")
|
|
|
|
|
if not isinstance(config, dict):
|
|
|
|
|
raise RuntimeError(f"{architecture} leaf manifest omits its config descriptor")
|
|
|
|
|
config_digest = str(config.get("digest") or "")
|
|
|
|
|
config_type = str(config.get("mediaType") or "")
|
|
|
|
|
if not DIGEST_PATTERN.fullmatch(config_digest):
|
|
|
|
|
raise RuntimeError(f"{architecture} leaf config digest is invalid")
|
|
|
|
|
if config_type not in IMAGE_CONFIG_TYPES:
|
|
|
|
|
raise RuntimeError(f"{architecture} leaf config media type is unsupported")
|
|
|
|
|
config_request = urllib.request.Request(
|
|
|
|
|
_blob_url(component, config_digest),
|
|
|
|
|
headers={"Accept": config_type, "Authorization": authorization},
|
|
|
|
|
method="GET",
|
|
|
|
|
)
|
|
|
|
|
with opener(config_request, 30) as response:
|
|
|
|
|
if _status(response) != 200:
|
|
|
|
|
raise RuntimeError(
|
|
|
|
|
f"{architecture} leaf config returned HTTP {_status(response)}"
|
|
|
|
|
)
|
|
|
|
|
config_body = response.read(MAX_CONFIG_BYTES + 1)
|
|
|
|
|
if len(config_body) > MAX_CONFIG_BYTES:
|
|
|
|
|
raise RuntimeError(f"{architecture} leaf config exceeded the size limit")
|
|
|
|
|
if f"sha256:{hashlib.sha256(config_body).hexdigest()}" != config_digest:
|
|
|
|
|
raise RuntimeError(f"{architecture} leaf config bytes do not hash to its digest")
|
|
|
|
|
try:
|
|
|
|
|
config_json = json.loads(config_body.decode("utf-8"))
|
|
|
|
|
except (UnicodeDecodeError, json.JSONDecodeError) as exc:
|
|
|
|
|
raise RuntimeError(f"{architecture} leaf config is not valid JSON") from exc
|
|
|
|
|
if config_json.get("architecture") != architecture:
|
|
|
|
|
raise RuntimeError(
|
|
|
|
|
f"{architecture} leaf config reports architecture "
|
|
|
|
|
f"{config_json.get('architecture')!r}"
|
|
|
|
|
)
|
|
|
|
|
if config_json.get("os") != "linux":
|
|
|
|
|
raise RuntimeError(f"{architecture} leaf config reports a non-linux os")
|
|
|
|
|
return {
|
|
|
|
|
"mediaType": content_type,
|
|
|
|
|
"size": len(body),
|
|
|
|
|
"digest": expected_digest,
|
|
|
|
|
"platform": {"architecture": architecture, "os": "linux"},
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _manifest_list_bytes(descriptors: list[dict[str, Any]]) -> bytes:
|
|
|
|
|
"""Serialize the manifest list deterministically for a stable index digest."""
|
|
|
|
|
document = {
|
|
|
|
|
"schemaVersion": 2,
|
|
|
|
|
"mediaType": DOCKER_MANIFEST_LIST,
|
|
|
|
|
"manifests": descriptors,
|
|
|
|
|
}
|
|
|
|
|
return json.dumps(document, sort_keys=True, separators=(",", ":")).encode("utf-8")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def combine_multiarch_index(
|
|
|
|
|
*,
|
|
|
|
|
destination: str,
|
|
|
|
|
arch_digests: dict[str, str],
|
|
|
|
|
username: str,
|
|
|
|
|
password: str,
|
|
|
|
|
opener: Callable[[urllib.request.Request, int], Any] = _registry_request,
|
|
|
|
|
) -> dict[str, str]:
|
|
|
|
|
"""Verify both leaves, publish, and re-verify one multi-arch index tag."""
|
|
|
|
|
match = DESTINATION_PATTERN.fullmatch(destination.strip())
|
|
|
|
|
if not match:
|
|
|
|
|
raise ValueError("invalid multi-arch destination")
|
|
|
|
|
if set(arch_digests) != set(ARCHITECTURES):
|
|
|
|
|
raise ValueError("expected exactly the arm64 and amd64 per-arch digests")
|
build(hermes-webui): multi-arch image (arm64 + amd64)
Make registry.bstein.dev/bstein/hermes-webui a linux/amd64 + linux/arm64
manifest list so the agent pod's `hux` sidecar (which runs the webui image)
can schedule onto the amd64 node titan-22. Reuses the hermes-agent multi-arch
pattern already on main.
- Dockerfile.hermes-webui: repoint both FROMs to multi-arch, internal sources.
The upstream WebUI base (ghcr sha256:a83a3893..., already a multi-arch OCI
index) is now pulled from the in-cluster Harbor mirror; the agent base moves
from the retired arm64-only leaf (81970563) to the multi-arch agent index
(a68d1c4d). Kaniko selects the matching arch leaf per build node.
- services/harbor/hermes-webui-base-mirror-job.yaml: new suspended, operator-run
skopeo `copy --all` Job mirroring the upstream WebUI base index into Harbor's
`mirror` project (modeled on hermes-agent-base-mirror-job.yaml; reuses the
generic ensure-project helper). Wired into the harbor kustomization.
- Jenkinsfile.hermes-webui-image: arm64 leg (titan-20) + amd64 leg (titan-24,
hostname+arch pin, toleration Exists, resource-capped, own checkout scm) +
Combine multi-arch index stage; per-arch evidence archived alongside the index.
- hermes_multiarch_combine.py: generalize the destination pattern/component to
serve both hermes-agent and hermes-webui (fail-closed to just those two).
- Tests updated to the two-arch topology (two legs, combine, both FROM bases,
the mirror Job, twelve archived evidence files).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BvMSXH8VH2tMWXanb8SJdf
2026-08-25 18:17:35 -03:00
|
|
|
component = match.group("component")
|
build(hermes-agent): multi-arch image via two native kaniko legs
Repoint the hermes-agent base FROM at the upstream multi-arch OCI INDEX
digest (tag v2026.7.7.2, revision 9de9c25f) whose arm64 leaf is byte-for-byte
the previously pinned single-arch base, so the arm64 build is unchanged while
the same reviewed version now also resolves an amd64 leaf. Kaniko selects the
matching leaf per build platform.
Rework the release pipeline to build both arches natively and promote a
multi-arch image without switching off kaniko or weakening any existing
security assertion:
- Keep the arm64 kaniko leg on the unchanged rpi5 coordinating pod; it now
pushes an arch-suffixed candidate tag (...-build-<N>-arm64).
- Add a second native amd64 kaniko leg on a titan-24-pinned, tolerating,
resource-capped pod (ceiling strictly below the arm64 leg) that
independently re-verifies the reviewed revision and stashes its leaf
evidence (...-build-<N>-amd64).
- Add ci/scripts/hermes_multiarch_combine.py: a pure-python, fail-closed
combiner that re-reads each per-arch leaf from the registry, proves its
digest AND its config architecture, assembles a Docker manifest LIST
(already inside the promote allow-list), refuses to overwrite an existing
final tag, publishes the arch-less ...-build-<N> tag, and re-verifies the
registry resolved the exact index referencing exactly the two leaves. It
emits the index digest in the SAME digest-file/image-file format the
single-arch step produced, so render/verify-evidence/hermes_oci_promote.py
promote the INDEX with no change to those scripts.
Tests: add test_hermes_multiarch_combine.py (full hash/verification chain);
strengthen the image-builder suites for the two-arch topology (both kaniko
legs carry the reviewed heredoc-compat build-arg; amd64 leg pinned+capped+
boundary-checked; combine stage wiring; expanded evidence archive) without
weakening the arm64-leg assertions.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BvMSXH8VH2tMWXanb8SJdf
2026-08-25 11:05:29 -03:00
|
|
|
index_tag = destination.rsplit(":", 1)[1]
|
|
|
|
|
authorization = _authorization(username, password)
|
|
|
|
|
|
|
|
|
|
descriptors = [
|
|
|
|
|
_verified_leaf(
|
|
|
|
|
component=component,
|
|
|
|
|
per_arch_tag=f"{index_tag}-{architecture}",
|
|
|
|
|
architecture=architecture,
|
|
|
|
|
expected_digest=arch_digests[architecture],
|
|
|
|
|
authorization=authorization,
|
|
|
|
|
opener=opener,
|
|
|
|
|
)
|
|
|
|
|
for architecture in ARCHITECTURES
|
|
|
|
|
]
|
|
|
|
|
manifest_list = _manifest_list_bytes(descriptors)
|
|
|
|
|
if len(manifest_list) > MAX_MANIFEST_BYTES:
|
|
|
|
|
raise RuntimeError("assembled manifest list exceeded the size limit")
|
|
|
|
|
index_digest = f"sha256:{hashlib.sha256(manifest_list).hexdigest()}"
|
|
|
|
|
|
|
|
|
|
index_url = _manifest_url(component, index_tag)
|
|
|
|
|
head_request = urllib.request.Request(
|
|
|
|
|
index_url,
|
|
|
|
|
headers={"Accept": DOCKER_MANIFEST_LIST, "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 != index_digest:
|
|
|
|
|
raise RuntimeError("final tag already exists with another index digest")
|
|
|
|
|
result = "already-present"
|
|
|
|
|
elif head_status == 404:
|
|
|
|
|
put_request = urllib.request.Request(
|
|
|
|
|
index_url,
|
|
|
|
|
data=manifest_list,
|
|
|
|
|
headers={
|
|
|
|
|
"Authorization": authorization,
|
|
|
|
|
"Content-Type": DOCKER_MANIFEST_LIST,
|
|
|
|
|
},
|
|
|
|
|
method="PUT",
|
|
|
|
|
)
|
|
|
|
|
with opener(put_request, 30) as response:
|
|
|
|
|
put_status = _status(response)
|
|
|
|
|
put_digest = response.headers.get("Docker-Content-Digest", "")
|
|
|
|
|
if put_status not in {201, 202}:
|
|
|
|
|
raise RuntimeError(f"index manifest returned HTTP {put_status}")
|
|
|
|
|
if put_digest and put_digest != index_digest:
|
|
|
|
|
raise RuntimeError("index manifest digest changed during publish")
|
|
|
|
|
result = "published"
|
|
|
|
|
else:
|
|
|
|
|
raise RuntimeError(f"final tag preflight returned HTTP {head_status}")
|
|
|
|
|
|
|
|
|
|
verify_request = urllib.request.Request(
|
|
|
|
|
index_url,
|
|
|
|
|
headers={"Accept": DOCKER_MANIFEST_LIST, "Authorization": authorization},
|
|
|
|
|
method="GET",
|
|
|
|
|
)
|
|
|
|
|
with opener(verify_request, 30) as response:
|
|
|
|
|
if _status(response) != 200:
|
|
|
|
|
raise RuntimeError(f"index verification returned HTTP {_status(response)}")
|
|
|
|
|
verify_body = response.read(MAX_MANIFEST_BYTES + 1)
|
|
|
|
|
if len(verify_body) > MAX_MANIFEST_BYTES:
|
|
|
|
|
raise RuntimeError("index verification exceeded the size limit")
|
|
|
|
|
verify_digest = response.headers.get("Docker-Content-Digest", "")
|
|
|
|
|
verify_type = response.headers.get("Content-Type", "").split(";", 1)[0].strip()
|
|
|
|
|
if verify_digest != index_digest:
|
|
|
|
|
raise RuntimeError("registry resolved the final tag to another index digest")
|
|
|
|
|
if verify_type != DOCKER_MANIFEST_LIST:
|
|
|
|
|
raise RuntimeError("registry did not store a Docker manifest list")
|
|
|
|
|
try:
|
|
|
|
|
published = json.loads(verify_body.decode("utf-8"))
|
|
|
|
|
except (UnicodeDecodeError, json.JSONDecodeError) as exc:
|
|
|
|
|
raise RuntimeError("registry returned invalid index JSON") from exc
|
|
|
|
|
published_leaves = {
|
|
|
|
|
(
|
|
|
|
|
str((item or {}).get("platform", {}).get("architecture")),
|
|
|
|
|
str((item or {}).get("digest")),
|
|
|
|
|
)
|
|
|
|
|
for item in published.get("manifests") or []
|
|
|
|
|
}
|
|
|
|
|
expected_leaves = {
|
|
|
|
|
(architecture, arch_digests[architecture]) for architecture in ARCHITECTURES
|
|
|
|
|
}
|
|
|
|
|
if published_leaves != expected_leaves:
|
|
|
|
|
raise RuntimeError("published index does not reference the exact two leaves")
|
|
|
|
|
|
|
|
|
|
return {
|
|
|
|
|
"component": component,
|
|
|
|
|
"index_digest": index_digest,
|
|
|
|
|
"index_tag": index_tag,
|
|
|
|
|
"result": result,
|
|
|
|
|
**{f"{architecture}_digest": arch_digests[architecture] for architecture in ARCHITECTURES},
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _load_arch_digest(
|
|
|
|
|
*, destination: str, architecture: str, digest_file: Path, image_file: Path
|
|
|
|
|
) -> str:
|
|
|
|
|
"""Bind one arch's two Kaniko evidence files to its arch-suffixed tag."""
|
|
|
|
|
per_arch_tag_ref = f"{destination}-{architecture}"
|
|
|
|
|
return _read_evidence_pair(
|
|
|
|
|
digest_text=digest_file.read_text(encoding="utf-8"),
|
|
|
|
|
image_text=image_file.read_text(encoding="utf-8"),
|
|
|
|
|
per_arch_tag_ref=per_arch_tag_ref,
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def main() -> int:
|
|
|
|
|
"""Combine two verified per-arch leaves and emit index digest evidence."""
|
|
|
|
|
parser = argparse.ArgumentParser(description=__doc__)
|
|
|
|
|
parser.add_argument("--destination", required=True)
|
|
|
|
|
parser.add_argument("--source-revision", required=True)
|
|
|
|
|
parser.add_argument("--build-number", required=True)
|
|
|
|
|
parser.add_argument("--arm64-digest-file", required=True, type=Path)
|
|
|
|
|
parser.add_argument("--arm64-image-file", required=True, type=Path)
|
|
|
|
|
parser.add_argument("--amd64-digest-file", required=True, type=Path)
|
|
|
|
|
parser.add_argument("--amd64-image-file", required=True, type=Path)
|
|
|
|
|
parser.add_argument("--digest-file", required=True, type=Path)
|
|
|
|
|
parser.add_argument("--image-file", required=True, type=Path)
|
|
|
|
|
args = parser.parse_args()
|
|
|
|
|
try:
|
|
|
|
|
match = DESTINATION_PATTERN.fullmatch(args.destination.strip())
|
|
|
|
|
if not match:
|
|
|
|
|
raise ValueError("invalid multi-arch destination")
|
|
|
|
|
if match.group("revision") != args.source_revision.strip():
|
|
|
|
|
raise ValueError("destination revision does not match evidence")
|
|
|
|
|
if match.group("build") != args.build_number.strip():
|
|
|
|
|
raise ValueError("destination build number does not match evidence")
|
|
|
|
|
destination = args.destination.strip()
|
|
|
|
|
arch_digests = {
|
|
|
|
|
"arm64": _load_arch_digest(
|
|
|
|
|
destination=destination,
|
|
|
|
|
architecture="arm64",
|
|
|
|
|
digest_file=args.arm64_digest_file,
|
|
|
|
|
image_file=args.arm64_image_file,
|
|
|
|
|
),
|
|
|
|
|
"amd64": _load_arch_digest(
|
|
|
|
|
destination=destination,
|
|
|
|
|
architecture="amd64",
|
|
|
|
|
digest_file=args.amd64_digest_file,
|
|
|
|
|
image_file=args.amd64_image_file,
|
|
|
|
|
),
|
|
|
|
|
}
|
|
|
|
|
result = combine_multiarch_index(
|
|
|
|
|
destination=destination,
|
|
|
|
|
arch_digests=arch_digests,
|
|
|
|
|
username=os.environ.get("HARBOR_USER", ""),
|
|
|
|
|
password=os.environ.get("HARBOR_PASSWORD", ""),
|
|
|
|
|
)
|
|
|
|
|
args.digest_file.write_text(result["index_digest"] + "\n", encoding="utf-8")
|
|
|
|
|
args.image_file.write_text(
|
|
|
|
|
f"{destination}@{result['index_digest']}\n", encoding="utf-8"
|
|
|
|
|
)
|
|
|
|
|
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())
|