atlas-iac/testing/tests/test_hermes_multiarch_combine.py
jenkins 9c516b9808 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

431 lines
15 KiB
Python

"""Safety tests for the fail-closed multi-arch manifest-list combiner."""
from __future__ import annotations
import hashlib
import importlib.util
import io
import json
import sys
from pathlib import Path
import pytest
ROOT = Path(__file__).resolve().parents[2]
SCRIPT = ROOT / "ci/scripts/hermes_multiarch_combine.py"
REVISION = "a" * 40
BUILD = "17"
DESTINATION = f"registry.bstein.dev/bstein/hermes-agent:git-{REVISION}-build-{BUILD}"
DOCKER_MANIFEST_LIST = "application/vnd.docker.distribution.manifest.list.v2+json"
DOCKER_MANIFEST = "application/vnd.docker.distribution.manifest.v2+json"
IMAGE_CONFIG = "application/vnd.docker.container.image.v1+json"
def _load():
spec = importlib.util.spec_from_file_location("hermes_multiarch_combine", SCRIPT)
assert spec is not None and spec.loader is not None
module = importlib.util.module_from_spec(spec)
spec.loader.exec_module(module)
return module
def _sha(body: bytes) -> str:
return "sha256:" + hashlib.sha256(body).hexdigest()
class Response(io.BytesIO):
"""Minimal context-managed urllib response."""
def __init__(self, status: int, body: bytes = b"", headers=None):
super().__init__(body)
self.status = status
self.headers = headers or {}
def __enter__(self):
return self
def __exit__(self, *_args):
self.close()
def _leaf(architecture: str, *, os_name: str = "linux"):
"""Build a self-consistent config+manifest whose bytes hash to real digests."""
config_body = json.dumps(
{"architecture": architecture, "os": os_name}, sort_keys=True
).encode()
config_digest = _sha(config_body)
manifest_body = json.dumps(
{
"schemaVersion": 2,
"mediaType": DOCKER_MANIFEST,
"config": {
"mediaType": IMAGE_CONFIG,
"digest": config_digest,
"size": len(config_body),
},
"layers": [],
},
sort_keys=True,
).encode()
return {
"arch": architecture,
"config_body": config_body,
"config_digest": config_digest,
"manifest_body": manifest_body,
"digest": _sha(manifest_body),
"content_type": DOCKER_MANIFEST,
}
def _descriptor(module, leaf):
return {
"mediaType": leaf["content_type"],
"size": len(leaf["manifest_body"]),
"digest": leaf["digest"],
"platform": {"architecture": leaf["arch"], "os": "linux"},
}
def _index_digest(module, amd64, arm64) -> str:
body = module._manifest_list_bytes(
[_descriptor(module, amd64), _descriptor(module, arm64)]
)
return _sha(body)
class Registry:
"""Route the combiner's deterministic request sequence by method and path."""
def __init__(self, module, amd64, arm64, *, head_status=404, existing="") -> None:
self.module = module
self.leaves = {amd64["arch"]: amd64, arm64["arch"]: arm64}
self.index_digest = _index_digest(module, amd64, arm64)
self.head_status = head_status
self.existing = existing
self.put_body = None
self.calls = []
def __call__(self, request, timeout):
method = request.method
url = request.full_url
self.calls.append((method, url))
for arch, leaf in self.leaves.items():
if url.endswith(f"-{arch}"):
return Response(
200,
leaf["manifest_body"],
{
"Docker-Content-Digest": leaf["digest"],
"Content-Type": leaf["content_type"],
},
)
# Blob URLs percent-encode the ``sha256:`` colon; match the raw hex.
if url.endswith(leaf["config_digest"].split(":", 1)[1]):
return Response(200, leaf["config_body"], {})
# Final index tag (no arch suffix, ends with the build tag).
if method == "HEAD":
return Response(
self.head_status,
headers={"Docker-Content-Digest": self.existing},
)
if method == "PUT":
self.put_body = request.data
return Response(201, headers={"Docker-Content-Digest": self.index_digest})
return Response(
200,
self.module._manifest_list_bytes(
[
_descriptor(self.module, self.leaves["amd64"]),
_descriptor(self.module, self.leaves["arm64"]),
]
),
{
"Docker-Content-Digest": self.index_digest,
"Content-Type": DOCKER_MANIFEST_LIST,
},
)
def _combine(module, registry, arch_digests=None):
if arch_digests is None:
arch_digests = {
"amd64": registry.leaves["amd64"]["digest"],
"arm64": registry.leaves["arm64"]["digest"],
}
return module.combine_multiarch_index(
destination=DESTINATION,
arch_digests=arch_digests,
username="robot",
password="private",
opener=registry,
)
def test_combines_two_verified_leaves_into_one_index() -> None:
"""Both native leaves are re-read, arch-proven, and published as a list."""
module = _load()
amd64, arm64 = _leaf("amd64"), _leaf("arm64")
registry = Registry(module, amd64, arm64)
result = _combine(module, registry)
assert result["result"] == "published"
assert result["index_digest"] == registry.index_digest
assert result["amd64_digest"] == amd64["digest"]
assert result["arm64_digest"] == arm64["digest"]
# The published bytes are exactly what we hashed for the index digest.
assert _sha(registry.put_body) == registry.index_digest
assert "private" not in json.dumps(result)
def test_idempotent_when_index_already_matches() -> None:
"""A replay is accepted only when the existing index digest is identical."""
module = _load()
amd64, arm64 = _leaf("amd64"), _leaf("arm64")
index_digest = _index_digest(module, amd64, arm64)
registry = Registry(
module, amd64, arm64, head_status=200, existing=index_digest
)
result = _combine(module, registry)
assert result["result"] == "already-present"
assert registry.put_body is None
def test_existing_index_with_other_digest_fails_closed() -> None:
"""An occupied final tag with a different index digest never republishes."""
module = _load()
amd64, arm64 = _leaf("amd64"), _leaf("arm64")
registry = Registry(
module, amd64, arm64, head_status=200, existing="sha256:" + "c" * 64
)
with pytest.raises(RuntimeError, match="another index digest"):
_combine(module, registry)
def test_rejects_leaf_digest_that_disagrees_with_evidence() -> None:
"""A leaf whose registry digest is not the Kaniko evidence fails closed."""
module = _load()
amd64, arm64 = _leaf("amd64"), _leaf("arm64")
registry = Registry(module, amd64, arm64)
with pytest.raises(RuntimeError, match="amd64 leaf digest"):
_combine(
module,
registry,
arch_digests={"amd64": "sha256:" + "d" * 64, "arm64": arm64["digest"]},
)
def test_rejects_leaf_whose_config_architecture_is_wrong() -> None:
"""A leaf that claims the wrong architecture in its config fails closed."""
module = _load()
# Build an "amd64" candidate tag whose config actually says arm64.
swapped = _leaf("arm64")
swapped["arch"] = "amd64" # served under the amd64 tag, but arm64 inside
arm64 = _leaf("arm64")
registry = Registry(module, swapped, arm64)
with pytest.raises(RuntimeError, match="architecture"):
_combine(
module,
registry,
arch_digests={"amd64": swapped["digest"], "arm64": arm64["digest"]},
)
def test_rejects_leaf_served_as_a_manifest_list() -> None:
"""A per-arch leaf must be a single image, never itself an index/list."""
module = _load()
amd64, arm64 = _leaf("amd64"), _leaf("arm64")
amd64["content_type"] = DOCKER_MANIFEST_LIST
registry = Registry(module, amd64, arm64)
with pytest.raises(RuntimeError, match="single-image manifest"):
_combine(module, registry)
def test_requires_both_architectures() -> None:
"""The combiner refuses any arch set other than exactly arm64 and amd64."""
module = _load()
amd64, arm64 = _leaf("amd64"), _leaf("arm64")
registry = Registry(module, amd64, arm64)
with pytest.raises(ValueError, match="arm64 and amd64"):
module.combine_multiarch_index(
destination=DESTINATION,
arch_digests={"amd64": amd64["digest"]},
username="robot",
password="private",
opener=registry,
)
def test_rejects_destination_with_arch_suffix() -> None:
"""Only the arch-less final tag is a valid combine destination."""
module = _load()
amd64, arm64 = _leaf("amd64"), _leaf("arm64")
with pytest.raises(ValueError, match="invalid multi-arch destination"):
module.combine_multiarch_index(
destination=f"{DESTINATION}-arm64",
arch_digests={"amd64": amd64["digest"], "arm64": arm64["digest"]},
username="robot",
password="private",
opener=Registry(module, amd64, arm64),
)
def test_cli_binds_evidence_files_and_writes_index_outputs(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str]
) -> None:
"""The command reads both Kaniko pairs and emits arch-less index evidence."""
module = _load()
amd64, arm64 = _leaf("amd64"), _leaf("arm64")
registry = Registry(module, amd64, arm64)
files = {}
for arch, leaf in (("amd64", amd64), ("arm64", arm64)):
digest_file = tmp_path / f"{arch}.digest"
image_file = tmp_path / f"{arch}.image"
digest_file.write_text(leaf["digest"] + "\n", encoding="utf-8")
image_file.write_text(
f"{DESTINATION}-{arch}@{leaf['digest']}\n", encoding="utf-8"
)
files[arch] = (digest_file, image_file)
out_digest = tmp_path / "index.digest"
out_image = tmp_path / "index.image"
monkeypatch.setenv("HARBOR_USER", "robot")
monkeypatch.setenv("HARBOR_PASSWORD", "private")
captured = {}
def fake_combine(*, destination, arch_digests, username, password):
captured["destination"] = destination
captured["arch_digests"] = dict(arch_digests)
return {"index_digest": registry.index_digest, "result": "published"}
monkeypatch.setattr(module, "combine_multiarch_index", fake_combine)
monkeypatch.setattr(
sys,
"argv",
[
"hermes_multiarch_combine.py",
"--destination",
DESTINATION,
"--source-revision",
REVISION,
"--build-number",
BUILD,
"--arm64-digest-file",
str(files["arm64"][0]),
"--arm64-image-file",
str(files["arm64"][1]),
"--amd64-digest-file",
str(files["amd64"][0]),
"--amd64-image-file",
str(files["amd64"][1]),
"--digest-file",
str(out_digest),
"--image-file",
str(out_image),
],
)
assert module.main() == 0
assert out_digest.read_text(encoding="utf-8").strip() == registry.index_digest
assert (
out_image.read_text(encoding="utf-8").strip()
== f"{DESTINATION}@{registry.index_digest}"
)
assert json.loads(capsys.readouterr().out)["result"] == "published"
# main() must bind each arch's two evidence files to the correct leaf digest.
assert captured["destination"] == DESTINATION
assert captured["arch_digests"] == {
"amd64": amd64["digest"],
"arm64": arm64["digest"],
}
def test_cli_rejects_mismatched_per_arch_evidence_pair(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str]
) -> None:
"""A digest/image evidence file mismatch is reported as a JSON error."""
module = _load()
amd64, arm64 = _leaf("amd64"), _leaf("arm64")
files = {}
for arch, leaf in (("amd64", amd64), ("arm64", arm64)):
digest_file = tmp_path / f"{arch}.digest"
image_file = tmp_path / f"{arch}.image"
digest_file.write_text(leaf["digest"] + "\n", encoding="utf-8")
# amd64 image file points at the wrong digest.
recorded = "sha256:" + "e" * 64 if arch == "amd64" else leaf["digest"]
image_file.write_text(
f"{DESTINATION}-{arch}@{recorded}\n", encoding="utf-8"
)
files[arch] = (digest_file, image_file)
monkeypatch.setenv("HARBOR_USER", "robot")
monkeypatch.setenv("HARBOR_PASSWORD", "private")
monkeypatch.setattr(
sys,
"argv",
[
"hermes_multiarch_combine.py",
"--destination",
DESTINATION,
"--source-revision",
REVISION,
"--build-number",
BUILD,
"--arm64-digest-file",
str(files["arm64"][0]),
"--arm64-image-file",
str(files["arm64"][1]),
"--amd64-digest-file",
str(files["amd64"][0]),
"--amd64-image-file",
str(files["amd64"][1]),
"--digest-file",
str(tmp_path / "index.digest"),
"--image-file",
str(tmp_path / "index.image"),
],
)
assert module.main() == 1
assert "does not match" in json.loads(capsys.readouterr().out)["error"]
# The combiner serves both Hermes multi-arch images; only the repository name in
# the destination differs. hermes-webui must route to its own registry paths.
WEBUI_DESTINATION = (
f"registry.bstein.dev/bstein/hermes-webui:git-{REVISION}-build-{BUILD}"
)
def test_destination_pattern_captures_both_components() -> None:
"""The fail-closed pattern accepts exactly hermes-agent and hermes-webui."""
module = _load()
agent = module.DESTINATION_PATTERN.fullmatch(DESTINATION)
webui = module.DESTINATION_PATTERN.fullmatch(WEBUI_DESTINATION)
assert agent is not None and agent.group("component") == "hermes-agent"
assert webui is not None and webui.group("component") == "hermes-webui"
# A third, unexpected repository is still rejected.
assert (
module.DESTINATION_PATTERN.fullmatch(
f"registry.bstein.dev/bstein/hermes-other:git-{REVISION}-build-{BUILD}"
)
is None
)
def test_combines_webui_leaves_under_the_webui_component() -> None:
"""A hermes-webui destination re-reads leaves from the webui registry paths."""
module = _load()
amd64, arm64 = _leaf("amd64"), _leaf("arm64")
registry = Registry(module, amd64, arm64)
result = module.combine_multiarch_index(
destination=WEBUI_DESTINATION,
arch_digests={"amd64": amd64["digest"], "arm64": arm64["digest"]},
username="robot",
password="private",
opener=registry,
)
assert result["component"] == "hermes-webui"
assert result["result"] == "published"
# Every registry call the combiner made must be scoped to the webui repo, and
# never leak into the agent repo.
assert registry.calls, "combiner made no registry calls"
for _method, url in registry.calls:
assert "/v2/bstein/hermes-webui/" in url
assert "hermes-agent" not in url