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:
@ -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
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
58
tests/unit/test_warm_realm.py
Normal file
58
tests/unit/test_warm_realm.py
Normal 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")
|
||||
Reference in New Issue
Block a user