Files
home-network-k3s-impl/ci/render-keycloak-passkey-policy.py
T
jakman85andClaude Opus 5.5 17eed6e8ae fix(keycloak-passkey): bind only on git's say-so; protect real sub-flows; screen the requirement PUT by body id
Review findings on the passkey reconciler, fixed:

1. HIGH - the bind was automatic and rollback did not stick. A git-declared switch,
   `bind-browser-flow` in passkey-desired-state.yaml, now decides whether pass 3 may
   PUT browserFlow. Default "false": build + verify the candidate (or reuse a verified
   one), log WOULD BIND <alias>, never touch the binding - including after an earlier
   bind, so an operator's rollback sticks, and a hand-edited bound candidate is left
   alone rather than shadowed by an -r2 build. "true" binds as before. The renderer
   carries the one hand-edited line through and --check refuses anything but the
   quoted strings "true"/"false". --no-bind can only narrow it.

2. HIGH - the protected-subtree guard read `alias` on sub-flow rows. Keycloak 26.0.6
   AuthenticationManagementResource.recurseExecutions sets displayName =
   flowRef.getAlias() and flowId on a sub-flow row and never sets alias (alias is the
   CONFIG alias on authenticator rows), so the protected set was only the top-level
   alias. Sub-flows are now named by displayName (getFlows() lists top-level flows
   only, so flowId cannot be resolved through it); a sub-flow row without one leaves
   the subtree undetermined and the flow pass fail-closed. The selftest stub now
   returns 26.0.6-shaped rows, and a real run is asserted to refuse writes into
   browser-mfa-forms and browser-mfa-conditional-otp.

