feat(2w): W0.1 keycloak realm lifecycle primitives (WC1)

sso.py: list_realms, delete_keycloak_realm (idempotent, refuses master),
realms_to_reap (pure, concurrency-safe predicate), reap_orphaned_realms.
The per-run realm is the isolation unit on a shared live-warm keycloak;
orphans (crashed runs) reaped by hex not mapping to a live app stack.
+8 unit tests (tests/unit/test_warm_realm.py); 43 unit pass on cc-ci.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-28 23:16:48 +01:00
parent 5dd76d7c8c
commit 74bf8c1723
2 changed files with 133 additions and 0 deletions

View File

@ -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 "<parent_recipe>-<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 "<parent_recipe>-<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
# ---------------------------------------------------------------------------

View File

@ -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 ("<parent>-<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")