#!/usr/bin/env python3 # ============================================================================= # ci/render-keycloak-client-urls.py # # Render platform/keycloak/client-urls.yaml FROM platform/keycloak/keycloak-realm-import.yaml. # # WHY THIS EXISTS # --------------- # The Keycloak realm import is a ONE-SHOT that fails OPEN: once it rests at # Done=True / Started=False it never re-runs, so a redirect URI changed in git is # never applied to the realm. The client-secret half of that failure class is # already healed by a level-triggered CronJob (platform/keycloak/client-secret- # reconciler.yaml). This renderer supplies the DESIRED STATE that the same CronJob # needs to heal the URL half. # # THE CONSTRAINT THAT SHAPES IT: the reconciler pod runs with # automountServiceAccountToken: false. It has NO Kubernetes API access at all, by # design (R-SEC-02) — so it cannot read the KeycloakRealmImport CR to learn what # the realm is supposed to look like. Its inputs must be MOUNTED. Hence a # ConfigMap. # # A hand-authored ConfigMap would be a SECOND SOURCE OF TRUTH for every public # hostname in the realm, free to disagree with the realm import — and "two # declarations that can disagree" is the same shape as the bug this whole effort # is closing. So the ConfigMap is GENERATED from the realm import and the # generation is GATED: conformance/tests/l0-static/keycloak-client-secret- # reconciler/check.sh runs this script with --check on every merge, and the merge # fails if the committed file is not what this renderer produces. That is the same # render==golden idiom catalog/render.py + ci/render-golden.sh already use for # apps/. # # STRINGS ARE COPIED VERBATIM, NEVER RE-DERIVED. If a URL in the realm import is # written as a Flux substitution token (https://grafana.${BASE_DOMAIN}/...), the # token is carried through into the ConfigMap CHARACTER FOR CHARACTER. Both files # are rendered by the SAME Flux Kustomization (./platform-config, whose # postBuild.substituteFrom reads cluster-vars), so both are substituted in the # same pass with the same values and cannot disagree about what BASE_DOMAIN is. # This renderer never parses, splits, or reconstructs a hostname, and never reads # cluster-vars — so there is no second place for the domain to be decided. # # USAGE # python3 ci/render-keycloak-client-urls.py # print the rendered file # python3 ci/render-keycloak-client-urls.py --write # write it (regenerate) # python3 ci/render-keycloak-client-urls.py --check # exit 1 if it has drifted # ============================================================================= from __future__ import annotations import argparse import difflib import json import sys from pathlib import Path try: import yaml except ImportError: # pragma: no cover print("FATAL: PyYAML is required (same dependency as catalog/render.py)", file=sys.stderr) raise SystemExit(1) ROOT = Path(__file__).resolve().parent.parent IMPORT_FILE = "platform/keycloak/keycloak-realm-import.yaml" OUT_FILE = "platform/keycloak/client-urls.yaml" DATA_KEY = "client-urls.json" CONFIGMAP_NAME = "keycloak-client-urls" NAMESPACE = "keycloak" # The client fields this renderer exports, and therefore the ONLY fields the # reconciler may write. Every one of them is an environment-derived URL: it changes # when BASE_DOMAIN changes, and a stale value breaks OIDC login (a wrong # redirectUri) or silently widens trust (a stale wildcard left behind). # # DELIBERATELY ABSENT: `secret` (healed by the secret pass from the SOPS Secret, # and it must never appear in a ConfigMap), and every non-URL field — # protocolMappers, defaultClientScopes, attributes, flags. Those are not # environment-derived, so nothing re-renders them on a domain flip; keeping them # out keeps the reconciler's write surface as narrow as the problem. LIST_FIELDS = ("redirectUris", "webOrigins") STR_FIELDS = ("rootUrl", "baseUrl", "adminUrl") def render(root: Path) -> str: """Return the full text of client-urls.yaml as it should be committed.""" docs = list(yaml.safe_load_all((root / IMPORT_FILE).read_text())) imp = next((d for d in docs if d and d.get("kind") == "KeycloakRealmImport"), None) if imp is None: raise SystemExit("FATAL: no KeycloakRealmImport document in %s" % IMPORT_FILE) clients = (imp.get("spec") or {}).get("realm", {}).get("clients") or [] if not clients: # Fail closed. Emitting an empty desired state would make the reconciler # believe every client is unmanaged and silently stop reconciling URLs. raise SystemExit("FATAL: %s declares zero clients — refusing to render an " "empty desired state" % IMPORT_FILE) want: dict[str, dict] = {} for c in clients: cid = c.get("clientId") if not isinstance(cid, str) or not cid: raise SystemExit("FATAL: %s has a client with no clientId" % IMPORT_FILE) entry: dict[str, object] = {} for f in LIST_FIELDS: if f not in c: continue # not declared => NOT MANAGED (never forced empty) v = c[f] if not isinstance(v, list) or not all(isinstance(x, str) for x in v): raise SystemExit("FATAL: %s client %r field %s is not a list of strings" % (IMPORT_FILE, cid, f)) entry[f] = list(v) # verbatim, order as authored for f in STR_FIELDS: if f not in c: continue v = c[f] if not isinstance(v, str): raise SystemExit("FATAL: %s client %r field %s is not a string" % (IMPORT_FILE, cid, f)) entry[f] = v if entry: want[cid] = entry if not want: raise SystemExit("FATAL: no client in %s declares any of %s — refusing to render " "an empty desired state" % (IMPORT_FILE, ", ".join(LIST_FIELDS + STR_FIELDS))) body = json.dumps(want, indent=2, sort_keys=True, ensure_ascii=False) indented = "\n".join((" " + ln) if ln else "" for ln in body.splitlines()) return HEADER + indented + "\n" HEADER = """--- # ============================================================================= # GENERATED FILE — DO NOT EDIT BY HAND. # # source: platform/keycloak/keycloak-realm-import.yaml (spec.realm.clients) # regenerate: python3 ci/render-keycloak-client-urls.py --write # gated by: conformance/tests/l0-static/keycloak-client-secret-reconciler # (runs this renderer with --check on every merge) # # WHAT THIS IS: the DESIRED STATE for every environment-derived client URL in the # `homelab` realm — redirectUris, webOrigins, rootUrl, baseUrl, adminUrl. The # client-secret reconciler CronJob mounts it and makes the live realm match it. # # WHY IT IS A SEPARATE OBJECT: the realm import is a ONE-SHOT that never re-runs # once Done=True, so a redirect URI edited in git never reaches the realm. Prod's # stored redirect URIs were measured still pointing at the pre-migration hostname # while git declared the new one, and every OIDC login would have failed with a # redirect_uri mismatch. The reconciler is the level-triggered loop that closes # that; this file is what it reconciles TOWARDS. # # WHY IT IS GENERATED AND NOT AUTHORED: a hand-written copy of every public # hostname would be a second source of truth, free to disagree with the realm # import. It is rendered from the import and the render is gated, so the two # cannot drift. URL strings are copied VERBATIM — if the source uses a # ${BASE_DOMAIN} token it appears here unchanged, and both files are substituted # by the SAME Flux Kustomization (./platform-config), so the domain is decided in # exactly one place. # # A field that is NOT declared on a client in the realm import is simply ABSENT # here, and the reconciler leaves it alone. Absence means UNMANAGED, never # "force to empty". # ============================================================================= apiVersion: v1 kind: ConfigMap metadata: name: %s namespace: %s labels: app.kubernetes.io/name: %s app.kubernetes.io/part-of: keycloak data: %s: | """ % (CONFIGMAP_NAME, NAMESPACE, CONFIGMAP_NAME, DATA_KEY) def main() -> int: ap = argparse.ArgumentParser(description=__doc__) g = ap.add_mutually_exclusive_group() g.add_argument("--write", action="store_true", help="write %s" % OUT_FILE) g.add_argument("--check", action="store_true", help="exit 1 if the committed file has drifted from the source") args = ap.parse_args() want = render(ROOT) target = ROOT / OUT_FILE if args.write: target.write_text(want, encoding="utf-8") print("wrote %s" % OUT_FILE) return 0 if args.check: if not target.is_file(): print("CHECK FAIL: %s does not exist. Run: python3 " "ci/render-keycloak-client-urls.py --write" % OUT_FILE, file=sys.stderr) return 1 have = target.read_text(encoding="utf-8") if have == want: print("client-urls.yaml is in sync with %s" % IMPORT_FILE) return 0 print("CHECK FAIL: %s has DRIFTED from %s — the reconciler would converge the " "realm on a desired state the realm import does not declare.\n" "Regenerate with: python3 ci/render-keycloak-client-urls.py --write\n" % (OUT_FILE, IMPORT_FILE), file=sys.stderr) for line in difflib.unified_diff(have.splitlines(True), want.splitlines(True), fromfile="committed " + OUT_FILE, tofile="rendered from " + IMPORT_FILE): sys.stderr.write(line) return 1 sys.stdout.write(want) return 0 if __name__ == "__main__": raise SystemExit(main())