3. MEDIUM - PUT /flows/{alias}/executions was screened on the path alias only, but
   updateExecutions resolves the execution realm-wide by body id. body.id is now
   screened against the protected ids AND required to be in the tree this run is
   building (recorded from the build's own readback); the config POST is held to the
   same build scope; and the body may carry only id/requirement/priority (the same
   handler renames any flow named by flowId).

4. Stale "audit-only" docs rewritten (platform-config kustomization, chainsaw test,
   desired-state header and its renderer template, README) to describe what ships.

5. backoffLimit 0 (a retried deterministic build failure burns the candidate-alias
   budget); the stale "Renovate does not manage image: tags" comment corrected and the
   image pinned by index digest.

6. The ~160-line header duplicated in the YAML and the script is now kept once, in the
   script, with a short pointer above it.

Selftest: 259 assertions (was 216). mutations.py: 40 injected regressions (was 30),
including one per finding above, all caught.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-29 21:41:14 -05:00

491 lines
25 KiB
Python

#!/usr/bin/env python3
# =============================================================================
# ci/render-keycloak-passkey-policy.py
#
# Render platform/keycloak/passkey-desired-state.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. Measured on prod 2026-10-18, hours
# after the passkey declaration merged:
#
# kubectl -n keycloak get keycloakrealmimport homelab -o jsonpath=...status
# Done=True Started=False HasErrors=False
#
# So the WebAuthn passwordless policy and the restructured `browser-mfa` flow that
# keycloak-realm-import.yaml declares are INERT. They are in git, Flux applied
# them, and they reach the realm never. platform/keycloak/passkey-reconciler.yaml
# is the level-triggered loop that closes that; this renderer supplies the DESIRED
# STATE it converges towards.
#
# THE ONE VALUE THAT IS NOT RENDERED: `bind-browser-flow`, git's switch for whether the
# reconciler may BIND its verified candidate browser flow (one atomic PUT of the
# realm's browserFlow). It is an operator DECISION -- "I have tested the candidate
# flow in a browser; make it the login path" -- not a fact derivable from the realm
# import, so it cannot be generated. It is instead CARRIED THROUGH from the committed
# file: --write preserves it, --check accepts any value of it that is exactly the
# string "true" or "false" and rejects everything else (an unquoted YAML true is a
# bool, which a ConfigMap rejects at apply time -- failing the whole platform-config
# Kustomization -- so it is refused HERE, at merge time). An absent file or key
# renders as "false". Going live is therefore a one-line PR flipping that line.
#
# THE CONSTRAINT THAT SHAPES IT: the reconciler pod runs with
# automountServiceAccountToken: false, exactly like its client-secret sibling. 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 input
# must be MOUNTED. Hence a ConfigMap.
#
# THAT IS ALSO WHY THIS IS A RENDERER AND NOT A HAND-WRITTEN ConfigMap. A
# hand-authored copy of the policy would be a SECOND SOURCE OF TRUTH for the RP ID,
# the user-verification requirement and the whole flow tree — free to disagree with
# the realm import. "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-passkey-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. Same render==golden idiom as
# ci/render-keycloak-client-urls.py, catalog/render.py and ci/render-golden.sh.
#
# STRINGS ARE COPIED VERBATIM, NEVER RE-DERIVED. webAuthnPolicyPasswordlessRpId is
# written in the realm import as the Flux substitution token ${BASE_DOMAIN}, and it
# 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 value and cannot disagree about what the base domain is. This
# renderer never parses, splits or reconstructs a hostname and never reads
# cluster-vars — there is no second place the domain is decided.
#
# THE ONE THING THIS RENDERER DERIVES RATHER THAN COPIES
# -----------------------------------------------------
# The enrolment required action, `webauthn-register-passwordless`. It is
# deliberately NOT declared in the realm import, and the import's own header
# explains at length why declaring it there would be actively DANGEROUS: Keycloak
# 26.0.6 treats `requiredActions` in a realm representation as REPLACE, not merge,
# so declaring one action imports exactly that action plus delete_account and skips
# DefaultRequiredActions.addActions entirely — deleting CONFIGURE_TOTP,
# UPDATE_PASSWORD, VERIFY_EMAIL and seven more. Losing CONFIGURE_TOTP is a hard
# lock-out of every platform-admin, because auth-otp-form in the browser flow still
# demands a TOTP they could then never enrol.
#
# So the required action cannot be copied from the import, because it is correctly
# absent from it. It is instead DERIVED, as a consequence of what the import DOES
# declare:
#
# IF the declared browser flow contains a `webauthn-authenticator-passwordless`
# execution, THEN passkey logins are offered, THEREFORE the enrolment action must
# be registered and enabled — and NOT a defaultAction, because the import's whole
# stated posture is "additive and optional" and a defaultAction forces enrolment
# on every user at next login.
#
# This is a derivation, not a second declaration: there is no value here that a
# human could edit into disagreement with the import. If the import stops declaring
# the passkey execution, this renderer stops emitting the required action (and
# fails, because then the whole desired state is empty of passkey intent).
#
# WHAT IS DELIBERATELY NOT EXPORTED
# * the TWO-FACTOR webAuthnPolicy* family. Nothing in this realm uses the
# `webauthn-authenticator` two-factor authenticator; the passwordless
# authenticator reads getWebAuthnPolicyPasswordless() only, so a policy written
# into the two-factor family is a silent no-op. Exporting it would hand the
# reconciler a write surface with no purpose.
# * `clients`, `users`, `groups`, `roles`. Client secrets and client URLs are the
# SIBLING reconciler's surface (platform/keycloak/client-secret-reconciler.yaml),
# and users/groups/roles are nobody's — prod's real user and the media-users /
# platform-admins / viewers groups must survive every run of everything.
# * execution `priority`. It is consumed here to ORDER the exported executions
# (Keycloak returns them in priority order), but the numbers themselves are not
# exported: the reconciler compares the resulting SEQUENCE, which is what
# actually determines behaviour. Exporting the integers would make a
# priority renumbering that changes no ordering read as drift.
#
# USAGE
# python3 ci/render-keycloak-passkey-policy.py # print the rendered file
# python3 ci/render-keycloak-passkey-policy.py --write # write it (regenerate)
# python3 ci/render-keycloak-passkey-policy.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/passkey-desired-state.yaml"
DATA_KEY = "passkey-desired-state.json"
CONFIGMAP_NAME = "keycloak-passkey-desired-state"
NAMESPACE = "keycloak"
# The realm keys this renderer exports, and therefore the ONLY realm keys the
# reconciler may write. Every one is a webAuthnPolicyPasswordless* field. Kept in
# lockstep with POLICY_FIELDS in the reconciler script; the L0 check asserts the two
# tuples are identical, so they cannot drift apart silently.
POLICY_STR_FIELDS = (
"webAuthnPolicyPasswordlessRpId",
"webAuthnPolicyPasswordlessRpEntityName",
"webAuthnPolicyPasswordlessRequireResidentKey",
"webAuthnPolicyPasswordlessUserVerificationRequirement",
"webAuthnPolicyPasswordlessAuthenticatorAttachment",
"webAuthnPolicyPasswordlessAttestationConveyancePreference",
)
POLICY_LIST_FIELDS = (
"webAuthnPolicyPasswordlessSignatureAlgorithms",
"webAuthnPolicyPasswordlessAcceptableAaguids",
)
POLICY_INT_FIELDS = ("webAuthnPolicyPasswordlessCreateTimeout",)
POLICY_BOOL_FIELDS = ("webAuthnPolicyPasswordlessAvoidSameAuthenticatorRegister",)
POLICY_FIELDS = (POLICY_STR_FIELDS + POLICY_LIST_FIELDS + POLICY_INT_FIELDS
+ POLICY_BOOL_FIELDS)
# The field Keycloak 26.0.6 gates the WHOLE passwordless policy on
# (DefaultExportImportManager.updateRealm: the policy is applied only when
# RpEntityName is non-null and non-empty). A desired state without it would produce
# an accepted PUT that changes nothing, so refuse to render one.
RP_ENTITY_NAME = "webAuthnPolicyPasswordlessRpEntityName"
# The execution whose presence is what makes passkey enrolment necessary.
PASSKEY_AUTHENTICATOR = "webauthn-authenticator-passwordless"
ENROL_ACTION = "webauthn-register-passwordless"
BIND_KEY = "bind-browser-flow"
BIND_VALUES = ("true", "false")
def committed_bind(root: Path) -> str:
"""The operator's bind-browser-flow value from the COMMITTED file, or "false".
This is the only input read from the output file, and it is validated, not trusted:
anything other than the exact strings "true" / "false" is a hard failure."""
target = root / OUT_FILE
if not target.is_file():
return "false"
docs = [d for d in yaml.safe_load_all(target.read_text(encoding="utf-8")) if d]
cm = next((d for d in docs if d.get("kind") == "ConfigMap"), None) or {}
data = cm.get("data") or {}
if BIND_KEY not in data:
return "false"
v = data[BIND_KEY]
if not isinstance(v, str) or v not in BIND_VALUES:
raise SystemExit(
"FATAL: %s data.%s is %r. It must be exactly the QUOTED string \"true\" or "
"\"false\": an unquoted true/false is a YAML boolean, which a ConfigMap rejects "
"at apply time and which would fail the whole platform-config Kustomization"
% (OUT_FILE, BIND_KEY, v))
return v
def render(root: Path) -> str:
"""Return the full text of passkey-desired-state.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)
realm = (imp.get("spec") or {}).get("realm") or {}
realm_name = realm.get("realm")
if not isinstance(realm_name, str) or not realm_name:
raise SystemExit("FATAL: %s declares no spec.realm.realm name" % IMPORT_FILE)
# ---- the passwordless policy, copied VERBATIM ----------------------------------
policy: dict[str, object] = {}
for f in POLICY_STR_FIELDS:
if f not in realm:
continue # not declared => NOT MANAGED
v = realm[f]
# A bare ${BASE_DOMAIN} parses as a plain string; an int or bool here would be
# a YAML quoting mistake that must not be silently stringified.
if not isinstance(v, str):
raise SystemExit("FATAL: %s realm field %s is not a string (got %r) — "
"quote it in the realm import" % (IMPORT_FILE, f, v))
policy[f] = v
for f in POLICY_LIST_FIELDS:
if f not in realm:
continue
v = realm[f]
if not isinstance(v, list) or not all(isinstance(x, str) for x in v):
raise SystemExit("FATAL: %s realm field %s is not a list of strings"
% (IMPORT_FILE, f))
policy[f] = list(v) # verbatim, order as authored
for f in POLICY_INT_FIELDS:
if f not in realm:
continue
v = realm[f]
if not isinstance(v, int) or isinstance(v, bool):
raise SystemExit("FATAL: %s realm field %s is not an integer"
% (IMPORT_FILE, f))
policy[f] = v
for f in POLICY_BOOL_FIELDS:
if f not in realm:
continue
v = realm[f]
if not isinstance(v, bool):
raise SystemExit("FATAL: %s realm field %s is not a boolean"
% (IMPORT_FILE, f))
policy[f] = v
if not policy:
# Fail closed. An empty policy would make the reconciler believe every field is
# unmanaged and silently stop reconciling — the realm import's own fail-open.
raise SystemExit("FATAL: %s declares no webAuthnPolicyPasswordless* field — "
"refusing to render an empty desired state" % IMPORT_FILE)
if not policy.get(RP_ENTITY_NAME):
raise SystemExit(
"FATAL: %s does not declare a non-empty %s. Keycloak 26.0.6 applies the "
"passwordless policy ONLY when that field is set, so every realm PUT would "
"be accepted and change nothing" % (IMPORT_FILE, RP_ENTITY_NAME))
# ---- the flow tree, flattened per flow and ORDERED BY PRIORITY -----------------
browser_flow = realm.get("browserFlow")
if not isinstance(browser_flow, str) or not browser_flow:
raise SystemExit("FATAL: %s declares no spec.realm.browserFlow — the flow could "
"be perfect and still not be the one anyone logs in through"
% IMPORT_FILE)
raw_flows = realm.get("authenticationFlows") or []
if not isinstance(raw_flows, list) or not raw_flows:
raise SystemExit("FATAL: %s declares no authenticationFlows" % IMPORT_FILE)
flows: dict[str, object] = {}
for fl in raw_flows:
alias = fl.get("alias")
if not isinstance(alias, str) or not alias:
raise SystemExit("FATAL: %s has an authentication flow with no alias"
% IMPORT_FILE)
if alias in flows:
raise SystemExit("FATAL: %s declares the flow %r twice"
% (IMPORT_FILE, alias))
execs = fl.get("authenticationExecutions") or []
if not isinstance(execs, list) or not execs:
raise SystemExit("FATAL: %s flow %r declares no authenticationExecutions"
% (IMPORT_FILE, alias))
ordered = []
for ex in sorted(execs, key=lambda e: (e.get("priority") or 0)):
is_flow = bool(ex.get("authenticatorFlow"))
name = ex.get("flowAlias") if is_flow else ex.get("authenticator")
if not isinstance(name, str) or not name:
raise SystemExit(
"FATAL: %s flow %r has an execution with neither an authenticator "
"nor a flowAlias" % (IMPORT_FILE, alias))
requirement = ex.get("requirement")
if not isinstance(requirement, str) or not requirement:
raise SystemExit("FATAL: %s flow %r execution %r declares no requirement"
% (IMPORT_FILE, alias, name))
entry: dict[str, object] = {
"kind": "flow" if is_flow else "authenticator",
"name": name,
"requirement": requirement,
}
cfg = ex.get("authenticatorConfig")
if cfg is not None:
if not isinstance(cfg, str) or not cfg:
raise SystemExit("FATAL: %s flow %r execution %r has a non-string "
"authenticatorConfig" % (IMPORT_FILE, alias, name))
entry["configAlias"] = cfg
ordered.append(entry)
flows[alias] = {"topLevel": bool(fl.get("topLevel")), "executions": ordered}
if browser_flow not in flows:
raise SystemExit("FATAL: %s binds browserFlow to %r but declares no such flow"
% (IMPORT_FILE, browser_flow))
# Every referenced subflow must be declared, or the reconciler's flattening walks
# into a hole and the audit silently covers less than it claims.
for alias, spec in flows.items():
for ex in spec["executions"]:
if ex["kind"] == "flow" and ex["name"] not in flows:
raise SystemExit("FATAL: %s flow %r references subflow %r, which is not "
"declared" % (IMPORT_FILE, alias, ex["name"]))
# ---- authenticator configs, copied verbatim ------------------------------------
configs: dict[str, dict] = {}
for cfg in realm.get("authenticatorConfig") or []:
alias = cfg.get("alias")
if not isinstance(alias, str) or not alias:
raise SystemExit("FATAL: %s has an authenticatorConfig with no alias"
% IMPORT_FILE)
body = cfg.get("config") or {}
if not isinstance(body, dict) or not body:
raise SystemExit("FATAL: %s authenticatorConfig %r has no config body"
% (IMPORT_FILE, alias))
# Values are stringified because Keycloak stores authenticator config values as
# strings. `negate: "false"` in the import and a stored "false" are the same
# declaration, and a YAML-unquoted `false` must not become a Python bool that
# then never matches.
configs[alias] = {k: str(v) for k, v in sorted(body.items())}
# Every configAlias a flow references must be defined, else the reconciler reports
# "git declares config X but defines no key/value for it" on every run forever.
for alias, spec in flows.items():
for ex in spec["executions"]:
ca = ex.get("configAlias")
if ca and ca not in configs:
raise SystemExit(
"FATAL: %s flow %r execution %r references authenticatorConfig %r, "
"which is not declared" % (IMPORT_FILE, alias, ex["name"], ca))
# ---- the enrolment required action, DERIVED (see the header) -------------------
offers_passkey = any(
ex["kind"] == "authenticator" and ex["name"] == PASSKEY_AUTHENTICATOR
for spec in flows.values() for ex in spec["executions"])
if not offers_passkey:
raise SystemExit(
"FATAL: %s declares a webAuthnPolicyPasswordless* policy but no %r execution "
"in any flow. The policy decides what a passkey must BE; without that "
"execution nothing ever OFFERS one, so the policy is unreachable "
"configuration and this desired state would be meaningless"
% (IMPORT_FILE, PASSKEY_AUTHENTICATOR))
want = {
"realm": realm_name,
"webAuthnPolicyPasswordless": policy,
# enabled: passkey enrolment must be possible, since the flow offers passkeys.
# defaultAction FALSE: enrolment stays OPT-IN. True would force every user to
# enrol a passkey at next login — the platform-wide lock-out the realm import's
# "additive and optional" posture exists to avoid. Derived, not copied; see the
# header for why the import cannot declare this itself.
"requiredAction": {"alias": ENROL_ACTION, "enabled": True,
"defaultAction": False},
"browserFlow": browser_flow,
"flows": flows,
"authenticatorConfig": configs,
}
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 + BIND_BLOCK % (BIND_KEY, committed_bind(root))
+ " %s: |\n" % DATA_KEY + indented + "\n")
HEADER = """---
# =============================================================================
# GENERATED FILE — DO NOT EDIT BY HAND, except the one bind-browser-flow line below.
#
# source: platform/keycloak/keycloak-realm-import.yaml (spec.realm)
# regenerate: python3 ci/render-keycloak-passkey-policy.py --write
# gated by: conformance/tests/l0-static/keycloak-passkey-reconciler
# (runs this renderer with --check on every merge)
#
# WHAT THIS IS: the DESIRED STATE for the `homelab` realm's PASSKEY configuration —
# the WebAuthn passwordless policy, the enrolment required action, and the declared
# browser authentication flow tree. platform/keycloak/passkey-reconciler.yaml mounts
# it and (a) converges the policy and the required action onto it, (b) builds the
# declared flow tree BLUE/GREEN under a new alias and verifies it by readback, never
# writing the flow users are logging in through, and (c) BINDS that verified flow as
# the realm's browserFlow ONLY when `bind-browser-flow` below is "true".
#
# EVERYTHING HERE IS GENERATED EXCEPT ONE LINE: `bind-browser-flow`. That is an
# operator decision, carried through by the renderer from this committed file (see
# ci/render-keycloak-passkey-policy.py). It defaults to "false".
#
# WHY IT IS A SEPARATE OBJECT: the realm import is a ONE-SHOT that never re-runs
# once Done=True, so a realm-level field or a flow edited in git never reaches the
# realm. Measured on prod 2026-10-18, hours after the passkey declaration merged:
# Done=True / Started=False, with the whole passkey block inert. 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 the RP ID, the
# user-verification requirement and the flow tree would be a second source of truth,
# free to disagree with the realm import — the same "two declarations that can
# disagree" shape as the bug being closed. It is rendered from the import and the
# render is gated, so the two cannot drift.
#
# STRINGS ARE VERBATIM: webAuthnPolicyPasswordlessRpId is the ${BASE_DOMAIN}
# substitution token exactly as the realm import writes it. Both files are rendered
# by the SAME Flux Kustomization (./platform-config), so the token resolves to the
# same value in both and the domain is decided in exactly one place.
#
# `requiredAction` is the ONE entry that is DERIVED rather than copied: the realm
# import deliberately declares no `requiredActions` block, because Keycloak 26.0.6
# treats that as REPLACE and declaring one action would delete CONFIGURE_TOTP and
# nine more (a hard lock-out of every platform-admin). It is derived instead from
# the presence of a webauthn-authenticator-passwordless execution in the flow:
# passkeys are offered, therefore enrolment must be enabled — and defaultAction is
# false, so enrolment stays opt-in.
#
# A field NOT declared on the realm in the import is simply ABSENT here, and the
# reconciler leaves it alone. Absence means UNMANAGED, never "force to a default".
# =============================================================================
apiVersion: v1
kind: ConfigMap
metadata:
name: %s
namespace: %s
labels:
app.kubernetes.io/name: %s
app.kubernetes.io/part-of: keycloak
data:
""" % (CONFIGMAP_NAME, NAMESPACE, CONFIGMAP_NAME)
BIND_BLOCK = """ # ---------------------------------------------------------------------------
# THE BIND SWITCH -- the ONLY hand-edited line in this file. Exactly "true" or
# "false", QUOTED.
#
# "false" the reconciler builds and verifies the candidate browser flow and
# logs `WOULD BIND <alias>`, but NEVER changes the realm's browserFlow.
# Whatever the realm is bound to -- including a rollback -- is left alone.
# "true" it binds the verified candidate in one atomic PUT, and keeps the
# realm bound to it: a binding changed by hand is changed back.
#
# GO LIVE: test the candidate in a browser first (platform/keycloak/README.md,
# "Passkeys"), then a PR setting this to "true".
# ROLL BACK: a PR setting this to "false" FIRST (or the next run rebinds), then
# one write setting browserFlow back to the previous alias from the Job log.
# ---------------------------------------------------------------------------
%s: "%s"
"""
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-passkey-policy.py --write" % OUT_FILE,
file=sys.stderr)
return 1
have = target.read_text(encoding="utf-8")
if have == want:
print("passkey-desired-state.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 passkey policy the realm import does not declare, and build "
"the login flow from a tree nobody authored.\n"
"Regenerate with: python3 ci/render-keycloak-passkey-policy.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())