470 lines
16 KiB
Python
470 lines
16 KiB
Python
|
|
"""Adversarial boundaries for the Hermes image publisher and trigger."""
|
||
|
|
|
||
|
|
from __future__ import annotations
|
||
|
|
|
||
|
|
import importlib.util
|
||
|
|
import io
|
||
|
|
import json
|
||
|
|
from pathlib import Path
|
||
|
|
|
||
|
|
import pytest
|
||
|
|
|
||
|
|
|
||
|
|
REPO_ROOT = Path(__file__).resolve().parents[2]
|
||
|
|
RELEASE_SCRIPT = REPO_ROOT / "ci/scripts/hermes_image_release.py"
|
||
|
|
TRIGGER_SCRIPT = REPO_ROOT / "services/hermes/scripts/jenkins_image_build_trigger.py"
|
||
|
|
|
||
|
|
|
||
|
|
def _load(path: Path, name: str):
|
||
|
|
spec = importlib.util.spec_from_file_location(name, path)
|
||
|
|
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):
|
||
|
|
"""Small context-managed HTTP response fixture."""
|
||
|
|
|
||
|
|
def __init__(
|
||
|
|
self,
|
||
|
|
status: int,
|
||
|
|
headers: dict[str, str] | None = None,
|
||
|
|
body: bytes = b"",
|
||
|
|
):
|
||
|
|
super().__init__(body)
|
||
|
|
self.status = status
|
||
|
|
self.headers = headers or {}
|
||
|
|
|
||
|
|
|
||
|
|
def test_kaniko_evidence_binds_digest_destination_and_unique_build() -> None:
|
||
|
|
"""Both Kaniko artifacts must describe one exact non-replayable tag."""
|
||
|
|
module = _load(RELEASE_SCRIPT, "hermes_image_release_evidence")
|
||
|
|
revision = "a" * 40
|
||
|
|
digest = "sha256:" + "b" * 64
|
||
|
|
destination = f"{module.DEFAULT_IMAGE}:git-{revision}-build-42"
|
||
|
|
assert module.validate_destination(destination, revision, "42") == (
|
||
|
|
revision,
|
||
|
|
"42",
|
||
|
|
)
|
||
|
|
assert (
|
||
|
|
module.validate_kaniko_evidence(
|
||
|
|
digest_text=f"{digest}\n",
|
||
|
|
image_text=f"{destination}@{digest}\n",
|
||
|
|
destination=destination,
|
||
|
|
)
|
||
|
|
== digest
|
||
|
|
)
|
||
|
|
|
||
|
|
|
||
|
|
@pytest.mark.parametrize(
|
||
|
|
("digest_text", "image_template"),
|
||
|
|
[
|
||
|
|
("sha256:" + "a" * 64 + "\nextra\n", "{destination}@{digest}"),
|
||
|
|
("sha256:" + "a" * 64, "{destination}@sha256:" + "b" * 64),
|
||
|
|
("sha256:" + "a" * 64, "registry.invalid/x:y@{digest}"),
|
||
|
|
("sha256:" + "a" * 64, "{destination}@{digest}\nextra"),
|
||
|
|
],
|
||
|
|
)
|
||
|
|
def test_kaniko_evidence_rejects_cross_artifact_mismatch(
|
||
|
|
digest_text: str, image_template: str
|
||
|
|
) -> None:
|
||
|
|
"""A digest, tag, repository, or cardinality mismatch stops rendering."""
|
||
|
|
module = _load(RELEASE_SCRIPT, "hermes_image_release_mismatch")
|
||
|
|
revision = "c" * 40
|
||
|
|
digest = "sha256:" + "a" * 64
|
||
|
|
destination = f"{module.DEFAULT_IMAGE}:git-{revision}-build-8"
|
||
|
|
image_text = image_template.format(destination=destination, digest=digest)
|
||
|
|
with pytest.raises(ValueError):
|
||
|
|
module.validate_kaniko_evidence(
|
||
|
|
digest_text=digest_text,
|
||
|
|
image_text=image_text,
|
||
|
|
destination=destination,
|
||
|
|
)
|
||
|
|
|
||
|
|
|
||
|
|
def test_registry_preflight_rejects_existing_tag_and_auth_failures() -> None:
|
||
|
|
"""Only an authenticated 404 permits Kaniko to claim the unique tag."""
|
||
|
|
module = _load(RELEASE_SCRIPT, "hermes_image_release_preflight")
|
||
|
|
destination = f"{module.DEFAULT_IMAGE}:git-{'d' * 40}-build-3"
|
||
|
|
captured = {}
|
||
|
|
|
||
|
|
def missing(request, timeout):
|
||
|
|
captured["request"] = request
|
||
|
|
captured["timeout"] = timeout
|
||
|
|
return Response(404)
|
||
|
|
|
||
|
|
module.assert_tag_absent(
|
||
|
|
destination, username="robot", password="private", opener=missing
|
||
|
|
)
|
||
|
|
request = captured["request"]
|
||
|
|
assert request.method == "GET"
|
||
|
|
assert request.full_url.startswith(
|
||
|
|
"https://registry.bstein.dev/api/v2.0/projects/bstein/repositories/"
|
||
|
|
"hermes-agent/artifacts/"
|
||
|
|
)
|
||
|
|
assert "private" not in request.full_url
|
||
|
|
assert captured["timeout"] == 20
|
||
|
|
|
||
|
|
for status, message in (
|
||
|
|
(200, "already exists"),
|
||
|
|
(401, "HTTP 401"),
|
||
|
|
(503, "HTTP 503"),
|
||
|
|
):
|
||
|
|
with pytest.raises(RuntimeError, match=message):
|
||
|
|
module.assert_tag_absent(
|
||
|
|
destination,
|
||
|
|
username="robot",
|
||
|
|
password="private",
|
||
|
|
opener=lambda *_args, code=status: Response(code),
|
||
|
|
)
|
||
|
|
|
||
|
|
|
||
|
|
def test_registry_preflight_requires_exact_server_immutable_policy() -> None:
|
||
|
|
"""The already-running controller cannot publish before Flux installs the rule."""
|
||
|
|
module = _load(RELEASE_SCRIPT, "hermes_image_release_policy_preflight")
|
||
|
|
expected = {
|
||
|
|
"disabled": False,
|
||
|
|
"action": "immutable",
|
||
|
|
"template": "immutable_template",
|
||
|
|
"tag_selectors": [
|
||
|
|
{
|
||
|
|
"kind": "doublestar",
|
||
|
|
"decoration": "matches",
|
||
|
|
"pattern": "git-*-build-*",
|
||
|
|
}
|
||
|
|
],
|
||
|
|
"scope_selectors": {
|
||
|
|
"repository": [
|
||
|
|
{
|
||
|
|
"kind": "doublestar",
|
||
|
|
"decoration": "repoMatches",
|
||
|
|
"pattern": "hermes-agent",
|
||
|
|
}
|
||
|
|
]
|
||
|
|
},
|
||
|
|
}
|
||
|
|
captured = {}
|
||
|
|
|
||
|
|
def exact(request, timeout):
|
||
|
|
captured["url"] = request.full_url
|
||
|
|
captured["timeout"] = timeout
|
||
|
|
return Response(
|
||
|
|
200,
|
||
|
|
{"X-Total-Count": "1"},
|
||
|
|
body=json.dumps([{"id": 9, **expected}]).encode(),
|
||
|
|
)
|
||
|
|
|
||
|
|
module.verify_immutable_policy(username="robot", password="private", opener=exact)
|
||
|
|
assert captured["url"].endswith(
|
||
|
|
"/projects/bstein/immutabletagrules?page=1&page_size=100"
|
||
|
|
)
|
||
|
|
assert captured["timeout"] == 20
|
||
|
|
|
||
|
|
for status, body, headers, message in (
|
||
|
|
(403, b"", {}, "HTTP 403"),
|
||
|
|
(200, b"[]", {"X-Total-Count": "0"}, "absent"),
|
||
|
|
(
|
||
|
|
200,
|
||
|
|
json.dumps([{**expected, "disabled": True}]).encode(),
|
||
|
|
{"X-Total-Count": "1"},
|
||
|
|
"not exact",
|
||
|
|
),
|
||
|
|
(200, b"not-json", {}, "invalid"),
|
||
|
|
(
|
||
|
|
200,
|
||
|
|
json.dumps([expected]).encode(),
|
||
|
|
{"X-Total-Count": "2"},
|
||
|
|
"truncated",
|
||
|
|
),
|
||
|
|
):
|
||
|
|
with pytest.raises(RuntimeError, match=message):
|
||
|
|
module.verify_immutable_policy(
|
||
|
|
username="robot",
|
||
|
|
password="private",
|
||
|
|
opener=lambda *_args, code=status, value=body, evidence=headers: Response(
|
||
|
|
code, evidence, body=value
|
||
|
|
),
|
||
|
|
)
|
||
|
|
|
||
|
|
|
||
|
|
def test_harbor_client_fails_closed_on_input_size_auth_and_json(
|
||
|
|
monkeypatch: pytest.MonkeyPatch,
|
||
|
|
) -> None:
|
||
|
|
"""Malformed inputs, oversized bodies, missing auth, and bad JSON all fail."""
|
||
|
|
module = _load(RELEASE_SCRIPT, "hermes_image_release_harbor_errors")
|
||
|
|
destination = f"{module.DEFAULT_IMAGE}:git-{'1' * 40}-build-6"
|
||
|
|
with pytest.raises(ValueError, match="destination"):
|
||
|
|
module.assert_tag_absent(
|
||
|
|
"registry.invalid/x:tag", username="robot", password="private"
|
||
|
|
)
|
||
|
|
with pytest.raises(RuntimeError, match="credentials"):
|
||
|
|
module.assert_tag_absent(destination, username="", password="private")
|
||
|
|
with pytest.raises(RuntimeError, match="size limit"):
|
||
|
|
module.assert_tag_absent(
|
||
|
|
destination,
|
||
|
|
username="robot",
|
||
|
|
password="private",
|
||
|
|
opener=lambda *_args: Response(200, body=b"x" * 1_048_577),
|
||
|
|
)
|
||
|
|
with pytest.raises(RuntimeError, match="invalid artifact JSON"):
|
||
|
|
module.verify_registry_digest(
|
||
|
|
destination,
|
||
|
|
"sha256:" + "2" * 64,
|
||
|
|
username="robot",
|
||
|
|
password="private",
|
||
|
|
opener=lambda *_args: Response(200, body=b"\xff"),
|
||
|
|
)
|
||
|
|
monkeypatch.delenv("HARBOR_USER", raising=False)
|
||
|
|
monkeypatch.delenv("HARBOR_PASSWORD", raising=False)
|
||
|
|
with pytest.raises(RuntimeError, match="unavailable"):
|
||
|
|
module._credentials()
|
||
|
|
|
||
|
|
|
||
|
|
def test_default_harbor_opener_returns_http_responses(
|
||
|
|
monkeypatch: pytest.MonkeyPatch,
|
||
|
|
) -> None:
|
||
|
|
"""The real wrapper returns both normal and non-redirect HTTP responses."""
|
||
|
|
module = _load(RELEASE_SCRIPT, "hermes_image_release_opener")
|
||
|
|
request = module.urllib.request.Request("https://registry.bstein.dev/test")
|
||
|
|
|
||
|
|
class SuccessOpener:
|
||
|
|
def open(self, _request, timeout):
|
||
|
|
assert timeout == 7
|
||
|
|
return Response(204)
|
||
|
|
|
||
|
|
monkeypatch.setattr(
|
||
|
|
module.urllib.request, "build_opener", lambda *_handlers: SuccessOpener()
|
||
|
|
)
|
||
|
|
assert module._registry_request(request, 7).status == 204
|
||
|
|
|
||
|
|
class ErrorOpener:
|
||
|
|
def open(self, _request, timeout):
|
||
|
|
assert timeout == 8
|
||
|
|
raise module.urllib.error.HTTPError(
|
||
|
|
_request.full_url, 404, "missing", {}, None
|
||
|
|
)
|
||
|
|
|
||
|
|
monkeypatch.setattr(
|
||
|
|
module.urllib.request, "build_opener", lambda *_handlers: ErrorOpener()
|
||
|
|
)
|
||
|
|
assert module._registry_request(request, 8).code == 404
|
||
|
|
|
||
|
|
|
||
|
|
@pytest.mark.parametrize(
|
||
|
|
("status", "artifact", "message"),
|
||
|
|
[
|
||
|
|
(404, {}, "HTTP 404"),
|
||
|
|
(200, {}, "omitted"),
|
||
|
|
(200, {"digest": "sha256:" + "2" * 64}, "does not match"),
|
||
|
|
(200, {"digest": "latest"}, "omitted"),
|
||
|
|
],
|
||
|
|
)
|
||
|
|
def test_registry_verification_rejects_missing_or_mismatched_digest(
|
||
|
|
status: int, artifact: dict[str, str], message: str
|
||
|
|
) -> None:
|
||
|
|
"""Rendering requires an independent exact Harbor digest response."""
|
||
|
|
module = _load(RELEASE_SCRIPT, "hermes_image_release_registry")
|
||
|
|
digest = "sha256:" + "1" * 64
|
||
|
|
destination = f"{module.DEFAULT_IMAGE}:git-{'e' * 40}-build-4"
|
||
|
|
body = json.dumps(artifact).encode("utf-8")
|
||
|
|
with pytest.raises(RuntimeError, match=message):
|
||
|
|
module.verify_registry_digest(
|
||
|
|
destination,
|
||
|
|
digest,
|
||
|
|
username="robot",
|
||
|
|
password="private",
|
||
|
|
opener=lambda *_args: Response(status, body=body),
|
||
|
|
)
|
||
|
|
|
||
|
|
|
||
|
|
def test_registry_verification_accepts_exact_pushed_digest() -> None:
|
||
|
|
"""An exact authenticated Harbor digest allows artifact rendering."""
|
||
|
|
module = _load(RELEASE_SCRIPT, "hermes_image_release_registry_ok")
|
||
|
|
digest = "sha256:" + "4" * 64
|
||
|
|
destination = f"{module.DEFAULT_IMAGE}:git-{'f' * 40}-build-5"
|
||
|
|
with pytest.raises(RuntimeError, match="expected tag"):
|
||
|
|
module.verify_registry_digest(
|
||
|
|
destination,
|
||
|
|
digest,
|
||
|
|
username="robot",
|
||
|
|
password="private",
|
||
|
|
opener=lambda *_args: Response(
|
||
|
|
200,
|
||
|
|
body=json.dumps(
|
||
|
|
{"digest": digest, "tags": [{"name": "different-tag"}]}
|
||
|
|
).encode("utf-8"),
|
||
|
|
),
|
||
|
|
)
|
||
|
|
module.verify_registry_digest(
|
||
|
|
destination,
|
||
|
|
digest,
|
||
|
|
username="robot",
|
||
|
|
password="private",
|
||
|
|
opener=lambda *_args: Response(
|
||
|
|
200,
|
||
|
|
body=json.dumps(
|
||
|
|
{
|
||
|
|
"digest": digest,
|
||
|
|
"tags": [
|
||
|
|
{
|
||
|
|
"name": destination.rsplit(":", 1)[1],
|
||
|
|
"immutable": True,
|
||
|
|
}
|
||
|
|
],
|
||
|
|
}
|
||
|
|
).encode("utf-8"),
|
||
|
|
),
|
||
|
|
)
|
||
|
|
|
||
|
|
|
||
|
|
def test_registry_verification_rejects_tag_without_server_immutability() -> None:
|
||
|
|
"""Digest evidence is insufficient unless Harbor reports the build tag immutable."""
|
||
|
|
module = _load(RELEASE_SCRIPT, "hermes_image_release_registry_mutable")
|
||
|
|
digest = "sha256:" + "7" * 64
|
||
|
|
destination = f"{module.DEFAULT_IMAGE}:git-{'8' * 40}-build-19"
|
||
|
|
captured = {}
|
||
|
|
|
||
|
|
def opener(request, _timeout):
|
||
|
|
captured["url"] = request.full_url
|
||
|
|
return Response(
|
||
|
|
200,
|
||
|
|
body=json.dumps(
|
||
|
|
{
|
||
|
|
"digest": digest,
|
||
|
|
"tags": [
|
||
|
|
{
|
||
|
|
"name": destination.rsplit(":", 1)[1],
|
||
|
|
"immutable": False,
|
||
|
|
}
|
||
|
|
],
|
||
|
|
}
|
||
|
|
).encode(),
|
||
|
|
)
|
||
|
|
|
||
|
|
with pytest.raises(RuntimeError, match="immutable"):
|
||
|
|
module.verify_registry_digest(
|
||
|
|
destination,
|
||
|
|
digest,
|
||
|
|
username="robot",
|
||
|
|
password="private",
|
||
|
|
opener=opener,
|
||
|
|
)
|
||
|
|
assert captured["url"].endswith("?with_immutable_status=true")
|
||
|
|
|
||
|
|
|
||
|
|
def test_release_main_preflight_uses_fixed_credentials_and_destination(
|
||
|
|
monkeypatch: pytest.MonkeyPatch,
|
||
|
|
) -> None:
|
||
|
|
"""The preflight CLI validates and checks exactly the requested build tag."""
|
||
|
|
module = _load(RELEASE_SCRIPT, "hermes_image_release_main_absent")
|
||
|
|
revision = "3" * 40
|
||
|
|
destination = f"{module.DEFAULT_IMAGE}:git-{revision}-build-12"
|
||
|
|
captured = {}
|
||
|
|
monkeypatch.setenv("HARBOR_USER", "robot")
|
||
|
|
monkeypatch.setenv("HARBOR_PASSWORD", "private")
|
||
|
|
|
||
|
|
def assert_absent(value, **credentials):
|
||
|
|
captured["destination"] = value
|
||
|
|
captured["credentials"] = credentials
|
||
|
|
|
||
|
|
def verify_policy(**credentials):
|
||
|
|
captured["policy_credentials"] = credentials
|
||
|
|
|
||
|
|
monkeypatch.setattr(module, "assert_tag_absent", assert_absent)
|
||
|
|
monkeypatch.setattr(module, "verify_immutable_policy", verify_policy)
|
||
|
|
monkeypatch.setattr(
|
||
|
|
"sys.argv",
|
||
|
|
[
|
||
|
|
"hermes_image_release.py",
|
||
|
|
"assert-absent",
|
||
|
|
"--source-revision",
|
||
|
|
revision,
|
||
|
|
"--build-number",
|
||
|
|
"12",
|
||
|
|
"--destination",
|
||
|
|
destination,
|
||
|
|
],
|
||
|
|
)
|
||
|
|
assert module.main() == 0
|
||
|
|
assert captured == {
|
||
|
|
"destination": destination,
|
||
|
|
"credentials": {"username": "robot", "password": "private"},
|
||
|
|
"policy_credentials": {"username": "robot", "password": "private"},
|
||
|
|
}
|
||
|
|
|
||
|
|
|
||
|
|
def test_renderer_skips_a_matching_entry_without_a_digest() -> None:
|
||
|
|
"""Only the one complete image entry is changed when an earlier entry drifts."""
|
||
|
|
module = _load(RELEASE_SCRIPT, "hermes_image_release_manifest_scan")
|
||
|
|
digest = "sha256:" + "5" * 64
|
||
|
|
source = (
|
||
|
|
"images:\n"
|
||
|
|
f" - name: {module.DEFAULT_IMAGE}\n"
|
||
|
|
" newTag: ignored\n"
|
||
|
|
" - name: example.invalid/other\n"
|
||
|
|
" newTag: stable\n"
|
||
|
|
f" - name: {module.DEFAULT_IMAGE}\n"
|
||
|
|
" digest: sha256:" + "0" * 64 + "\n"
|
||
|
|
)
|
||
|
|
assert module.render_kustomization(source, digest).endswith(f"{digest}\n")
|
||
|
|
|
||
|
|
|
||
|
|
@pytest.mark.parametrize("status", [200, 202, 204, 301, 302, 307, 308])
|
||
|
|
def test_trigger_rejects_non_plugin_success_status(tmp_path: Path, status: int) -> None:
|
||
|
|
"""Generic proxy successes and redirects are not proof of a queued build."""
|
||
|
|
module = _load(TRIGGER_SCRIPT, f"jenkins_trigger_status_{status}")
|
||
|
|
token = tmp_path / "token"
|
||
|
|
token.write_text("private\n", encoding="utf-8")
|
||
|
|
response = lambda *_args, **_kwargs: Response( # noqa: E731
|
||
|
|
status, {"Location": "https://ci.bstein.dev/queue/item/9/"}
|
||
|
|
)
|
||
|
|
with pytest.raises(RuntimeError, match=f"HTTP {status}"):
|
||
|
|
module.trigger_build("a" * 40, token_file=token, opener=response)
|
||
|
|
|
||
|
|
|
||
|
|
@pytest.mark.parametrize(
|
||
|
|
"location",
|
||
|
|
[
|
||
|
|
"",
|
||
|
|
"https://evil.invalid/queue/item/9/",
|
||
|
|
"http://ci.bstein.dev/queue/item/9/",
|
||
|
|
"https://ci.bstein.dev/job/hermes-agent-image/9/",
|
||
|
|
"https://ci.bstein.dev/queue/item/9/?token=leak",
|
||
|
|
"https://ci.bstein.dev/queue/item/9/#fragment",
|
||
|
|
"https://ci.bstein.dev/queue/item/not-a-number/",
|
||
|
|
],
|
||
|
|
)
|
||
|
|
def test_trigger_rejects_missing_malformed_or_cross_origin_queue_location(
|
||
|
|
tmp_path: Path, location: str
|
||
|
|
) -> None:
|
||
|
|
"""A real accepted status still needs the exact same-origin queue resource."""
|
||
|
|
module = _load(TRIGGER_SCRIPT, "jenkins_trigger_location")
|
||
|
|
token = tmp_path / "token"
|
||
|
|
token.write_text("private\n", encoding="utf-8")
|
||
|
|
response = lambda *_args, **_kwargs: Response( # noqa: E731
|
||
|
|
201, {"Location": location}
|
||
|
|
)
|
||
|
|
with pytest.raises(RuntimeError, match="Location"):
|
||
|
|
module.trigger_build("b" * 40, token_file=token, opener=response)
|
||
|
|
|
||
|
|
|
||
|
|
def test_trigger_uses_https_and_accepts_exact_relative_queue_path(
|
||
|
|
tmp_path: Path,
|
||
|
|
) -> None:
|
||
|
|
"""A relative plugin Location is resolved only against the fixed HTTPS origin."""
|
||
|
|
module = _load(TRIGGER_SCRIPT, "jenkins_trigger_origin")
|
||
|
|
token = tmp_path / "token"
|
||
|
|
token.write_text("private\n", encoding="utf-8")
|
||
|
|
captured = {}
|
||
|
|
|
||
|
|
def opener(request, timeout):
|
||
|
|
captured["url"] = request.full_url
|
||
|
|
captured["timeout"] = timeout
|
||
|
|
return Response(201, {"Location": "/queue/item/11/"})
|
||
|
|
|
||
|
|
result = module.trigger_build("c" * 40, token_file=token, opener=opener)
|
||
|
|
assert captured["url"].startswith("https://ci.bstein.dev/")
|
||
|
|
assert captured["timeout"] == 20
|
||
|
|
assert result["queue_path"] == "/queue/item/11/"
|