test(conformance): gate the URL desired state, and prove the selftest can fail
L0 (212 assertions, was 175):
* runs ci/render-keycloak-client-urls.py --check, so client-urls.yaml must be exactly
what the realm import renders. A hand edit that makes the two disagree is
unmergeable -- this is what keeps the realm import the single source of truth for
hostnames rather than just the place they were first typed.
* asserts client-urls.yaml and the realm import are rendered by the SAME Flux
Kustomization. postBuild.substituteFrom is per-Kustomization, so a split would let
one ${BASE_DOMAIN} resolve to two different values and the realm would converge on
a hostname the HTTPRoutes do not serve.
* asserts the desired state uses no substitution token the realm definition lacks,
names only clients the import declares, and declares no field outside the
five-field write surface; that the CronJob mounts it readOnly at the path the
script defaults to; and that the script, renderer and check agree on which fields
are writable.
* NEW mutations.py: injects 12 plausible regressions into the EXTRACTED script and
requires --selftest to catch every one. A passing suite proves nothing if it cannot
fail, and "reported success while the realm was broken" is precisely what this
component exists to end -- so the same standard applies to its own test. ~20s.
L2: the audit Job now mounts keycloak-client-urls and asserts
KEYCLOAK_CLIENT_URLS_AUDIT_OK. Without that it would pass on a realm still holding
pre-migration redirect URIs -- the exact state prod was measured in.
Alerts: the existing Failing/Stale pair is EXTENDED to name redirect-URI drift, not
duplicated. One CronJob gets one pair; a second pair on the same job_name regex would
fire twice for one cause. No new critical, no new permanently-firing rule.
Co-Authored-By: Claude Opus 5 <[email protected]>
This commit is contained in:
1 parent
23ea72d01e
commit
6202d92c27
4 files changed
+465
-20
No files matched your search
@@ -56,6 +56,23 @@
|
|||||||
# no-secret-logging, the endpoint allow-list (users/groups/roles are
|
# no-secret-logging, the endpoint allow-list (users/groups/roles are
|
||||||
# unreachable) and fail-closed behaviour. What CI proves is byte-for-byte
|
# unreachable) and fail-closed behaviour. What CI proves is byte-for-byte
|
||||||
# what ships.
|
# what ships.
|
||||||
|
# (6b) AND THAT PROOF IS FALSIFIABLE: mutations.py injects plausible regressions
|
||||||
|
# into the extracted script one at a time and requires --selftest to CATCH
|
||||||
|
# every one. A passing suite proves nothing if it cannot fail, and "reported
|
||||||
|
# success while the realm was broken" is the exact failure this component
|
||||||
|
# exists to end — so the same standard is applied to its own test.
|
||||||
|
# (8) THE URL DESIRED STATE IS DERIVED, NOT DECLARED TWICE: the reconciler's
|
||||||
|
# second pass converges the realm's environment-derived client URLs
|
||||||
|
# (redirectUris, webOrigins, rootUrl, baseUrl, adminUrl) on
|
||||||
|
# platform/keycloak/client-urls.yaml. That file is GENERATED from the realm
|
||||||
|
# import by ci/render-keycloak-client-urls.py, and this check RUNS the
|
||||||
|
# renderer with --check, so a hand edit that makes the two disagree is
|
||||||
|
# unmergeable. It further asserts the two files are rendered by the SAME Flux
|
||||||
|
# Kustomization (postBuild.substituteFrom is per-Kustomization, so a split
|
||||||
|
# would let one ${BASE_DOMAIN} resolve to two different values), that the
|
||||||
|
# desired state uses no substitution token the realm definition does not,
|
||||||
|
# that every client it names exists in the import, and that it declares no
|
||||||
|
# field outside the reconciler's five-field write surface.
|
||||||
# AND (7) THIS CHECK IS LOAD-BEARING: ci/l0.sh runs it unconditionally, ci/gate.sh
|
# AND (7) THIS CHECK IS LOAD-BEARING: ci/l0.sh runs it unconditionally, ci/gate.sh
|
||||||
# blocks the merge when the l0 stage fails, ci/touched-requirements.sh maps
|
# blocks the merge when the l0 stage fails, ci/touched-requirements.sh maps
|
||||||
# platform/keycloak/ to these requirement IDs, and an L2 test exists that
|
# platform/keycloak/ to these requirement IDs, and an L2 test exists that
|
||||||
@@ -63,8 +80,13 @@
|
|||||||
# and isn't" failure this repo keeps hitting.
|
# and isn't" failure this repo keeps hitting.
|
||||||
#
|
#
|
||||||
# NOT proven here (named honestly, in the style of the sibling L0 checks):
|
# NOT proven here (named honestly, in the style of the sibling L0 checks):
|
||||||
# - that the LIVE realm's stored secrets match git. That is cluster state; it is
|
# - that the LIVE realm's stored secrets OR redirect URIs match git. That is cluster
|
||||||
# conformance/tests/l2-behavioral/keycloak-client-secrets-match-git.
|
# state; it is conformance/tests/l2-behavioral/keycloak-client-secrets-match-git,
|
||||||
|
# which runs the reconciler's own --audit against the live realm.
|
||||||
|
# - that the hostnames the realm import declares are the ones the Gateway/HTTPRoutes
|
||||||
|
# actually serve. This check proves the realm converges on what GIT says; it cannot
|
||||||
|
# prove git is right about the domain. A BASE_DOMAIN flip that misses an HTTPRoute is
|
||||||
|
# a different invariant.
|
||||||
# - that the SOPS ciphertext decrypts to a usable secret. Key PRESENCE is checkable
|
# - that the SOPS ciphertext decrypts to a usable secret. Key PRESENCE is checkable
|
||||||
# offline; the plaintext is not, and must not be.
|
# offline; the plaintext is not, and must not be.
|
||||||
# - that the CronJob's image can be pulled or its schedule fires.
|
# - that the CronJob's image can be pulled or its schedule fires.
|
||||||
@@ -77,9 +99,10 @@ PYTHON="${PYTHON:-python3}"
|
|||||||
IMPORT_FILE="platform/keycloak/keycloak-realm-import.yaml"
|
IMPORT_FILE="platform/keycloak/keycloak-realm-import.yaml"
|
||||||
SOPS_FILE="platform/keycloak/keycloak-realm-clients.sops.yaml"
|
SOPS_FILE="platform/keycloak/keycloak-realm-clients.sops.yaml"
|
||||||
RECON_FILE="platform/keycloak/client-secret-reconciler.yaml"
|
RECON_FILE="platform/keycloak/client-secret-reconciler.yaml"
|
||||||
|
URLS_FILE="platform/keycloak/client-urls.yaml"
|
||||||
|
|
||||||
# Honest SKIP (never green) if the thing under test is absent entirely.
|
# Honest SKIP (never green) if the thing under test is absent entirely.
|
||||||
for f in "$IMPORT_FILE" "$SOPS_FILE" "$RECON_FILE"; do
|
for f in "$IMPORT_FILE" "$SOPS_FILE" "$RECON_FILE" "$URLS_FILE"; do
|
||||||
if [ ! -f "${ROOT}/${f}" ]; then
|
if [ ! -f "${ROOT}/${f}" ]; then
|
||||||
echo "check SKIPPED: ${f} absent — nothing to audit" >&2
|
echo "check SKIPPED: ${f} absent — nothing to audit" >&2
|
||||||
exit 3
|
exit 3
|
||||||
@@ -95,6 +118,7 @@ the Flux graph walk rather than forked, so there is one definition of "what a Fl
|
|||||||
renders" in the harness."""
|
renders" in the harness."""
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
import re
|
import re
|
||||||
import subprocess
|
import subprocess
|
||||||
import sys
|
import sys
|
||||||
@@ -116,10 +140,21 @@ import status as st # noqa: E402 — reuse the walk, don't f
|
|||||||
IMPORT_FILE = "platform/keycloak/keycloak-realm-import.yaml"
|
IMPORT_FILE = "platform/keycloak/keycloak-realm-import.yaml"
|
||||||
SOPS_FILE = "platform/keycloak/keycloak-realm-clients.sops.yaml"
|
SOPS_FILE = "platform/keycloak/keycloak-realm-clients.sops.yaml"
|
||||||
RECON_FILE = "platform/keycloak/client-secret-reconciler.yaml"
|
RECON_FILE = "platform/keycloak/client-secret-reconciler.yaml"
|
||||||
|
URLS_FILE = "platform/keycloak/client-urls.yaml"
|
||||||
SECRET_NAME = "keycloak-realm-clients"
|
SECRET_NAME = "keycloak-realm-clients"
|
||||||
ADMIN_SECRET = "keycloak-admin-credentials"
|
ADMIN_SECRET = "keycloak-admin-credentials"
|
||||||
|
URLS_CM_NAME = "keycloak-client-urls"
|
||||||
KEY_SUFFIX = "-client-secret"
|
KEY_SUFFIX = "-client-secret"
|
||||||
SECRETS_MOUNT = "/etc/keycloak-realm-clients"
|
SECRETS_MOUNT = "/etc/keycloak-realm-clients"
|
||||||
|
URLS_MOUNT = "/etc/keycloak-client-urls"
|
||||||
|
URLS_DATA_KEY = "client-urls.json"
|
||||||
|
RENDERER = "ci/render-keycloak-client-urls.py"
|
||||||
|
DIR_PATH = ROOT / "conformance" / "tests" / "l0-static" / "keycloak-client-secret-reconciler"
|
||||||
|
# The ONLY client fields the reconciler may write besides the secret. Kept in lockstep
|
||||||
|
# with the renderer and the script; asserted below to be exactly what they agree on.
|
||||||
|
URL_LIST_FIELDS = ("redirectUris", "webOrigins")
|
||||||
|
URL_STR_FIELDS = ("rootUrl", "baseUrl", "adminUrl")
|
||||||
|
URL_FIELDS = URL_LIST_FIELDS + URL_STR_FIELDS
|
||||||
DOLLAR = chr(36)
|
DOLLAR = chr(36)
|
||||||
|
|
||||||
FAILURES: list[str] = []
|
FAILURES: list[str] = []
|
||||||
@@ -446,6 +481,24 @@ if cj is not None:
|
|||||||
check(len(cm_vols) == 1,
|
check(len(cm_vols) == 1,
|
||||||
f"{RECON_FILE}: the CronJob does not mount the reconciler ConfigMap, so it "
|
f"{RECON_FILE}: the CronJob does not mount the reconciler ConfigMap, so it "
|
||||||
f"would run nothing")
|
f"would run nothing")
|
||||||
|
# The URL desired state must be MOUNTED at the path the script defaults to, for the
|
||||||
|
# same reason as the Secret: the pod holds no ServiceAccount token and cannot read
|
||||||
|
# the KeycloakRealmImport CR, so an unmounted ConfigMap means pass 2 fails closed on
|
||||||
|
# every run forever -- correct behaviour, but a permanently red reconciler.
|
||||||
|
url_vols = [v for v in vols.values()
|
||||||
|
if (v.get("configMap") or {}).get("name") == URLS_CM_NAME]
|
||||||
|
check(len(url_vols) == 1,
|
||||||
|
f"{RECON_FILE}: the CronJob must mount the {URLS_CM_NAME} ConfigMap — that "
|
||||||
|
f"mount IS the desired state the URL pass reconciles towards, and the pod has "
|
||||||
|
f"no Kubernetes API access to read the realm import instead")
|
||||||
|
if url_vols:
|
||||||
|
mp = mounts.get(url_vols[0]["name"], {}).get("mountPath")
|
||||||
|
check(mp == URLS_MOUNT,
|
||||||
|
f"{RECON_FILE}: {URLS_CM_NAME} is mounted at {mp}, but the script defaults "
|
||||||
|
f"to {URLS_MOUNT}/{URLS_DATA_KEY} and no env var overrides it")
|
||||||
|
check(mounts.get(url_vols[0]["name"], {}).get("readOnly") is True,
|
||||||
|
f"{RECON_FILE}: the {URLS_CM_NAME} mount must be readOnly — the reconciler "
|
||||||
|
f"reads its desired state, it never writes it")
|
||||||
env_srcs = {e["name"]: e for e in ctr.get("env", [])}
|
env_srcs = {e["name"]: e for e in ctr.get("env", [])}
|
||||||
for var in ("KEYCLOAK_ADMIN_USER", "KEYCLOAK_ADMIN_PASSWORD"):
|
for var in ("KEYCLOAK_ADMIN_USER", "KEYCLOAK_ADMIN_PASSWORD"):
|
||||||
ref = (env_srcs.get(var, {}).get("valueFrom") or {}).get("secretKeyRef") or {}
|
ref = (env_srcs.get(var, {}).get("valueFrom") or {}).get("secretKeyRef") or {}
|
||||||
@@ -467,6 +520,110 @@ if cj is not None:
|
|||||||
check(re.search(r":[0-9]", img),
|
check(re.search(r":[0-9]", img),
|
||||||
f"{RECON_FILE}: container image {img!r} is not pinned to an explicit version")
|
f"{RECON_FILE}: container image {img!r} is not pinned to an explicit version")
|
||||||
|
|
||||||
|
# ================================================ (8) the URL desired state is DERIVED
|
||||||
|
# The reconciler's second pass makes the realm's environment-derived client URLs match
|
||||||
|
# platform/keycloak/client-urls.yaml. That file must not be an independent declaration of
|
||||||
|
# every public hostname: two declarations that can disagree is the same shape as the bug
|
||||||
|
# this whole change closes. It is GENERATED from the realm import, and the generation is
|
||||||
|
# gated HERE -- a hand edit to either file that makes them disagree is unmergeable.
|
||||||
|
urls_docs = [d for d in load(URLS_FILE) if d]
|
||||||
|
urls_cm = next((d for d in urls_docs if d.get("kind") == "ConfigMap"), None)
|
||||||
|
check(urls_cm is not None, f"{URLS_FILE}: no ConfigMap document found")
|
||||||
|
check((urls_cm or {}).get("metadata", {}).get("name") == URLS_CM_NAME,
|
||||||
|
f"{URLS_FILE}: the ConfigMap must be named {URLS_CM_NAME} (the CronJob mounts it "
|
||||||
|
f"by that name)")
|
||||||
|
|
||||||
|
urls_want: dict[str, dict] = {}
|
||||||
|
if urls_cm is not None:
|
||||||
|
data = urls_cm.get("data") or {}
|
||||||
|
check(set(data) == {URLS_DATA_KEY},
|
||||||
|
f"{URLS_FILE}: the ConfigMap must carry exactly the key {URLS_DATA_KEY} "
|
||||||
|
f"(found {sorted(data)}) — the script reads that filename from its mount")
|
||||||
|
raw = data.get(URLS_DATA_KEY, "")
|
||||||
|
try:
|
||||||
|
urls_want = json.loads(raw)
|
||||||
|
except ValueError as e:
|
||||||
|
check(False, f"{URLS_FILE}: {URLS_DATA_KEY} is not valid JSON ({e}) — the "
|
||||||
|
f"reconciler would fail closed on every run")
|
||||||
|
check(isinstance(urls_want, dict) and bool(urls_want),
|
||||||
|
f"{URLS_FILE}: {URLS_DATA_KEY} must be a non-empty JSON object of clients")
|
||||||
|
|
||||||
|
# THE GATE: the committed file must be exactly what the renderer produces from the realm
|
||||||
|
# import. This is what makes the realm import the single source of truth for hostnames
|
||||||
|
# rather than merely the place they were first typed.
|
||||||
|
if (ROOT / RENDERER).is_file():
|
||||||
|
proc = subprocess.run([sys.executable, str(ROOT / RENDERER), "--check"],
|
||||||
|
capture_output=True, text=True, timeout=120, cwd=str(ROOT))
|
||||||
|
check(proc.returncode == 0,
|
||||||
|
f"{URLS_FILE} is NOT what {RENDERER} renders from {IMPORT_FILE}. The URL "
|
||||||
|
f"reconciler would converge the live realm on a desired state the realm import "
|
||||||
|
f"does not declare — two sources of truth for every public hostname, which is "
|
||||||
|
f"the failure class this change exists to close. Regenerate with: "
|
||||||
|
f"python3 {RENDERER} --write\n "
|
||||||
|
+ "\n ".join((proc.stdout + proc.stderr).strip().splitlines()[:12]))
|
||||||
|
else:
|
||||||
|
check(False, f"{RENDERER} is missing — {URLS_FILE} would become a hand-maintained "
|
||||||
|
f"second declaration of every hostname with nothing keeping it honest")
|
||||||
|
|
||||||
|
# Every client named in the desired state must exist in the realm import (otherwise every
|
||||||
|
# reconciler run reports NOT FOUND forever), and every field must be one the reconciler is
|
||||||
|
# actually allowed to write.
|
||||||
|
import_client_ids = {c.get("clientId") for c in clients}
|
||||||
|
for cid in sorted(urls_want):
|
||||||
|
check(cid in import_client_ids,
|
||||||
|
f"{URLS_FILE}: declares URLs for client '{cid}', which {IMPORT_FILE} does not "
|
||||||
|
f"declare. The reconciler would look it up, not find it, and fail every run")
|
||||||
|
entry = urls_want[cid] if isinstance(urls_want.get(cid), dict) else {}
|
||||||
|
check(bool(entry), f"{URLS_FILE}: client '{cid}' declares no URL fields")
|
||||||
|
for field in sorted(entry):
|
||||||
|
check(field in URL_FIELDS,
|
||||||
|
f"{URLS_FILE}: client '{cid}' declares field '{field}', which is not one of "
|
||||||
|
f"the writable URL fields ({', '.join(URL_FIELDS)}). Anything else would be "
|
||||||
|
f"a client-representation field written into the realm by a file edit")
|
||||||
|
|
||||||
|
# The three places that name the writable field set must agree, or the gate is checking a
|
||||||
|
# different contract than the one that ships.
|
||||||
|
for field in URL_FIELDS:
|
||||||
|
check(field in script,
|
||||||
|
f"{RECON_FILE}: the shipped script never mentions the writable URL field "
|
||||||
|
f"'{field}', but this check and {RENDERER} both treat it as writable — the "
|
||||||
|
f"three definitions of the write surface have diverged")
|
||||||
|
|
||||||
|
# A redirect URI is only as good as the hostname it points at. If the realm import uses a
|
||||||
|
# Flux substitution token, the desired-state ConfigMap must carry the SAME token (not a
|
||||||
|
# resolved value) and must be rendered by the SAME Kustomization, or the two are
|
||||||
|
# substituted differently and disagree about the domain at reconcile time.
|
||||||
|
# Scan the DATA PAYLOAD, not the whole file: both files carry header prose that discusses
|
||||||
|
# substitution tokens by name, and matching those would be a false positive.
|
||||||
|
TOKEN_RE = re.compile(re.escape(DOLLAR) + r"\{([A-Z_][A-Z0-9_]*)\}")
|
||||||
|
import_tokens = set(TOKEN_RE.findall(
|
||||||
|
yaml.safe_dump((realm_import or {}).get("spec", {}).get("realm", {}))))
|
||||||
|
urls_tokens = set(TOKEN_RE.findall(
|
||||||
|
((urls_cm or {}).get("data") or {}).get(URLS_DATA_KEY, "")))
|
||||||
|
check(urls_tokens <= import_tokens,
|
||||||
|
f"{URLS_FILE} uses substitution token(s) {sorted(urls_tokens - import_tokens)} that "
|
||||||
|
f"the realm definition in {IMPORT_FILE} does not — the desired state would resolve "
|
||||||
|
f"to a hostname the realm import never declared")
|
||||||
|
|
||||||
|
urls_owners = owners(URLS_FILE)
|
||||||
|
check(bool(urls_owners),
|
||||||
|
f"{URLS_FILE} is rendered by NO Flux Kustomization — the reconciler would mount a "
|
||||||
|
f"ConfigMap that does not exist and fail closed on every run")
|
||||||
|
check(set(urls_owners) == set(import_owners),
|
||||||
|
f"{URLS_FILE} is rendered by {urls_owners} but {IMPORT_FILE} by {import_owners}. "
|
||||||
|
f"They MUST be rendered by the same Flux Kustomization: postBuild.substituteFrom is "
|
||||||
|
f"per-Kustomization, so a split would let the same "
|
||||||
|
f"{DOLLAR}{{BASE_DOMAIN}} resolve to two different values — the realm converging on "
|
||||||
|
f"a hostname the HTTPRoutes do not serve")
|
||||||
|
for env in ("dev", "prod"):
|
||||||
|
entry = f"clusters/{env}"
|
||||||
|
if not (ROOT / entry).is_dir():
|
||||||
|
continue
|
||||||
|
applied, _ = st.reconciled_files(ROOT, entry)
|
||||||
|
check(URLS_FILE in applied,
|
||||||
|
f"{URLS_FILE} is not reachable from the {entry} entrypoint — the URL pass would "
|
||||||
|
f"fail closed on that cluster forever")
|
||||||
|
|
||||||
# ============================== (6) the reconciler's BEHAVIOUR, proven by its own selftest
|
# ============================== (6) the reconciler's BEHAVIOUR, proven by its own selftest
|
||||||
if script:
|
if script:
|
||||||
with tempfile.TemporaryDirectory() as td:
|
with tempfile.TemporaryDirectory() as td:
|
||||||
@@ -482,6 +639,35 @@ if script:
|
|||||||
if proc.returncode == 0:
|
if proc.returncode == 0:
|
||||||
print(" reconciler --selftest: " + (tail[-1] if tail else "(no output)"))
|
print(" reconciler --selftest: " + (tail[-1] if tail else "(no output)"))
|
||||||
|
|
||||||
|
# ---- (6b) THE SELFTEST IS FALSIFIABLE, not decorative -----------------------
|
||||||
|
# A passing suite proves nothing unless it can be shown to FAIL. mutations.py
|
||||||
|
# injects plausible regressions one at a time -- order-sensitive comparison, a
|
||||||
|
# fat round-tripped PUT, additive-only URI sets, a widened allow-list, coupled
|
||||||
|
# passes, fail-open on an unreadable input -- and requires --selftest to catch
|
||||||
|
# every one. A mutation that SURVIVES means the suite does not check that
|
||||||
|
# property, and this check fails naming it.
|
||||||
|
#
|
||||||
|
# This matters more than usual here: the whole incident was a component that
|
||||||
|
# REPORTED SUCCESS while the realm was broken. A green test that cannot fail is
|
||||||
|
# the same lie one level up. ~20s, no network, no cluster.
|
||||||
|
mut = DIR_PATH / "mutations.py"
|
||||||
|
if mut.is_file():
|
||||||
|
mproc = subprocess.run([sys.executable, str(mut), str(p)],
|
||||||
|
capture_output=True, text=True, timeout=600)
|
||||||
|
mtail = (mproc.stdout + mproc.stderr).strip().splitlines()
|
||||||
|
check(mproc.returncode == 0,
|
||||||
|
"the reconciler's --selftest is NOT FALSIFIABLE — at least one injected "
|
||||||
|
"regression survived it, so the suite does not prove what it claims:\n"
|
||||||
|
" " + "\n ".join(mtail[-14:]))
|
||||||
|
if mproc.returncode == 0:
|
||||||
|
print(" reconciler mutations: "
|
||||||
|
+ (mtail[-1] if mtail else "(no output)"))
|
||||||
|
else:
|
||||||
|
check(False,
|
||||||
|
"conformance/tests/l0-static/keycloak-client-secret-reconciler/"
|
||||||
|
"mutations.py is missing — nothing proves the --selftest can fail, and "
|
||||||
|
"an unfalsifiable proof is the failure mode this component exists to end")
|
||||||
|
|
||||||
# ======================================================= (7) this check is load-bearing
|
# ======================================================= (7) this check is load-bearing
|
||||||
L0 = (ROOT / "ci" / "l0.sh").read_text() if (ROOT / "ci" / "l0.sh").is_file() else ""
|
L0 = (ROOT / "ci" / "l0.sh").read_text() if (ROOT / "ci" / "l0.sh").is_file() else ""
|
||||||
GATE = (ROOT / "ci" / "gate.sh").read_text() if (ROOT / "ci" / "gate.sh").is_file() else ""
|
GATE = (ROOT / "ci" / "gate.sh").read_text() if (ROOT / "ci" / "gate.sh").is_file() else ""
|
||||||
@@ -504,9 +690,36 @@ check(l2.is_file(),
|
|||||||
"keycloak-client-secrets-match-git) is missing — this static check cannot see a "
|
"keycloak-client-secrets-match-git) is missing — this static check cannot see a "
|
||||||
"realm that drifted after apply, which is the whole failure mode")
|
"realm that drifted after apply, which is the whole failure mode")
|
||||||
if l2.is_file():
|
if l2.is_file():
|
||||||
check("KEYCLOAK_CLIENT_SECRETS_AUDIT_OK" in l2.read_text(),
|
l2_text = l2.read_text()
|
||||||
"the L2 test does not assert the reconciler's audit sentinel, so it could pass "
|
check("KEYCLOAK_CLIENT_SECRETS_AUDIT_OK" in l2_text,
|
||||||
"on a Job that never ran the audit")
|
"the L2 test does not assert the reconciler's secret audit sentinel, so it could "
|
||||||
|
"pass on a Job that never ran the audit")
|
||||||
|
check("KEYCLOAK_CLIENT_URLS_AUDIT_OK" in l2_text,
|
||||||
|
"the L2 test does not assert the reconciler's URL audit sentinel. The live realm "
|
||||||
|
"could hold pre-migration redirect URIs and the test would still pass — which is "
|
||||||
|
"exactly the state prod was measured in")
|
||||||
|
check(URLS_CM_NAME in l2_text,
|
||||||
|
f"the L2 audit Job does not mount the {URLS_CM_NAME} ConfigMap, so its URL pass "
|
||||||
|
f"would fail closed and the test would report a false failure")
|
||||||
|
|
||||||
|
# The alert pair must cover the reconciler's silence. Extended, not duplicated: one CronJob
|
||||||
|
# gets one Failing/Stale pair, because two pairs keyed to the same job_name regex would
|
||||||
|
# both fire for a single cause.
|
||||||
|
ALERTS = "platform/monitoring/alerts.yaml"
|
||||||
|
alerts_text = (ROOT / ALERTS).read_text() if (ROOT / ALERTS).is_file() else ""
|
||||||
|
for rule in ("KeycloakClientSecretReconcilerFailing", "KeycloakClientSecretReconcilerStale"):
|
||||||
|
check(rule in alerts_text,
|
||||||
|
f"{ALERTS} has no {rule} rule — the reconciler's silence would be unobservable, "
|
||||||
|
f"and a healer nobody notices is absent is the 'looks applied and isn't' failure")
|
||||||
|
check("redirect" in alerts_text.lower(),
|
||||||
|
f"{ALERTS}: the reconciler alert descriptions never mention redirect URIs, so an "
|
||||||
|
f"operator paged at 3am would look only for a client-secret problem while the actual "
|
||||||
|
f"drift was a hostname")
|
||||||
|
|
||||||
|
# The renderer must be a real, runnable, tracked file — not a documented intention.
|
||||||
|
check((ROOT / RENDERER).is_file(),
|
||||||
|
f"{RENDERER} is missing; without it {URLS_FILE} cannot be regenerated and the "
|
||||||
|
f"single-source-of-truth gate above has nothing to compare against")
|
||||||
|
|
||||||
# ------------------------------------------------------------------------------ report
|
# ------------------------------------------------------------------------------ report
|
||||||
print(f"realm import: {len(clients)} client(s), {len(declared_pairs)} confidential with a "
|
print(f"realm import: {len(clients)} client(s), {len(declared_pairs)} confidential with a "
|
||||||
|
|||||||
+190
@@ -0,0 +1,190 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
# =============================================================================
|
||||||
|
# mutations.py -- prove the reconciler's --selftest IS FALSIFIABLE.
|
||||||
|
#
|
||||||
|
# WHY THIS EXISTS
|
||||||
|
# ---------------
|
||||||
|
# A selftest that passes proves nothing on its own: a selftest asserting `True ==
|
||||||
|
# True` also passes. The reconciler exists because a one-shot import REPORTED
|
||||||
|
# SUCCESS while the realm was broken, and the same trap applies one level up -- a
|
||||||
|
# green test suite that cannot fail is the same lie in a different place.
|
||||||
|
#
|
||||||
|
# So this injects a series of PLAUSIBLE regressions into the shipped script, one at
|
||||||
|
# a time, and requires that --selftest FAILS for every one. A mutation that
|
||||||
|
# SURVIVES means the suite does not actually check that property, and the check
|
||||||
|
# fails, naming it.
|
||||||
|
#
|
||||||
|
# Each mutation is a mistake a reasonable person could make while editing this
|
||||||
|
# code -- not a nonsense edit. Several were live design questions while it was
|
||||||
|
# written (order-sensitivity, additive-vs-exact URI sets, whether the two passes
|
||||||
|
# should share a failure path); the mutation is the record that the answer is
|
||||||
|
# enforced and not merely commented.
|
||||||
|
#
|
||||||
|
# Invoked by check.sh with the script EXTRACTED from the shipped ConfigMap, so what
|
||||||
|
# is proven falsifiable is byte-for-byte what runs on the cluster.
|
||||||
|
#
|
||||||
|
# python3 mutations.py <path-to-extracted-reconciler.py>
|
||||||
|
#
|
||||||
|
# Runtime is ~20s (one subprocess per mutation, each running the full selftest
|
||||||
|
# against an in-process stub Keycloak; no network, no cluster).
|
||||||
|
# =============================================================================
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import re
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
import tempfile
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
|
||||||
|
def dedent4(text: str) -> str:
|
||||||
|
"""Anchors are written with the YAML file's indentation. The script extracted from
|
||||||
|
the ConfigMap block scalar has 4 fewer leading spaces on every line, so anchors are
|
||||||
|
authored to match the YAML (easy to diff against the manifest) and dedented here."""
|
||||||
|
return "\n".join(ln[4:] if ln.startswith(" ") else ln for ln in text.split("\n"))
|
||||||
|
|
||||||
|
|
||||||
|
# (name, the mistake being modelled, anchor, replacement)
|
||||||
|
MUTATIONS: list[tuple[str, str, str, str]] = [
|
||||||
|
("order-sensitive URL comparison",
|
||||||
|
"compare redirectUris in the order Keycloak returns them -- Keycloak does not "
|
||||||
|
"preserve the authored order, so a converged realm is rewritten on every run "
|
||||||
|
"forever while every run still reports success",
|
||||||
|
"if sorted(stored) != sorted(declared_value):",
|
||||||
|
"if stored != declared_value:"),
|
||||||
|
|
||||||
|
("fat round-tripped URL PUT",
|
||||||
|
"PUT the whole client representation back instead of only the drifted fields -- "
|
||||||
|
"the live server rejects it with 400 'Cannot parse the JSON', and even if it did "
|
||||||
|
"not, the blast radius would cover every field of the client",
|
||||||
|
" rep = {\"clientId\": client.get(\"clientId\")}\n"
|
||||||
|
" for field in sorted(drift):\n"
|
||||||
|
" rep[field] = want[cid][field]",
|
||||||
|
" rep = dict(client)\n"
|
||||||
|
" for field in sorted(drift):\n"
|
||||||
|
" rep[field] = want[cid][field]"),
|
||||||
|
|
||||||
|
("write unconditionally (compare-then-write removed)",
|
||||||
|
"drop the converged short-circuit -- every client is rewritten on every run, so "
|
||||||
|
"the CronJob becomes a permanent write load against the IdP database",
|
||||||
|
" drift = url_drift(client, want[cid])\n if not drift:",
|
||||||
|
" drift = url_drift(client, want[cid]) or dict(\n"
|
||||||
|
" (f, (\"\", want[cid][f])) for f in want[cid])\n if False:"),
|
||||||
|
|
||||||
|
("additive-only URI reconciliation",
|
||||||
|
"union the declared URIs with the stored ones -- a redirect URI REMOVED from git "
|
||||||
|
"is never removed from the realm, so a stale wildcard keeps working and quietly "
|
||||||
|
"widens what the realm will redirect to",
|
||||||
|
" rep[field] = want[cid][field]",
|
||||||
|
" rep[field] = (sorted(set(want[cid][field]) | set(client.get(field) or []))\n"
|
||||||
|
" if field in URL_LIST_FIELDS else want[cid][field])"),
|
||||||
|
|
||||||
|
("URL pass fails OPEN on an unreadable input",
|
||||||
|
"treat a missing ConfigMap as 'nothing declared' -- the run reports success while "
|
||||||
|
"reconciling nothing, which is precisely the fail-open of the realm import",
|
||||||
|
" except OSError as e:\n"
|
||||||
|
" url_input_fail = (\"cannot read %s (%s) -- the keycloak-client-urls ConfigMap \"\n"
|
||||||
|
" \"is not mounted\" % (URLS_FILE, e.__class__.__name__))",
|
||||||
|
" except OSError:\n want_urls = {}"),
|
||||||
|
|
||||||
|
("URL pass skips public clients",
|
||||||
|
"copy the secret pass's publicClient skip into the URL pass -- the `kubernetes` "
|
||||||
|
"client is PKCE-public, so kubectl's redirect URIs would never converge",
|
||||||
|
" drift = url_drift(client, want[cid])",
|
||||||
|
" if client.get(\"publicClient\"):\n continue\n"
|
||||||
|
" drift = url_drift(client, want[cid])"),
|
||||||
|
|
||||||
|
("the two passes are coupled",
|
||||||
|
"abort the whole run when either input is bad -- a typo in a hostname ConfigMap "
|
||||||
|
"would stop client-SECRET repair, making credential healing depend on a file that "
|
||||||
|
"has nothing to do with credentials",
|
||||||
|
" if want_secrets is None and want_urls is None:",
|
||||||
|
" if want_secrets is None or want_urls is None:"),
|
||||||
|
|
||||||
|
("unmanaged fields forced to empty",
|
||||||
|
"iterate every writable field instead of only the declared ones -- a rootUrl or "
|
||||||
|
"adminUrl the realm holds and git does not declare would be WIPED, turning "
|
||||||
|
"'not declared' into 'must be empty'",
|
||||||
|
" for field, declared_value in sorted(wanted.items()):",
|
||||||
|
" for field in URL_FIELDS:\n"
|
||||||
|
" declared_value = wanted.get(\n"
|
||||||
|
" field, [] if field in URL_LIST_FIELDS else \"\")"),
|
||||||
|
|
||||||
|
("no readback verification",
|
||||||
|
"trust the 204 -- a server that accepts the PUT and does not apply it is reported "
|
||||||
|
"as REPAIRED, which is 'reports success while the realm never converged' again",
|
||||||
|
" still = url_drift(after[0], want[cid])",
|
||||||
|
" still = {}"),
|
||||||
|
|
||||||
|
("allow-list widened to the whole realm",
|
||||||
|
"add a realm-level endpoint 'just in case' -- /users, /groups, /roles and "
|
||||||
|
"/authentication stop being structurally unreachable",
|
||||||
|
" r\"/admin/realms/[^/]+/clients\",",
|
||||||
|
" r\"/admin/realms/[^/]+\",\n r\"/admin/realms/[^/]+/clients\","),
|
||||||
|
|
||||||
|
("desired-state field validation removed",
|
||||||
|
"accept any field name from the ConfigMap -- an arbitrary client field becomes "
|
||||||
|
"writable through a file edit, bypassing the five-field write surface entirely",
|
||||||
|
" if field not in URL_FIELDS:",
|
||||||
|
" if False:"),
|
||||||
|
|
||||||
|
("URL drift detected but never repaired",
|
||||||
|
"leave the audit-mode early-continue in place on the write path -- drift is "
|
||||||
|
"reported in the log and silently not fixed",
|
||||||
|
" if audit:\n continue\n # MINIMAL representation",
|
||||||
|
" if True:\n continue\n # MINIMAL representation"),
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> int:
|
||||||
|
if len(sys.argv) != 2:
|
||||||
|
print("usage: mutations.py <extracted-reconciler.py>", file=sys.stderr)
|
||||||
|
return 2
|
||||||
|
src = Path(sys.argv[1]).read_text()
|
||||||
|
|
||||||
|
problems: list[str] = []
|
||||||
|
killed = 0
|
||||||
|
for name, why, old, new in MUTATIONS:
|
||||||
|
old, new = dedent4(old), dedent4(new)
|
||||||
|
# An anchor that no longer matches is a FAILURE, not a skip: it means the code
|
||||||
|
# moved and this mutation silently stopped proving anything.
|
||||||
|
n = src.count(old)
|
||||||
|
if n != 1:
|
||||||
|
problems.append("%s: anchor matches %d times (expected exactly 1) -- the "
|
||||||
|
"code moved and this mutation no longer proves anything. "
|
||||||
|
"Re-anchor it against the current source." % (name, n))
|
||||||
|
continue
|
||||||
|
with tempfile.NamedTemporaryFile("w", suffix=".py", delete=False) as fh:
|
||||||
|
fh.write(src.replace(old, new, 1))
|
||||||
|
path = fh.name
|
||||||
|
try:
|
||||||
|
proc = subprocess.run([sys.executable, path, "--selftest"],
|
||||||
|
capture_output=True, text=True, timeout=240)
|
||||||
|
finally:
|
||||||
|
Path(path).unlink(missing_ok=True)
|
||||||
|
if proc.returncode == 0:
|
||||||
|
problems.append("%s SURVIVED -- --selftest still passed with this regression "
|
||||||
|
"applied, so nothing in the suite actually checks it. The "
|
||||||
|
"mistake it models: %s" % (name, why))
|
||||||
|
continue
|
||||||
|
killed += 1
|
||||||
|
out = proc.stdout + proc.stderr
|
||||||
|
m = re.search(r"SELFTEST FAIL: (\d+) of \d+", out)
|
||||||
|
how = ("%s assertion(s) caught it" % m.group(1) if m
|
||||||
|
else "the selftest aborted (caught, less cleanly)")
|
||||||
|
print(" killed %-42s %s" % (name, how))
|
||||||
|
|
||||||
|
print()
|
||||||
|
if problems:
|
||||||
|
print("MUTATION CHECK FAIL: %d of %d mutation(s) not proven"
|
||||||
|
% (len(problems), len(MUTATIONS)), file=sys.stderr)
|
||||||
|
for p in problems:
|
||||||
|
print(" FAIL %s" % p, file=sys.stderr)
|
||||||
|
return 1
|
||||||
|
print("MUTATION CHECK OK: all %d injected regressions were caught by --selftest "
|
||||||
|
"(the suite is falsifiable, not decorative)" % killed)
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
raise SystemExit(main())
|
||||||
+35
-5
@@ -1,6 +1,6 @@
|
|||||||
# =============================================================================
|
# =============================================================================
|
||||||
# L2 BEHAVIORAL — every OIDC client's STORED secret equals the one git declares
|
# L2 BEHAVIORAL — every OIDC client's STORED secret AND redirect URIs equal what git
|
||||||
# (R-IDP-02, R-IDP-04) [RUNNABLE]
|
# declares (R-IDP-02, R-IDP-04) [RUNNABLE]
|
||||||
# Spec: docs/15 identity-and-access; ADR-0014.
|
# Spec: docs/15 identity-and-access; ADR-0014.
|
||||||
# Impl: platform/keycloak/client-secret-reconciler.yaml (the CronJob + its script).
|
# Impl: platform/keycloak/client-secret-reconciler.yaml (the CronJob + its script).
|
||||||
#
|
#
|
||||||
@@ -27,14 +27,26 @@
|
|||||||
# its endpoint allow-list makes users/groups/roles unreachable),
|
# its endpoint allow-list makes users/groups/roles unreachable),
|
||||||
# -> THEN every declared client's stored secret matches git, and none holds an
|
# -> THEN every declared client's stored secret matches git, and none holds an
|
||||||
# unresolved import placeholder. Emits KEYCLOAK_CLIENT_SECRETS_AUDIT_OK.
|
# unresolved import placeholder. Emits KEYCLOAK_CLIENT_SECRETS_AUDIT_OK.
|
||||||
|
# -> AND every declared client URL — redirectUris, webOrigins, rootUrl, baseUrl,
|
||||||
|
# adminUrl — matches git. Emits KEYCLOAK_CLIENT_URLS_AUDIT_OK.
|
||||||
|
#
|
||||||
|
# THE URL HALF EXISTS FOR THE SAME REASON, ONE FIELD OVER. The realm import is templated
|
||||||
|
# per environment, so moving prod's BASE_DOMAIN re-renders every declared redirect URI in
|
||||||
|
# git — and reaches the realm never, because a completed import does not re-run. Measured
|
||||||
|
# on prod: stored redirect URIs still on the PRE-migration hostname while git declared the
|
||||||
|
# new one. Every OIDC login would fail with a redirect_uri mismatch, and once again every
|
||||||
|
# signal would be green: the CR reads Done=True, the pods are healthy, and the
|
||||||
|
# secret-only version of this very test would have PASSED. Asserting only the secret
|
||||||
|
# sentinel is what would have let that ship.
|
||||||
#
|
#
|
||||||
# WHY IT RUNS THE SHIPPED SCRIPT rather than re-implementing the comparison: a detector
|
# WHY IT RUNS THE SHIPPED SCRIPT rather than re-implementing the comparison: a detector
|
||||||
# that duplicates the healer's logic drifts from it. This audits with the same code, in
|
# that duplicates the healer's logic drifts from it. This audits with the same code, in
|
||||||
# the same mode the L0 gate selftests, so a passing audit is evidence about the healer
|
# the same mode the L0 gate selftests, so a passing audit is evidence about the healer
|
||||||
# too. It runs as a Job IN the keycloak namespace (not in the ephemeral chainsaw
|
# too. It runs as a Job IN the keycloak namespace (not in the ephemeral chainsaw
|
||||||
# namespace) because the desired state is the mounted keycloak-realm-clients Secret and
|
# namespace) because the desired state is the mounted keycloak-realm-clients Secret plus
|
||||||
# the admin credential is keycloak-admin-credentials — both namespace-local, which is
|
# the keycloak-client-urls ConfigMap, and the admin credential is
|
||||||
# also why neither this Job nor the CronJob needs any Kubernetes API access.
|
# keycloak-admin-credentials — all namespace-local, which is also why neither this Job nor
|
||||||
|
# the CronJob needs any Kubernetes API access.
|
||||||
#
|
#
|
||||||
# chainsaw v0.2.12: Job + status assert; the leftover-Job delete keeps a re-run from
|
# chainsaw v0.2.12: Job + status assert; the leftover-Job delete keeps a re-run from
|
||||||
# colliding with an immutable Job left by a previous failure.
|
# colliding with an immutable Job left by a previous failure.
|
||||||
@@ -149,6 +161,12 @@ spec:
|
|||||||
- name: client-secrets
|
- name: client-secrets
|
||||||
mountPath: /etc/keycloak-realm-clients
|
mountPath: /etc/keycloak-realm-clients
|
||||||
readOnly: true
|
readOnly: true
|
||||||
|
# The URL pass's desired state. Without this mount the audit
|
||||||
|
# fails closed on pass 2 and this test would report a false
|
||||||
|
# failure rather than auditing the realm's redirect URIs.
|
||||||
|
- name: client-urls
|
||||||
|
mountPath: /etc/keycloak-client-urls
|
||||||
|
readOnly: true
|
||||||
securityContext:
|
securityContext:
|
||||||
allowPrivilegeEscalation: false
|
allowPrivilegeEscalation: false
|
||||||
readOnlyRootFilesystem: true
|
readOnlyRootFilesystem: true
|
||||||
@@ -170,6 +188,10 @@ spec:
|
|||||||
secret:
|
secret:
|
||||||
secretName: keycloak-realm-clients
|
secretName: keycloak-realm-clients
|
||||||
defaultMode: 0400
|
defaultMode: 0400
|
||||||
|
- name: client-urls
|
||||||
|
configMap:
|
||||||
|
name: keycloak-client-urls
|
||||||
|
defaultMode: 0444
|
||||||
- assert:
|
- assert:
|
||||||
resource:
|
resource:
|
||||||
apiVersion: batch/v1
|
apiVersion: batch/v1
|
||||||
@@ -192,6 +214,14 @@ spec:
|
|||||||
|| { echo "FAIL: the audit did not report every client secret matching git"; exit 1; }
|
|| { echo "FAIL: the audit did not report every client secret matching git"; exit 1; }
|
||||||
echo "$out" | grep -q 'UNRESOLVED IMPORT PLACEHOLDER' \
|
echo "$out" | grep -q 'UNRESOLVED IMPORT PLACEHOLDER' \
|
||||||
&& { echo "FAIL: a client still holds the literal import placeholder"; exit 1; }
|
&& { echo "FAIL: a client still holds the literal import placeholder"; exit 1; }
|
||||||
|
# The URL half. A realm still holding PRE-migration redirect URIs is the
|
||||||
|
# state prod was measured in: every OIDC login fails with a redirect_uri
|
||||||
|
# mismatch while the KeycloakRealmImport CR still reads Done=True. Asserting
|
||||||
|
# only the secret sentinel would pass on exactly that realm.
|
||||||
|
echo "$out" | grep -q 'KEYCLOAK_CLIENT_URLS_AUDIT_OK' \
|
||||||
|
|| { echo "FAIL: the audit did not report every client URL matching git"; exit 1; }
|
||||||
|
echo "$out" | grep -q 'DRIFT -- realm has' \
|
||||||
|
&& { echo "FAIL: a client URL still differs from the declared one"; exit 1; }
|
||||||
echo KEYCLOAK_CLIENT_SECRET_DRIFT_NONE_OK
|
echo KEYCLOAK_CLIENT_SECRET_DRIFT_NONE_OK
|
||||||
check:
|
check:
|
||||||
(contains($stdout, 'KEYCLOAK_CLIENT_SECRET_DRIFT_NONE_OK')): true
|
(contains($stdout, 'KEYCLOAK_CLIENT_SECRET_DRIFT_NONE_OK')): true
|
||||||
|
|||||||
@@ -431,7 +431,7 @@ spec:
|
|||||||
summary: "Keycloak database unavailable"
|
summary: "Keycloak database unavailable"
|
||||||
description: "Keycloak's PostgreSQL is not ready for 5m — the IdP will fail (docs/15)."
|
description: "Keycloak's PostgreSQL is not ready for 5m — the IdP will fail (docs/15)."
|
||||||
runbook_url: "https://${BASE_DOMAIN}/runbooks/keycloak-db"
|
runbook_url: "https://${BASE_DOMAIN}/runbooks/keycloak-db"
|
||||||
# --- The client-secret reconciler (platform/keycloak/client-secret-reconciler.yaml) ---
|
# --- The client reconciler (platform/keycloak/client-secret-reconciler.yaml) ---
|
||||||
# WHY THESE EXIST: on 2026-09-06 every OIDC login on prod failed because the realm import
|
# WHY THESE EXIST: on 2026-09-06 every OIDC login on prod failed because the realm import
|
||||||
# had stored the LITERAL `(env:...)` placeholder as all 12 client secrets — and every
|
# had stored the LITERAL `(env:...)` placeholder as all 12 client secrets — and every
|
||||||
# signal the cluster offered was GREEN (the import Job succeeded, the CR read Done=True,
|
# signal the cluster offered was GREEN (the import Job succeeded, the CR read Done=True,
|
||||||
@@ -440,6 +440,14 @@ spec:
|
|||||||
# the healer; these two rules make its SILENCE observable, so the class cannot sit
|
# the healer; these two rules make its SILENCE observable, so the class cannot sit
|
||||||
# undetected again. They are the runtime counterpart of the L2 audit test.
|
# undetected again. They are the runtime counterpart of the L2 audit test.
|
||||||
#
|
#
|
||||||
|
# THE CronJob NOW RECONCILES TWO THINGS — client secrets, and the environment-derived
|
||||||
|
# client URLs (redirectUris, webOrigins, rootUrl, baseUrl, adminUrl) that a BASE_DOMAIN
|
||||||
|
# migration re-renders in git and the one-shot import never carries to the realm. The
|
||||||
|
# pair below is EXTENDED to say so rather than DUPLICATED: one CronJob gets one
|
||||||
|
# Failing/Stale pair, because a second pair keyed to the same job_name regex would fire
|
||||||
|
# twice for a single cause and train the operator to ignore both. The Job log names which
|
||||||
|
# pass failed, which client, and for a URL the exact before/after.
|
||||||
|
#
|
||||||
# UNVERIFIED-ON-CLUSTER at authoring time: no CronJob existed on either cluster yet, so
|
# UNVERIFIED-ON-CLUSTER at authoring time: no CronJob existed on either cluster yet, so
|
||||||
# these series had no samples to check against. `kube_job_status_failed` /
|
# these series had no samples to check against. `kube_job_status_failed` /
|
||||||
# `kube_job_status_succeeded` and the `job_name` label WERE confirmed present (the realm
|
# `kube_job_status_succeeded` and the `job_name` label WERE confirmed present (the realm
|
||||||
@@ -457,12 +465,15 @@ spec:
|
|||||||
labels:
|
labels:
|
||||||
severity: warning
|
severity: warning
|
||||||
annotations:
|
annotations:
|
||||||
summary: "Keycloak client-secret reconciler failing"
|
summary: "Keycloak client reconciler failing"
|
||||||
description: >-
|
description: >-
|
||||||
A keycloak-client-secret-reconciler Job has been failing for 15m — an OIDC client
|
A keycloak-client-secret-reconciler Job has been failing for 15m — an OIDC client's
|
||||||
secret may differ from git (unresolved import placeholder, or an unrepairable
|
stored SECRET or its REDIRECT URIs may differ from git (an unresolved import
|
||||||
drift) and SSO logins for that client will fail. Read the Job log: it names the
|
placeholder, a pre-migration hostname the one-shot realm import never updated, or
|
||||||
client and never prints a secret.
|
an unrepairable drift), and SSO logins for that client will fail — a bad
|
||||||
|
redirectUri fails with a redirect_uri mismatch at the IdP. Read the Job log: it
|
||||||
|
names the pass, the client and the exact URL before/after, and never prints a
|
||||||
|
secret.
|
||||||
runbook_url: "https://${BASE_DOMAIN}/runbooks/keycloak-client-secrets"
|
runbook_url: "https://${BASE_DOMAIN}/runbooks/keycloak-client-secrets"
|
||||||
- alert: KeycloakClientSecretReconcilerStale
|
- alert: KeycloakClientSecretReconcilerStale
|
||||||
# No SUCCESSFUL run in scope. Catches the failure modes the rule above cannot see: the
|
# No SUCCESSFUL run in scope. Catches the failure modes the rule above cannot see: the
|
||||||
@@ -475,11 +486,12 @@ spec:
|
|||||||
labels:
|
labels:
|
||||||
severity: warning
|
severity: warning
|
||||||
annotations:
|
annotations:
|
||||||
summary: "Keycloak client-secret reconciler has not succeeded"
|
summary: "Keycloak client reconciler has not succeeded"
|
||||||
description: >-
|
description: >-
|
||||||
No successful keycloak-client-secret-reconciler run in 2h (schedule is every 15m).
|
No successful keycloak-client-secret-reconciler run in 2h (schedule is every 15m).
|
||||||
The CronJob may be suspended, unapplied, or unable to schedule — client-secret
|
The CronJob may be suspended, unapplied, or unable to schedule — client-secret AND
|
||||||
drift would go uncorrected and unnoticed (docs/15).
|
redirect-URI drift would go uncorrected and unnoticed (docs/15). This is the rule
|
||||||
|
that would catch the healer being quietly absent during a domain migration.
|
||||||
runbook_url: "https://${BASE_DOMAIN}/runbooks/keycloak-client-secrets"
|
runbook_url: "https://${BASE_DOMAIN}/runbooks/keycloak-client-secrets"
|
||||||
|
|
||||||
# =====================================================================================
|
# =====================================================================================
|
||||||
|
|||||||
Reference in new issue
Block a user