The realm import is a one-shot: once Done=True it never re-runs, so a redirect URI
changed in git never reaches the realm. Healing that needs a DESIRED STATE the
reconciler can read -- and the reconciler pod has no Kubernetes API access at all
(automountServiceAccountToken: false), so it cannot read the KeycloakRealmImport CR.
Its desired state 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 import -- the same shape as the bug
being closed. So it is GENERATED from the import and the render is gated, the same
render==golden idiom catalog/render.py already uses for apps/.
URL strings are copied VERBATIM: a ${BASE_DOMAIN} token in the source appears
unchanged here, and both files are rendered by the SAME Flux Kustomization
(./platform-config), so both are substituted in one pass with one value. The
renderer never parses a hostname and never reads cluster-vars, so there is no
second place the domain can be decided.
Co-Authored-By: Claude Opus 5 <[email protected]>
215 lines
9.8 KiB
Python
Executable File
215 lines
9.8 KiB
Python
Executable File
#!/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())
|