332 lines
11 KiB
Python
332 lines
11 KiB
Python
"""Contracts for Hermes' read-only image release follow-through helper."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import importlib.util
|
|
import json
|
|
import subprocess
|
|
import sys
|
|
from pathlib import Path
|
|
|
|
import pytest
|
|
|
|
|
|
ROOT = Path(__file__).resolve().parents[2]
|
|
STATUS = ROOT / "services/hermes/scripts/hermes_image_release_status.py"
|
|
TRIGGER = 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)
|
|
sys.modules[name] = module
|
|
spec.loader.exec_module(module)
|
|
return module
|
|
|
|
|
|
def _condition(ready: bool = True, generation: int = 3) -> list[dict]:
|
|
return [
|
|
{
|
|
"type": "Ready",
|
|
"status": "True" if ready else "False",
|
|
"observedGeneration": generation,
|
|
}
|
|
]
|
|
|
|
|
|
def _policy(module, revision: str, digest: str, *, ready: bool = True) -> dict:
|
|
return {
|
|
"metadata": {"generation": 3},
|
|
"status": {
|
|
"conditions": _condition(ready),
|
|
"latestRef": {
|
|
"tag": f"git-{revision}-build-17-release",
|
|
"digest": digest,
|
|
},
|
|
},
|
|
}
|
|
|
|
|
|
def _controller(kind: str, repository: str, digest: str, *, ready: bool = True) -> dict:
|
|
status = {
|
|
"observedGeneration": 4,
|
|
"readyReplicas": 1 if ready else 0,
|
|
"updatedReplicas": 1,
|
|
"availableReplicas": 1,
|
|
"currentReplicas": 1,
|
|
"currentRevision": "revision-2",
|
|
"updateRevision": "revision-2",
|
|
}
|
|
if kind == "statefulset" and not ready:
|
|
status["currentRevision"] = "revision-1"
|
|
return {
|
|
"metadata": {"generation": 4},
|
|
"spec": {
|
|
"replicas": 1,
|
|
"template": {
|
|
"spec": {
|
|
"initContainers": [{"image": "busybox:1"}],
|
|
"containers": [{"image": f"{repository}@{digest}"}],
|
|
}
|
|
},
|
|
},
|
|
"status": status,
|
|
}
|
|
|
|
|
|
def _pods(repository: str, digest: str, *, ready: bool = True) -> dict:
|
|
return {
|
|
"items": [
|
|
{
|
|
"status": {
|
|
"conditions": [
|
|
{"type": "Ready", "status": "True" if ready else "False"}
|
|
],
|
|
"containerStatuses": [
|
|
{
|
|
"image": f"{repository}@{digest}",
|
|
"imageID": f"{repository}@{digest}",
|
|
}
|
|
],
|
|
}
|
|
}
|
|
]
|
|
}
|
|
|
|
|
|
def _healthy_objects(module, component: str, revision: str, digest: str) -> dict:
|
|
config = module.COMPONENTS[component]
|
|
objects = {
|
|
("hermes", "imagepolicy", config["policy"]): _policy(
|
|
module, revision, digest
|
|
),
|
|
("hermes", "imageupdateautomation", "hermes"): {
|
|
"metadata": {"generation": 3},
|
|
"status": {"conditions": _condition()},
|
|
},
|
|
("flux-system", "kustomization", "hermes"): {
|
|
"metadata": {"generation": 3},
|
|
"status": {
|
|
"conditions": _condition(),
|
|
"lastAppliedRevision": "main@sha1:" + "f" * 40,
|
|
},
|
|
},
|
|
}
|
|
for kind, name, label in config["workloads"]:
|
|
objects[("hermes", kind, name)] = _controller(
|
|
kind, config["repository"], digest
|
|
)
|
|
objects[("hermes", "pods", f"app={label}")] = _pods(
|
|
config["repository"], digest
|
|
)
|
|
return objects
|
|
|
|
|
|
def _install_fake(module, monkeypatch: pytest.MonkeyPatch, objects: dict) -> None:
|
|
def fake(*command):
|
|
namespace = command[1]
|
|
kind = command[3]
|
|
if kind == "pods":
|
|
key = (namespace, kind, command[5])
|
|
else:
|
|
key = (namespace, kind, command[4])
|
|
return objects[key]
|
|
|
|
monkeypatch.setattr(module, "_kubectl_json", fake)
|
|
|
|
|
|
@pytest.mark.parametrize("component", ["agent", "webui", "stt", "tts"])
|
|
def test_exact_release_convergence_covers_every_component(
|
|
monkeypatch: pytest.MonkeyPatch, component: str
|
|
) -> None:
|
|
"""Every lane binds policy source/digest to Ready desired and running state."""
|
|
module = _load(STATUS, f"release_status_{component}")
|
|
revision = "a" * 40
|
|
digest = "sha256:" + "b" * 64
|
|
_install_fake(module, monkeypatch, _healthy_objects(module, component, revision, digest))
|
|
|
|
result = module.inspect_release(component, revision)
|
|
|
|
assert result["converged"] is True
|
|
assert result["stage"] == "converged"
|
|
assert result["selected_revision"] == revision
|
|
assert result["digest"] == digest
|
|
assert result["build"] == 17
|
|
assert all(item["converged"] for item in result["workloads"])
|
|
|
|
|
|
def test_pending_and_different_policy_never_claim_deployment(
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
"""No or different immutable source remains visibly incomplete."""
|
|
module = _load(STATUS, "release_status_policy_pending")
|
|
revision = "a" * 40
|
|
digest = "sha256:" + "b" * 64
|
|
config = module.COMPONENTS["tts"]
|
|
objects = _healthy_objects(module, "tts", revision, digest)
|
|
policy_key = ("hermes", "imagepolicy", config["policy"])
|
|
objects[policy_key]["status"]["latestRef"] = {}
|
|
_install_fake(module, monkeypatch, objects)
|
|
assert module.inspect_release("tts", revision)["stage"] == "image_policy_pending"
|
|
|
|
objects[policy_key] = _policy(module, "c" * 40, digest)
|
|
result = module.inspect_release("tts", revision)
|
|
assert result["stage"] == "different_revision_selected"
|
|
assert result["selected_revision"] == "c" * 40
|
|
assert result["converged"] is False
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
("mutation", "expected"),
|
|
[
|
|
("automation", "image_automation_pending"),
|
|
("flux", "flux_apply_pending"),
|
|
("desired", "flux_apply_pending"),
|
|
("pod", "rollout_pending"),
|
|
],
|
|
)
|
|
def test_followthrough_reports_the_first_incomplete_delivery_stage(
|
|
monkeypatch: pytest.MonkeyPatch, mutation: str, expected: str
|
|
) -> None:
|
|
"""The helper separates promotion, GitOps apply, and live rollout gaps."""
|
|
module = _load(STATUS, f"release_status_stage_{mutation}")
|
|
revision = "d" * 40
|
|
digest = "sha256:" + "e" * 64
|
|
objects = _healthy_objects(module, "stt", revision, digest)
|
|
if mutation == "automation":
|
|
objects[("hermes", "imageupdateautomation", "hermes")]["status"][
|
|
"conditions"
|
|
] = _condition(False)
|
|
elif mutation == "flux":
|
|
objects[("flux-system", "kustomization", "hermes")]["status"][
|
|
"conditions"
|
|
] = _condition(False)
|
|
elif mutation == "desired":
|
|
objects[("hermes", "deployment", "hermes-stt")]["spec"]["template"][
|
|
"spec"
|
|
]["containers"][0]["image"] = "registry.bstein.dev/bstein/hermes-jetson-stt@sha256:" + "1" * 64
|
|
else:
|
|
objects[("hermes", "pods", "app=hermes-stt")] = _pods(
|
|
module.COMPONENTS["stt"]["repository"], digest, ready=False
|
|
)
|
|
_install_fake(module, monkeypatch, objects)
|
|
|
|
result = module.inspect_release("stt", revision)
|
|
|
|
assert result["stage"] == expected
|
|
assert result["converged"] is False
|
|
|
|
|
|
def test_helpers_fail_closed_on_stale_generation_and_malformed_references() -> None:
|
|
"""Malformed release evidence and stale Ready conditions are not accepted."""
|
|
module = _load(STATUS, "release_status_helpers")
|
|
assert module._condition_ready(
|
|
{"metadata": {"generation": 4}, "status": {"conditions": _condition(True, 3)}}
|
|
) is False
|
|
assert module._condition_ready({}) is False
|
|
assert module._release_ref({"status": {"latestRef": {"tag": "latest"}}}) == (
|
|
"latest",
|
|
None,
|
|
None,
|
|
None,
|
|
)
|
|
assert module._rollout_ready("deployment", _controller("deployment", "r", "d"))[2]
|
|
stale = _controller("deployment", "r", "d")
|
|
stale["status"]["observedGeneration"] = 1
|
|
assert module._rollout_ready("deployment", stale)[2] is False
|
|
with pytest.raises(ValueError):
|
|
module.inspect_release("invalid", "a" * 40)
|
|
with pytest.raises(ValueError):
|
|
module.inspect_release("agent", "main")
|
|
|
|
|
|
def test_kubectl_reader_is_bounded_and_requires_an_object(
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
"""Status uses argument arrays, a timeout, and object-shaped JSON only."""
|
|
module = _load(STATUS, "release_status_kubectl")
|
|
seen = {}
|
|
|
|
def run(command, **kwargs):
|
|
seen["command"] = command
|
|
seen["kwargs"] = kwargs
|
|
return subprocess.CompletedProcess(command, 0, '{"ok": true}', "")
|
|
|
|
monkeypatch.setattr(module.subprocess, "run", run)
|
|
assert module._kubectl_json("get", "pod", "demo") == {"ok": True}
|
|
assert seen["command"] == ["kubectl", "get", "pod", "demo", "-o", "json"]
|
|
assert seen["kwargs"]["timeout"] == 30
|
|
monkeypatch.setattr(
|
|
module.subprocess,
|
|
"run",
|
|
lambda *_args, **_kwargs: subprocess.CompletedProcess([], 0, "[]", ""),
|
|
)
|
|
with pytest.raises(ValueError, match="non-object"):
|
|
module._kubectl_json("get", "pods")
|
|
|
|
|
|
def test_cli_waits_for_convergence_and_returns_safe_json(
|
|
monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str]
|
|
) -> None:
|
|
"""Wait mode polls bounded status and terminates only at convergence."""
|
|
module = _load(STATUS, "release_status_main")
|
|
revision = "7" * 40
|
|
states = [
|
|
{"stage": "image_policy_pending", "converged": False},
|
|
{"stage": "converged", "converged": True, "digest": "sha256:" + "8" * 64},
|
|
]
|
|
monkeypatch.setattr(module, "inspect_release", lambda *_args: states.pop(0))
|
|
monkeypatch.setattr(module.time, "sleep", lambda _seconds: None)
|
|
assert module.main(
|
|
["--component", "agent", "--revision", revision, "--wait", "--poll", "1"]
|
|
) == 0
|
|
assert json.loads(capsys.readouterr().out)["converged"] is True
|
|
|
|
assert module.main(
|
|
["--component", "agent", "--revision", revision, "--timeout", "0"]
|
|
) == 2
|
|
assert "must be positive" in capsys.readouterr().err
|
|
|
|
|
|
def test_trigger_returns_a_token_free_exact_follow_command(tmp_path: Path) -> None:
|
|
"""A successful trigger tells Hermes how to prove the approved release landed."""
|
|
module = _load(TRIGGER, "release_status_trigger")
|
|
revision = "9" * 40
|
|
token = tmp_path / "token"
|
|
token.write_text("never-print-me\n", encoding="utf-8")
|
|
|
|
class Response:
|
|
status = 201
|
|
headers = {"Location": "/queue/item/91/"}
|
|
|
|
def __enter__(self):
|
|
return self
|
|
|
|
def __exit__(self, *_args):
|
|
return None
|
|
|
|
result = module.trigger_build(
|
|
revision,
|
|
component="webui",
|
|
token_file=token,
|
|
opener=lambda *_args, **_kwargs: Response(),
|
|
)
|
|
|
|
assert result["follow_command"] == (
|
|
"/opt/coordinator/hermes_image_release_status.py --component webui "
|
|
f"--revision {revision} --wait"
|
|
)
|
|
assert "never-print-me" not in json.dumps(result)
|
|
|
|
|
|
def test_runtime_bundle_and_guidance_require_live_convergence() -> None:
|
|
"""The helper ships to Worker and merged code alone is explicitly insufficient."""
|
|
kustomization = (ROOT / "services/hermes/kustomization.yaml").read_text()
|
|
guidance = (ROOT / "services/hermes/agent-configmap.yaml").read_text()
|
|
assert "hermes_image_release_status.py=scripts/hermes_image_release_status.py" in kustomization
|
|
assert "safe `follow_command`" in guidance
|
|
assert "Merged code is not a completed release" in guidance.replace(
|
|
"merged code", "Merged code"
|
|
)
|