diff --git a/runner/harness/sso.py b/runner/harness/sso.py index 603027d..0253fc9 100644 --- a/runner/harness/sso.py +++ b/runner/harness/sso.py @@ -22,6 +22,7 @@ from __future__ import annotations import contextlib import json import os +import re import secrets import ssl import urllib.error @@ -30,6 +31,10 @@ import urllib.request from . import lifecycle +# A per-run realm is named "-<6hex>" (the parent's per-run domain hex). This regex +# extracts that trailing hex so reaping can map a realm to the live app stack it belongs to. +_REALM_HEX_RE = re.compile(r"-([0-9a-f]{6})$") + _CTX = ssl.create_default_context() _CTX.check_hostname = False _CTX.verify_mode = ssl.CERT_NONE @@ -205,6 +210,76 @@ def setup_keycloak_realm( } +# --------------------------------------------------------------------------- +# Realm lifecycle on a shared (live-warm) keycloak (WC1) +# --------------------------------------------------------------------------- +# +# When keycloak is live-warm and shared, the per-run realm is the isolation unit: each dependent run +# creates a namespaced realm "-<6hex>" (setup_keycloak_realm) and deletes it at +# teardown (delete_keycloak_realm). Crashed/killed runs leave orphan realms behind; reap_orphaned_ +# realms removes those whose hex no longer maps to a live app stack (concurrency-safe — a realm +# belonging to a still-running run keeps its app stack, so its hex stays in `live_hexes`). + + +def list_realms(provider_domain: str, admin_password: str | None = None) -> list[str]: + """List realm names on the provider (admin API GET /admin/realms).""" + admin_password = admin_password or _kc_admin_password(provider_domain) + token = _kc_admin_token(provider_domain, admin_password) + status, body, _ = _kc_admin_call(provider_domain, token, "/realms", "GET") + if status != 200 or not isinstance(body, list): + raise RuntimeError(f"list realms failed: HTTP {status}") + return [r.get("realm", "") for r in body if r.get("realm")] + + +def delete_keycloak_realm( + provider_domain: str, realm: str, admin_password: str | None = None +) -> bool: + """Delete a realm idempotently (admin API DELETE /admin/realms/{realm}). Returns True if the + realm was deleted (204) or already absent (404); raises on any other status. Never deletes + `master` (guard against a caller passing the wrong name).""" + if realm == "master": + raise ValueError("refusing to delete the keycloak master realm") + admin_password = admin_password or _kc_admin_password(provider_domain) + token = _kc_admin_token(provider_domain, admin_password) + status, _, _ = _kc_admin_call(provider_domain, token, f"/realms/{realm}", "DELETE") + if status in (204, 404): + return True + raise RuntimeError(f"delete realm {realm} failed: HTTP {status}") + + +def realms_to_reap(realm_names, live_hexes) -> list[str]: + """PURE predicate (unit-tested): given all realm names on the provider and the set of 6hex + suffixes of currently-live app stacks, return the per-run realms to reap — those matching the + "-<6hex>" namespace whose hex is NOT live (orphans from crashed/killed runs). Never returns + `master` or realms that don't match the per-run pattern (e.g. an operator-created realm).""" + live = set(live_hexes or ()) + out: list[str] = [] + for name in realm_names or (): + if name == "master": + continue + m = _REALM_HEX_RE.search(name) + if m and m.group(1) not in live: + out.append(name) + return out + + +def reap_orphaned_realms( + provider_domain: str, live_hexes, admin_password: str | None = None +) -> list[str]: + """Reap per-run realms left behind by crashed/killed dependent runs. `live_hexes` is the set of + 6hex suffixes of currently-deployed app stacks (the caller derives these from docker). Returns + the list of realms actually deleted. Concurrency-safe: a realm whose hex maps to a live stack is + kept.""" + admin_password = admin_password or _kc_admin_password(provider_domain) + names = list_realms(provider_domain, admin_password) + reaped: list[str] = [] + for realm in realms_to_reap(names, live_hexes): + with contextlib.suppress(Exception): + delete_keycloak_realm(provider_domain, realm, admin_password) + reaped.append(realm) + return reaped + + # --------------------------------------------------------------------------- # OIDC flows # --------------------------------------------------------------------------- diff --git a/tests/unit/test_warm_realm.py b/tests/unit/test_warm_realm.py new file mode 100644 index 0000000..2c9f0e1 --- /dev/null +++ b/tests/unit/test_warm_realm.py @@ -0,0 +1,58 @@ +"""Unit tests for the WC1 realm-lifecycle predicate (runner/harness/sso.py). + +Pure-Python: no real keycloak. Tests `realms_to_reap`, the concurrency-safe predicate that decides +which per-run realms ("-<6hex>") are orphans to reap given the set of live app-stack hexes. +The admin-API ops (list/delete/reap) are exercised by real e2e against the warm keycloak (W0.4). +""" + +from __future__ import annotations + +import os +import sys + +sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "..", "runner")) +from harness import sso # noqa: E402 + + +def test_reap_skips_master(): + assert sso.realms_to_reap(["master"], set()) == [] + + +def test_reap_skips_non_pattern_realms(): + # Realms not matching "-<6hex>" (e.g. an operator-created realm) are never reaped. + assert sso.realms_to_reap(["master", "myrealm", "production"], set()) == [] + + +def test_reap_orphan_when_hex_not_live(): + realms = ["master", "lasuite-docs-0a6fb2", "cryptpad-deadbe"] + # No live stacks -> both per-run realms are orphans. + assert sso.realms_to_reap(realms, set()) == ["lasuite-docs-0a6fb2", "cryptpad-deadbe"] + + +def test_reap_keeps_live_hex(): + realms = ["master", "lasuite-docs-0a6fb2", "cryptpad-deadbe"] + # cryptpad's stack is live -> its realm is kept; the orphaned one is reaped. + assert sso.realms_to_reap(realms, {"deadbe"}) == ["lasuite-docs-0a6fb2"] + + +def test_reap_keeps_all_when_all_live(): + realms = ["lasuite-docs-0a6fb2", "cryptpad-deadbe"] + assert sso.realms_to_reap(realms, {"0a6fb2", "deadbe"}) == [] + + +def test_reap_only_matches_six_hex(): + # 5 hex or 7 hex / non-hex suffixes must NOT be treated as per-run realms. + realms = ["foo-0a6fb", "foo-0a6fb2x", "foo-zzzzzz", "foo-0a6fb2"] + assert sso.realms_to_reap(realms, set()) == ["foo-0a6fb2"] + + +def test_reap_handles_empty_and_none(): + assert sso.realms_to_reap([], set()) == [] + assert sso.realms_to_reap(None, None) == [] + + +def test_delete_master_refused(): + import pytest + + with pytest.raises(ValueError): + sso.delete_keycloak_realm("warm-keycloak.ci.commoninternet.net", "master")