206 lines
6.2 KiB
Python
206 lines
6.2 KiB
Python
"""Safety tests for the evidence-bound Hermes OCI release promotion."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import importlib.util
|
|
import io
|
|
import json
|
|
import sys
|
|
import urllib.request
|
|
from pathlib import Path
|
|
|
|
import pytest
|
|
|
|
|
|
ROOT = Path(__file__).resolve().parents[2]
|
|
SCRIPT = ROOT / "ci/scripts/hermes_oci_promote.py"
|
|
REVISION = "a" * 40
|
|
DIGEST = "sha256:" + "b" * 64
|
|
DESTINATION = f"registry.bstein.dev/bstein/hermes-agent:git-{REVISION}-build-17"
|
|
CONTENT_TYPE = "application/vnd.docker.distribution.manifest.v2+json"
|
|
MANIFEST = b'{"schemaVersion":2}'
|
|
|
|
|
|
def _load():
|
|
spec = importlib.util.spec_from_file_location("hermes_oci_promote", 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
|
|
|
|
|
|
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 _candidate(headers=None, body: bytes = MANIFEST) -> Response:
|
|
values = {
|
|
"Docker-Content-Digest": DIGEST,
|
|
"Content-Type": CONTENT_TYPE,
|
|
}
|
|
values.update(headers or {})
|
|
return Response(200, body, values)
|
|
|
|
|
|
def _promote(module, opener):
|
|
return module.promote_candidate(
|
|
destination=DESTINATION,
|
|
digest=DIGEST,
|
|
source_revision=REVISION,
|
|
build_number="17",
|
|
username="robot",
|
|
password="private",
|
|
opener=opener,
|
|
)
|
|
|
|
|
|
def test_promotion_copies_exact_candidate_to_release_tag() -> None:
|
|
"""Only the exact evidence digest is copied to the Flux-visible tag."""
|
|
module = _load()
|
|
calls: list[urllib.request.Request] = []
|
|
responses = iter(
|
|
[
|
|
_candidate(),
|
|
Response(404),
|
|
Response(201, headers={"Docker-Content-Digest": DIGEST}),
|
|
]
|
|
)
|
|
|
|
def opener(request, timeout):
|
|
calls.append(request)
|
|
assert timeout in {20, 30}
|
|
return next(responses)
|
|
|
|
result = _promote(module, opener)
|
|
assert [request.method for request in calls] == ["GET", "HEAD", "PUT"]
|
|
assert calls[0].full_url.endswith(f"/manifests/git-{REVISION}-build-17")
|
|
assert calls[2].full_url.endswith(f"/manifests/git-{REVISION}-build-17-release")
|
|
assert calls[2].data == MANIFEST
|
|
assert result["result"] == "published"
|
|
assert result["digest"] == DIGEST
|
|
assert "private" not in json.dumps(result)
|
|
|
|
|
|
def test_promotion_is_idempotent_for_the_same_digest() -> None:
|
|
"""A replay succeeds only when the immutable release already matches."""
|
|
module = _load()
|
|
responses = iter(
|
|
[_candidate(), Response(200, headers={"Docker-Content-Digest": DIGEST})]
|
|
)
|
|
result = _promote(module, lambda *_args: next(responses))
|
|
assert result["result"] == "already-present"
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
("changes", "message"),
|
|
[
|
|
({"destination": "registry.invalid/hermes:latest"}, "destination"),
|
|
({"source_revision": "c" * 40}, "revision"),
|
|
({"build_number": "18"}, "build number"),
|
|
({"digest": "sha256:bad"}, "digest"),
|
|
({"username": ""}, "credentials"),
|
|
],
|
|
)
|
|
def test_promotion_rejects_unbound_inputs(changes: dict, message: str) -> None:
|
|
"""Destination, source, build, digest, and credentials fail closed."""
|
|
module = _load()
|
|
kwargs = {
|
|
"destination": DESTINATION,
|
|
"digest": DIGEST,
|
|
"source_revision": REVISION,
|
|
"build_number": "17",
|
|
"username": "robot",
|
|
"password": "private",
|
|
"opener": lambda *_args: _candidate(),
|
|
}
|
|
kwargs.update(changes)
|
|
with pytest.raises((ValueError, RuntimeError), match=message):
|
|
module.promote_candidate(**kwargs)
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
("responses", "message"),
|
|
[
|
|
([Response(401)], "candidate manifest returned HTTP 401"),
|
|
([_candidate({"Docker-Content-Digest": "sha256:" + "c" * 64})], "digest"),
|
|
([_candidate({"Content-Type": "text/plain"})], "content type"),
|
|
(
|
|
[
|
|
_candidate(),
|
|
Response(200, headers={"Docker-Content-Digest": "sha256:" + "c" * 64}),
|
|
],
|
|
"another digest",
|
|
),
|
|
([_candidate(), Response(500)], "preflight returned HTTP 500"),
|
|
(
|
|
[_candidate(), Response(404), Response(500)],
|
|
"release manifest returned HTTP 500",
|
|
),
|
|
(
|
|
[
|
|
_candidate(),
|
|
Response(404),
|
|
Response(201, headers={"Docker-Content-Digest": "sha256:" + "c" * 64}),
|
|
],
|
|
"changed",
|
|
),
|
|
],
|
|
)
|
|
def test_registry_failures_cannot_create_a_valid_release(
|
|
responses: list[Response], message: str
|
|
) -> None:
|
|
"""Registry ambiguity or mismatch always fails the final release boundary."""
|
|
module = _load()
|
|
queued = iter(responses)
|
|
with pytest.raises(RuntimeError, match=message):
|
|
_promote(module, lambda *_args: next(queued))
|
|
|
|
|
|
def test_cli_reports_credential_free_success_and_error(
|
|
tmp_path: Path, monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str]
|
|
) -> None:
|
|
"""The command emits bounded metadata and converts expected failures to JSON."""
|
|
module = _load()
|
|
digest_file = tmp_path / "digest"
|
|
digest_file.write_text(DIGEST + "\n", encoding="utf-8")
|
|
monkeypatch.setattr(
|
|
sys,
|
|
"argv",
|
|
[
|
|
"promote",
|
|
"--destination",
|
|
DESTINATION,
|
|
"--digest-file",
|
|
str(digest_file),
|
|
"--source-revision",
|
|
REVISION,
|
|
"--build-number",
|
|
"17",
|
|
],
|
|
)
|
|
monkeypatch.setattr(
|
|
module,
|
|
"promote_candidate",
|
|
lambda **_kwargs: {"result": "published", "digest": DIGEST},
|
|
)
|
|
assert module.main() == 0
|
|
assert json.loads(capsys.readouterr().out)["result"] == "published"
|
|
monkeypatch.setattr(
|
|
module,
|
|
"promote_candidate",
|
|
lambda **_kwargs: (_ for _ in ()).throw(RuntimeError("denied")),
|
|
)
|
|
assert module.main() == 1
|
|
assert json.loads(capsys.readouterr().out) == {"error": "denied"}
|