atlas-iac/testing/tests/test_hermes_public_host_continuity.py

164 lines
6.1 KiB
Python
Raw Normal View History

"""Public chat/triage hostnames stay served on every layer during a rename.
PR #34 renamed the chat and triage public hosts in place: the legacy names left
the certificate SANs, the Ingress rules, the CoreDNS overrides and the Keycloak
ensure script in one change. The legacy hosts began answering 404 with Traefik's
default certificate while the renamed hosts could not finish a login, because
Keycloak matches ``redirect_uri`` exactly and still held the old callback. Chat
and triage were unreachable on every hostname at once.
These tests pin the contract that makes that outage impossible to reintroduce
silently: a public host is either served by all four layers or by none of them.
"""
from __future__ import annotations
import re
from pathlib import Path
import pytest
import yaml
REPO = Path(__file__).parents[2]
HERMES = REPO / "services/hermes"
KEYCLOAK = REPO / "services/keycloak"
COREDNS = REPO / "infrastructure/core/coredns-custom.yaml"
ENSURE_SCRIPT = KEYCLOAK / "scripts/hermes_access_oidc_ensure.sh"
# Every hostname the chat/triage surfaces must answer on, with the oauth2-proxy
# backend that serves it. Both the renamed and the legacy names belong here
# until a deliberate retirement change removes a row from this table.
PUBLIC_HOSTS = {
"chat.bstein.dev": "oauth2-proxy-hermes-chat",
"chat.hermes.bstein.dev": "oauth2-proxy-hermes-chat",
"triage.bstein.dev": "oauth2-proxy-hermes-triage",
"triage.hermes.bstein.dev": "oauth2-proxy-hermes-triage",
}
refactor(hermes): rename the owner agent host to worker.bstein.dev Introduce worker.bstein.dev as the canonical hostname for the owner-only Hermes coordinator, previously agent.hermes.bstein.dev. The rename is additive, matching the shape #38 restored for chat and triage. CoreDNS, both agent Ingresses and the hermes-sites certificate now serve BOTH names, so merging this cannot take away the endpoint the operator uses to reach the coordinator. Retiring agent.hermes.bstein.dev is a separate, separately scheduled change. No redirect middleware is added. What switches to the new host: - HERMES_DASHBOARD_PUBLIC_URL and the oauth2-proxy --redirect-url - the Keycloak agent proxy rootUrl - operator docs, skills, the ZAP baseline target and the triage monitor default What stays dual-homed until retirement: - CoreDNS hosts entry, both agent Ingress rules, certificate SANs - API_SERVER_CORS_ORIGINS (now a comma-separated pair) - the Keycloak redirect URIs, web origins and post-logout origins, so a rollback only needs the oauth2-proxy --redirect-url reverted and does not require re-running the ensure job The agent client passes its legacy origin through the optional fourth argument #38 added to ensure_proxy_client, so no second mechanism is introduced. The immutable ensure Job goes -11 -> -12 because #38 already consumed -11 and that run has completed; without a further bump this change would never be applied. Login on the new host fails until the -12 Job completes. Because the session and CSRF cookies use the __Host- prefix they are bound to one origin, so a fresh login must start on worker.bstein.dev and existing sessions do not carry over -- re-login is required after rollout. #38's public-host continuity test now covers the agent proxy's dual origins rather than asserting the agent surface was untouched by the rename. Knowledge catalogs and diagrams regenerated with `make knowledge`.
2026-08-21 10:29:46 +00:00
# The agent surface is mid-rename too: worker.bstein.dev is canonical and the
# legacy name stays served until a deliberate retirement change removes it.
AGENT_HOST = "agent.hermes.bstein.dev"
refactor(hermes): rename the owner agent host to worker.bstein.dev Introduce worker.bstein.dev as the canonical hostname for the owner-only Hermes coordinator, previously agent.hermes.bstein.dev. The rename is additive, matching the shape #38 restored for chat and triage. CoreDNS, both agent Ingresses and the hermes-sites certificate now serve BOTH names, so merging this cannot take away the endpoint the operator uses to reach the coordinator. Retiring agent.hermes.bstein.dev is a separate, separately scheduled change. No redirect middleware is added. What switches to the new host: - HERMES_DASHBOARD_PUBLIC_URL and the oauth2-proxy --redirect-url - the Keycloak agent proxy rootUrl - operator docs, skills, the ZAP baseline target and the triage monitor default What stays dual-homed until retirement: - CoreDNS hosts entry, both agent Ingress rules, certificate SANs - API_SERVER_CORS_ORIGINS (now a comma-separated pair) - the Keycloak redirect URIs, web origins and post-logout origins, so a rollback only needs the oauth2-proxy --redirect-url reverted and does not require re-running the ensure job The agent client passes its legacy origin through the optional fourth argument #38 added to ensure_proxy_client, so no second mechanism is introduced. The immutable ensure Job goes -11 -> -12 because #38 already consumed -11 and that run has completed; without a further bump this change would never be applied. Login on the new host fails until the -12 Job completes. Because the session and CSRF cookies use the __Host- prefix they are bound to one origin, so a fresh login must start on worker.bstein.dev and existing sessions do not carry over -- re-login is required after rollout. #38's public-host continuity test now covers the agent proxy's dual origins rather than asserting the agent surface was untouched by the rename. Knowledge catalogs and diagrams regenerated with `make knowledge`.
2026-08-21 10:29:46 +00:00
AGENT_CANONICAL_HOST = "worker.bstein.dev"
def _docs(path: Path) -> list[dict]:
return [doc for doc in yaml.safe_load_all(path.read_text()) if doc]
def _named(path: Path, kind: str, name: str) -> dict:
for doc in _docs(path):
if doc.get("kind") == kind and doc["metadata"]["name"] == name:
return doc
raise AssertionError(f"{kind}/{name} missing from {path}")
@pytest.fixture(scope="module")
def certificate() -> dict:
return _named(HERMES / "agent-certificate.yaml", "Certificate", "hermes-sites-tls")
@pytest.fixture(scope="module")
def sites_ingress() -> dict:
return _named(HERMES / "agent-ingress.yaml", "Ingress", "hermes-sites")
@pytest.fixture(scope="module")
def coredns_hosts() -> set[str]:
block = yaml.safe_load(COREDNS.read_text())["data"]["bstein-dev.server"]
return {
line.split()[1]
for line in block.splitlines()
if len(line.split()) == 2 and re.fullmatch(r"[\d.]+", line.split()[0])
}
@pytest.fixture(scope="module")
def ensure_script() -> str:
return ENSURE_SCRIPT.read_text()
@pytest.mark.parametrize("host", sorted(PUBLIC_HOSTS))
def test_host_is_on_the_shared_certificate(host: str, certificate: dict):
"""A host without a SAN serves Traefik's default self-signed certificate."""
assert host in certificate["spec"]["dnsNames"]
@pytest.mark.parametrize("host", sorted(PUBLIC_HOSTS))
def test_host_resolves_inside_the_cluster(host: str, coredns_hosts: set[str]):
assert host in coredns_hosts
@pytest.mark.parametrize("host", sorted(PUBLIC_HOSTS))
def test_host_has_an_ingress_rule_and_tls_entry(host: str, sites_ingress: dict):
"""A host without a rule answers 404 even though DNS and TLS look healthy."""
spec = sites_ingress["spec"]
assert host in {name for entry in spec["tls"] for name in entry["hosts"]}
rule = next((item for item in spec["rules"] if item["host"] == host), None)
assert rule is not None, f"no hermes-sites rule serves {host}"
backends = {
path["backend"]["service"]["name"] for path in rule["http"]["paths"]
}
assert backends == {PUBLIC_HOSTS[host]}
@pytest.mark.parametrize("host", sorted(PUBLIC_HOSTS))
def test_host_is_registered_with_keycloak(host: str, ensure_script: str):
"""Keycloak matches redirect_uri exactly, so every served host needs one."""
assert f"https://{host}" in ensure_script
refactor(hermes): rename the owner agent host to worker.bstein.dev Introduce worker.bstein.dev as the canonical hostname for the owner-only Hermes coordinator, previously agent.hermes.bstein.dev. The rename is additive, matching the shape #38 restored for chat and triage. CoreDNS, both agent Ingresses and the hermes-sites certificate now serve BOTH names, so merging this cannot take away the endpoint the operator uses to reach the coordinator. Retiring agent.hermes.bstein.dev is a separate, separately scheduled change. No redirect middleware is added. What switches to the new host: - HERMES_DASHBOARD_PUBLIC_URL and the oauth2-proxy --redirect-url - the Keycloak agent proxy rootUrl - operator docs, skills, the ZAP baseline target and the triage monitor default What stays dual-homed until retirement: - CoreDNS hosts entry, both agent Ingress rules, certificate SANs - API_SERVER_CORS_ORIGINS (now a comma-separated pair) - the Keycloak redirect URIs, web origins and post-logout origins, so a rollback only needs the oauth2-proxy --redirect-url reverted and does not require re-running the ensure job The agent client passes its legacy origin through the optional fourth argument #38 added to ensure_proxy_client, so no second mechanism is introduced. The immutable ensure Job goes -11 -> -12 because #38 already consumed -11 and that run has completed; without a further bump this change would never be applied. Login on the new host fails until the -12 Job completes. Because the session and CSRF cookies use the __Host- prefix they are bound to one origin, so a fresh login must start on worker.bstein.dev and existing sessions do not carry over -- re-login is required after rollout. #38's public-host continuity test now covers the agent proxy's dual origins rather than asserting the agent surface was untouched by the rename. Knowledge catalogs and diagrams regenerated with `make knowledge`.
2026-08-21 10:29:46 +00:00
@pytest.mark.parametrize("host", [AGENT_HOST, AGENT_CANONICAL_HOST])
def test_agent_surface_serves_both_hosts_during_its_rename(
host: str, certificate: dict, coredns_hosts: set[str], ensure_script: str
):
refactor(hermes): rename the owner agent host to worker.bstein.dev Introduce worker.bstein.dev as the canonical hostname for the owner-only Hermes coordinator, previously agent.hermes.bstein.dev. The rename is additive, matching the shape #38 restored for chat and triage. CoreDNS, both agent Ingresses and the hermes-sites certificate now serve BOTH names, so merging this cannot take away the endpoint the operator uses to reach the coordinator. Retiring agent.hermes.bstein.dev is a separate, separately scheduled change. No redirect middleware is added. What switches to the new host: - HERMES_DASHBOARD_PUBLIC_URL and the oauth2-proxy --redirect-url - the Keycloak agent proxy rootUrl - operator docs, skills, the ZAP baseline target and the triage monitor default What stays dual-homed until retirement: - CoreDNS hosts entry, both agent Ingress rules, certificate SANs - API_SERVER_CORS_ORIGINS (now a comma-separated pair) - the Keycloak redirect URIs, web origins and post-logout origins, so a rollback only needs the oauth2-proxy --redirect-url reverted and does not require re-running the ensure job The agent client passes its legacy origin through the optional fourth argument #38 added to ensure_proxy_client, so no second mechanism is introduced. The immutable ensure Job goes -11 -> -12 because #38 already consumed -11 and that run has completed; without a further bump this change would never be applied. Login on the new host fails until the -12 Job completes. Because the session and CSRF cookies use the __Host- prefix they are bound to one origin, so a fresh login must start on worker.bstein.dev and existing sessions do not carry over -- re-login is required after rollout. #38's public-host continuity test now covers the agent proxy's dual origins rather than asserting the agent surface was untouched by the rename. Knowledge catalogs and diagrams regenerated with `make knowledge`.
2026-08-21 10:29:46 +00:00
"""Retiring the legacy owner host is a separate, deliberate change.
The agent hosts are served by hermes-agent-dashboard/-terminal rather than
hermes-sites, so their Ingress rules are asserted in
``test_hermes_agent_runtime_patches.py`` instead of the table above.
"""
assert host in certificate["spec"]["dnsNames"]
assert host in coredns_hosts
assert f"https://{host}" in ensure_script
def test_ensure_script_registers_legacy_and_renamed_origins_together(
ensure_script: str,
):
refactor(hermes): rename the owner agent host to worker.bstein.dev Introduce worker.bstein.dev as the canonical hostname for the owner-only Hermes coordinator, previously agent.hermes.bstein.dev. The rename is additive, matching the shape #38 restored for chat and triage. CoreDNS, both agent Ingresses and the hermes-sites certificate now serve BOTH names, so merging this cannot take away the endpoint the operator uses to reach the coordinator. Retiring agent.hermes.bstein.dev is a separate, separately scheduled change. No redirect middleware is added. What switches to the new host: - HERMES_DASHBOARD_PUBLIC_URL and the oauth2-proxy --redirect-url - the Keycloak agent proxy rootUrl - operator docs, skills, the ZAP baseline target and the triage monitor default What stays dual-homed until retirement: - CoreDNS hosts entry, both agent Ingress rules, certificate SANs - API_SERVER_CORS_ORIGINS (now a comma-separated pair) - the Keycloak redirect URIs, web origins and post-logout origins, so a rollback only needs the oauth2-proxy --redirect-url reverted and does not require re-running the ensure job The agent client passes its legacy origin through the optional fourth argument #38 added to ensure_proxy_client, so no second mechanism is introduced. The immutable ensure Job goes -11 -> -12 because #38 already consumed -11 and that run has completed; without a further bump this change would never be applied. Login on the new host fails until the -12 Job completes. Because the session and CSRF cookies use the __Host- prefix they are bound to one origin, so a fresh login must start on worker.bstein.dev and existing sessions do not carry over -- re-login is required after rollout. #38's public-host continuity test now covers the agent proxy's dual origins rather than asserting the agent surface was untouched by the rename. Knowledge catalogs and diagrams regenerated with `make knowledge`.
2026-08-21 10:29:46 +00:00
"""Every renamed proxy must accept its old and new origin at the same time."""
for client, canonical, legacy in (
("hermes-chat-proxy", "chat.bstein.dev", "chat.hermes.bstein.dev"),
("hermes-triage-proxy", "triage.bstein.dev", "triage.hermes.bstein.dev"),
refactor(hermes): rename the owner agent host to worker.bstein.dev Introduce worker.bstein.dev as the canonical hostname for the owner-only Hermes coordinator, previously agent.hermes.bstein.dev. The rename is additive, matching the shape #38 restored for chat and triage. CoreDNS, both agent Ingresses and the hermes-sites certificate now serve BOTH names, so merging this cannot take away the endpoint the operator uses to reach the coordinator. Retiring agent.hermes.bstein.dev is a separate, separately scheduled change. No redirect middleware is added. What switches to the new host: - HERMES_DASHBOARD_PUBLIC_URL and the oauth2-proxy --redirect-url - the Keycloak agent proxy rootUrl - operator docs, skills, the ZAP baseline target and the triage monitor default What stays dual-homed until retirement: - CoreDNS hosts entry, both agent Ingress rules, certificate SANs - API_SERVER_CORS_ORIGINS (now a comma-separated pair) - the Keycloak redirect URIs, web origins and post-logout origins, so a rollback only needs the oauth2-proxy --redirect-url reverted and does not require re-running the ensure job The agent client passes its legacy origin through the optional fourth argument #38 added to ensure_proxy_client, so no second mechanism is introduced. The immutable ensure Job goes -11 -> -12 because #38 already consumed -11 and that run has completed; without a further bump this change would never be applied. Login on the new host fails until the -12 Job completes. Because the session and CSRF cookies use the __Host- prefix they are bound to one origin, so a fresh login must start on worker.bstein.dev and existing sessions do not carry over -- re-login is required after rollout. #38's public-host continuity test now covers the agent proxy's dual origins rather than asserting the agent surface was untouched by the rename. Knowledge catalogs and diagrams regenerated with `make knowledge`.
2026-08-21 10:29:46 +00:00
("hermes-agent-proxy", "worker.bstein.dev", "agent.hermes.bstein.dev"),
):
call = re.search(
rf'ensure_proxy_client "{client}".*?(?=\nensure_)',
ensure_script,
re.DOTALL,
)
assert call, f"{client} is never ensured"
assert f"https://{canonical}" in call.group(0)
assert f"https://{legacy}" in call.group(0)
def test_ensure_job_is_rerun_whenever_the_script_changes():
"""The Job is immutable, so a stale name silently skips the rerun."""
job = _named(
KEYCLOAK / "bootstrap-jobs/hermes-access-oidc-client-job.yaml",
"Job",
# The suffix moves with every rerun; resolve it from the manifest.
_job_name(),
)
assert job["spec"]["template"]["spec"]["containers"][0]["command"] == [
"/scripts/hermes_access_oidc_ensure.sh"
]
def _job_name() -> str:
path = KEYCLOAK / "bootstrap-jobs/hermes-access-oidc-client-job.yaml"
name = yaml.safe_load(path.read_text())["metadata"]["name"]
assert re.fullmatch(r"hermes-access-oidc-client-ensure-\d+", name), name
return name