Changelog¶
v0.5.0 (2026-08-25)¶
⚠ Breaking¶
spec.providers[].urlis REMOVED. The fourth provider-source variant is nowobjectRef(bucket/key, S3-compatible object storage), not a direct HTTPS URL.sha256moves with it — still mandatory on the fetched-and-verified variants, still forbidden onimage/configMapRef/secretRef. Any existingKeycloakInstanceusingspec.providers[].urlwill have that field silently pruned by the apiserver on its next apply (a structural CRD with no matching field drops rather than rejects) — the provider entry then fails the CELexists_oneunion rule instead, surfacing as an admission error rather than a silent no-op. Pre-release audit (team-lead, corroborated by b-infra-2) confirms zero instances usingurlon svc01; there is no migration path to run because there is nothing to migrate, but any external adopter of this operator should audit before upgrading.
Features¶
spec.truststore.secretRefs— outbound trust only: the CA bundle Keycloak uses when it dials a customer's own services (AD/LDAPS, federated IdPs), deliberately separate from the inbound serving certificate. Mounted, appended to the pod's system CA store (never replaces it).spec.providers[].objectRefreplaces the removedurlvariant — an S3-compatible bucket/key fetched, checksum-verified againstsha256, and placed by the same non-root initContainer mechanism the other three variants already use.spec.adopt— for an instance running against an already-populated, imported database whose realms and admin already exist. When true, the controller skips generating an admin Secret and omits bothKC_BOOTSTRAP_ADMIN_*env vars entirely; a CEL rule refusesspec.adoptandspec.adminSecrettogether at the apiserver, so an adopted instance can never be told to also mint operator-supplied admin credentials.- Recovery-admin Job (day-2 counterpart to
spec.adopt) — a one-shot, requestID-gated Job that creates a break-glass admin account on an adopted instance AND proves the credentials actually work before reporting success, rather than trustingkc.sh's exit code. (bootstrap-admin user --no-promptexits 0 when the username already exists while creating nothing — verified against real Keycloak 26.3.4 — so a bare exit-code check would hand an operator credentials that were never created; this Job chains the token request&&after creation, so its own exit code means "created AND authenticated.") Idempotent onrequestID(repeat requests, forced reconciles, and a deleted Job all leave the completed state alone, no re-run). VersionEntry.Digest(optional) — when set, the operator resolves and pullsimage@sha256:<digest>instead of the bare tag, and reports aVersionPinnedcondition (True/VersionDigestPinnedorFalse/VersionMapUnpinned) so an unpinned entry in the version map is visible on the instance rather than silently trusted.ImageOverrideActivecondition — whenspec.imagebypasses the version map entirely (as it always has), the instance now says so explicitly rather than leaving it implicit; mutually exclusive withVersionPinned, each path clears the other.
Fixes and behaviour changes¶
- ⚠ Every instance's NetworkPolicy gains intra-instance egress on the HTTP
port. Previously the rule between an instance's own pods opened only the
JGroups port, because cache replication was the only reason they talked. The
recovery-admin Job talks HTTP, and NetworkPolicy requires the SENDER's egress
to allow a connection even when the receiver's ingress already does — so on an
enforcing CNI its verification was dropped. This applies on upgrade to every
instance, not only those using
spec.recoveryAdmin, since one rule is rendered for all. It widens the policy by one port between pods of the same instance; nothing external gains reach. Do not read this release's provider/truststore additions as meaning the upgrade is purely additive — this part is not. - A healthy operator no longer reports as stalled. On the Ready path the
operator returned
RequeueAfter: routesRequeue, which is zero once routes settle — and in Ingress mode there are no routes at all. controller-runtime reads zero as "do not requeue", so a fully converged estate produced no reconciles until an event or the ~10h informer resync, and a reconcile-rate alert fired on the healthiest possible state. A 10-minute floor now sits under that requeue; it is a floor, not a replacement, so a sooner pending route verdict still wins. ⚠ Consumers of the reconcile-rate signal should re-derive their threshold from this floor, and note the rate is per-operator, not per-instance, so it scales with instance count. - The Helm chart now grants
batch/jobs(create,delete,get,list,watch— noupdate, nopatch). The recovery-admin Job cannot be created without it. The chart's ClusterRole is hand-maintained and is what actually deploys;config/rbac/role.yamlis controller-gen output and does not. A test now fails if the two drift in either direction.
Known limitations (new this release)¶
- BYO in-cluster Postgres on a Cilium-enforcing cluster needs an explicit
podSelectoregress rule. A plainipBlock: 0.0.0.0/0port-5432 egress rule does not permit reaching an in-cluster Postgres pod under Cilium — Cilium resolves in-cluster traffic by workload identity, not by CIDR, so an ipBlock rule that would work against an external database silently fails to cover one running inside the same cluster. Managed Percona and external (out-of-cluster) BYO Postgres are unaffected. Proven via a real A/B comparison on an enforcing-Cilium cluster (two otherwise-identical operator builds, one with the egress rule, one without — timeout vs. success). sha256andkeyare NOT enforced by the apiserver. The only CEL rule onspec.providers[]is the exactly-one-of-image/objectRef/configMapRef/secretRefunion check — so nothing there enforces MORE than that, and a client must not assume it does. The "sha256required withobjectRef, forbidden otherwise" and "keyrequired withconfigMapRef/secretRef" rules are enforced by the OPERATOR at render time, not at admission. A malformed or missing digest is therefore ACCEPTED on write and surfaces later as a reconcile error: a client treating "the API accepted it" as success will show a saved provider that never runs. Validate the digest shape (64 lowercase hex, present iffobjectRef) client-side — already required in hq's expressibility DoD.- (Carried forward, unchanged since v0.4.0: the provider-fetcher image and the
26.3.4/26.6.4versionmap entries remain on mutable upstream tags, not Harbor-mirrored digests — still pending Bernd's Harbor-mirror trigger.)
v0.4.0 (2026-08-24)¶
Features¶
spec.backups.database: durable, scheduled S3 backups of the managed Postgres backend. Opt-in and additive — absent, today's behavior is unchanged: the operator still renders only the local (non-durable) pgBackRest repo. When set, adds a second pgBackRest repo ("repo2", S3) alongside it, never replacing it, with its own schedule and retention. Only meaningful whenspec.postgres.managedis true; validated, not silently ignored, on a BYO backend. Credentials (S3 access key/secret, endpoint, region, bucket, and the backup-encryption cipher passphrase) are granular keys on a single referenced Secret, the same shapespec.postgrescredentials already use.- repo2 backups are always encrypted (pgBackRest repo-cipher), no opt-out. The cipher passphrase is generated and durably held by hq — never minted or owned by the operator — so an instance deletion (or a lost cluster) can never garbage-collect the key protecting backups that persist in S3 under retention. hq's own retention/soft-delete discipline for the passphrase record is a separate, hq-side commitment, not implemented in this release.
BackupHealthystatus condition, with a staleness threshold derived from the newscheduleCadencefield (hourly/daily/weekly, default daily) rather than a fixed 48h — a weekly schedule no longer reads permanentlyFalsebetween runs.scheduleCadenceis declared, not parsed from the cron string; the two must agree.- Backup Jobs carry explicit CPU/memory bounds (
limits200m/512Mi,requests50m/128Mi) instead of inheriting the namespace default. In a namespace whose LimitRange defaults an unset pod to a full core, an unbounded backup Job would consume the entire remaining CPU margin the moment it ran. Set only whenspec.backups.databaseis configured, so an instance that never enables backups renders exactly what it did before. PGInput.RestoreFromresolves the restore-CR shape for a Percona PG restore driven from aspec.backups.database-configured instance. This is the CR shape only. It is not a proven restore path: the shape is unit-tested and thedataSourcevariant was chosen against the vendored Percona operator source, but no test in this repo has ever watched it recover data, and no restore drill has been run. Backups in this release can be described as configured and encrypted; recovery cannot be described as proven, and no RTO follows from this release.
Security¶
- Backup-cipher-passphrase custody: the operator never mints or owns the
key encrypting repo2 backups. hq generates it and hands it over as a
required Secret key; a Secret missing it fails closed (surfaces as the
non-blocking
DatabaseBackupIgnoredcondition, never wires an unencrypted or empty-passphrase repo2).
Fixed¶
- Grants the RBAC the controller needs to read
perconapgbackups(list/watch — notget, list-derived reconciliation only) for backup status.
Changed¶
- Version map gains
26.3.4(plus a26.3alias). It sits BELOW the rest of the ladder deliberately — an imported database arrives at its own schema version and the upgrade-only guard carries it forward. Because that guard blocks downgrades, an instance must be CREATED pinned to26.3.4; one that first comes up onstablecannot later be re-pinned down without the break-glass annotation. hack/mirror-versions.shmirrors curated upstream images into Harbor, digest-pinned, viaskopeo copy --all.--allrather than docker pull/tag/push because the upstream images are multi-arch manifest lists and docker's path mirrors only the runner's own architecture, silently turning a multi-arch source into a single-arch mirror. Shipped inert: nothing in this release consumes a Harbor digest yet, and the map above still points at upstream tags.
v0.3.4 (2026-08-23)¶
Features¶
spec.ingress.gateway.parentRef.kind(andadminParentRef.kind). Lets aparentRefname something other than aGateway— e.g. a per-appListenerSet, needed for the custom-domains attachment. Empty is unchanged from every prior release (Gateway API's ownGatewaydefault). Passed through to the renderedHTTPRouteverbatim, unvalidated: a Gateway implementation that cannot resolve the referent surfaces that through the existingRoutesAcceptedcondition, the same way an unpinnedsectionNamealready does. See the CRD reference.
v0.3.3 (2026-08-22)¶
Security¶
- The admin-pin CEL rule now also fires on an ABSENT
hosts.adminExposurewhen the instance references noKeycloakProfile. v0.3.2 shipped it scoped to an explicitadminExposure: Public; an instance that left the field unset (which also means Public, per the default) and pinned its publicparentRef.sectionNamecould still admit an unpinned admin route — the same cleartext-admin-console gap v0.3.2's own CEL rule exists to close, reachable through the one case it didn't cover. An instance that DOES reference a profile is unaffected: the profile may still supplyInternal, so judging it against Public before the merge would reject the documented "adminExposureon the profile, bare instances" pattern —ValidateWrittenSpec(instance as authored) now defers that case toValidateSpec(effective, post-merge spec), which the controller always runs. See Admin-console isolation.
Features¶
PublicRoutePinnedstatus condition (GatewayAPI mode). Warns —Warning, not a rejection — wheningress.gateway.parentRefhas nosectionName. The operator has no RBAC to readGatewayobjects (v0.3.0 security review), so it cannot tell whether the named Gateway is single- or multi-listener; on a multi-listener one, an unpinned route attaches to every listener that admits it, including a hostname-less plaintext:80kept for ACME, serving the login form in cleartext. A genuinely single-listener Gateway with an unpinnedparentRef(the kind e2e rig, for instance) is legitimate and stays green. Closes the deferred item from the v0.3.0/v0.3.1 release notes; see known limitations.- D16 status fields:
status.adminHost,status.urls: {public, admin}(scheme-prefixed —httpsonce GatewayAPI mode oringress.clusterIssueris set),status.realmImport: {state: Pending|Imported, message},status.providers[]: {name, state: Pending|Ready, message}.adminHost/urlsreflect the effective spec from the first reconcile — the intended configuration, not just what's currently serving.realmImport/providers[]instead wait for the workload to report Ready: Kubernetes runs every init container to completion before starting a Pod's main container, and--import-realmruns synchronously at Keycloak's own startup before it opens its port, so Ready is real evidence of success — not a guess.Failedis declared in both enums but not yet emitted: telling "still starting" apart from "crash-looping on a bad import/fetch" needs Pod-level read RBAC this operator does not request; that is left for a future release rather than guessed at with a timeout. No spec changes.
Toolchain¶
sigs.k8s.io/controller-runtime0.17 → 0.18,k8s.io/*0.29 → 0.30,sigs.k8s.io/gateway-api1.0.0 → 1.1.0,ENVTEST_K8S_VERSION1.29 → 1.31 — the bump deferred from v0.3.1. No source changes were required;go build,go vet, and the full envtest suite are green unchanged against the new versions.go 1.22.0ingo.mod; the CI/Dockergolang:1.21images move togolang:1.22to match.
v0.3.2 (2026-08-21)¶
Features¶
spec.hosts.adminExposure: Public | Internaldecides how strictly the admin host is separated from the public entry point.Internalis the isolation v0.3.0 introduced:ingress.gateway.adminParentRef(GatewayAPI) oringress.adminClass(Ingress) is required and must name a different Gateway / class.Public— the default — places no constraint on where: the admin host may share the public Gateway or class.ingress.adminClassmay be omitted (the admin Ingress then takes the public class), andgateway.adminParentRefmay be omitted whileparentRefis unpinned. OnceparentRef.sectionNameis set,adminParentRefand its ownsectionNameare required — see the security note below.- Available on
KeycloakProfile.spec.adminExposureto set estate-wide; an instance can opt out with an explicitPublic. - The public host's
/adminblock is unchanged under both values. The admin console is never reachable onspec.hosts.host.adminExposuregoverns where the ADMIN HOST is served, not whether the public host leaks the console.
Upgrade notes¶
This release WIDENS two defaults. Both only affect instances that set
hosts.adminHost and did not configure the admin path explicitly. Anything
you set explicitly is untouched, and the public host's /admin block is
untouched in all cases.
- Ingress mode: the admin Ingress class default changes from
nginx-internalto the PUBLIC class. An instance withadminHostset and noingress.adminClasspreviously rendered its admin Ingress onnginx-internal; it now renders oningress.class(or thenginxdefault). If you were relying on that default to keep the admin console on an internal controller, the admin console will move to the public controller on upgrade. - GatewayAPI mode:
adminParentRefis no longer required and no longer has to name a different Gateway. Previously anadminHostwithout a separate admin Gateway was rejected at apply time; now it defaults to the public Gateway.
Find the instances affected by (1) — Ingress-mode CRs with an admin host and no explicit admin class:
kubectl get kci -A -o json | jq -r '
.items[]
| select((.spec.ingress.mode // "Ingress") == "Ingress")
| select(.spec.hosts.adminHost != null and .spec.hosts.adminHost != "")
| select((.spec.ingress.adminClass // "") == "")
| select((.spec.hosts.adminExposure // "Public") != "Internal")
| "\(.metadata.namespace)/\(.metadata.name)"'
Each one listed will move to the public ingress class. To keep the old behaviour, set both:
Internal now requires the explicit adminClass rather than guessing the
-internal name, which is why both lines are needed.
The operator-managed NetworkPolicy follows the same resolution: under Public
the admin rule admits the same namespace as the public rule and the two
collapse into one; under Internal with a separate class it keeps the
ingress-nginx-internal default. An explicit
networkPolicy.adminIngressControllerSelector still wins.
spec.hosts.rootRedirect is unchanged in this release.
Security¶
- A pre-release build of this feature would have served the admin console in
cleartext. With
adminExposure: Publicand noadminParentRef, the admin route was attached to the public Gateway with nosectionName. A route without asectionNameattaches to every listener that admits it and whose hostname intersects — including the hostname-less:80listener a Gateway keeps for ACME and the HTTP→HTTPS redirect. The admin route is unblocked, so the admin console would have been served over plain HTTP on the public address; worse, its hostname out-ranks the hostname-less redirect (Gateway API resolves hostname specificity before path), so the redirect would have stayed in place, looked correct, and never fired for that host.
Caught in review by kc-infra before release and reproduced on Envoy Gateway
(attachedRoutes=1 on the :80 listener; the admin hostname answered from
the route while a control hostname 404'd). Fixed with one rule:
Under
adminExposure: Publicin GatewayAPI mode, ifingress.gateway.parentRef.sectionNameis set, theningress.gateway.adminParentRefand itssectionNameare required — whichever Gateway the admin ref names.
Enforced by CEL at apply time and by the controller on the effective spec
(the path a KeycloakProfile-supplied exposure takes). A pinned public route
is the only evidence the operator has that a Gateway is multi-listener: it
cannot read Gateway objects at all, that RBAC having been dropped in the
v0.3.0 security review. Where the public route needs no pin, the public host
already rides that listener by the user's choice and the admin host follows
the same posture — so the single-listener case keeps working with
adminParentRef omitted entirely.
Upgrade impact. A CR with a pinned parentRef, an adminHost, and an
adminParentRef that carries no sectionName becomes invalid — including
one whose admin ref names a separate internal Gateway, which was valid in
v0.3.0/v0.3.1. Two ways forward, both one line:
# 1. The admin host really is internal — say so, and the rule does not apply.
spec: {hosts: {adminExposure: Internal}}
# 2. Keep it Public and name the listener that serves the admin host.
spec: {ingress: {gateway: {adminParentRef: {sectionName: https-admin}}}}
Find them before upgrading:
kubectl get kci -A -o json | jq -r '
.items[]
| select((.spec.ingress.mode // "") == "GatewayAPI")
| select((.spec.hosts.adminExposure // "Public") != "Internal")
| select((.spec.hosts.adminHost // "") != "")
| select((.spec.ingress.gateway.parentRef.sectionName // "") != "")
| select((.spec.ingress.gateway.adminParentRef.sectionName // "") == "")
| "\(.metadata.namespace)/\(.metadata.name)"'
Docs¶
- The Serving guide now leads with svc01's configuration instead of a
hypothetical
iam.example.com: public host on one HTTPS listener, admin host on a second, both pinned withsectionName,rootRedirectset, managed Percona standalone. The Gateway owner's half — two HTTPS listeners plus the:80one cert-manager's Gateway shim needs — is verbatim from svc01 and verified live there (2026-08-21); the instance half is the target configuration, applied once v0.3.2 is rolled out, sinceadminExposuredoes not exist in the CRD before it. TheInternaltopology, which is what svc01 serves today, follows as the second example and carries that stamp. - New
examples/gatewayapi-public-admin.yaml— that same case as one applyable file: namespace,Gateway,KeycloakInstance. Every file inexamples/is now applied against the real CRDs in the test suite, so a shipped example cannot drift from the schema or from a CEL rule. - Spelled out why an admin route wants
sectionName: an unpinned route attaches to every listener that admits it and matches its hostname — including a plain:80one. The admin route is deliberately unblocked, so unpinned it serves the admin console in cleartext on the public address. - The kind e2e row covers all three
Publicattachments now:adminParentRefomitted on an unpinned public ref (it serves), a pinned public ref with the admin ref missing or unpinned (the cluster refuses both), and both pinned — the form the guide leads with. Knob:E2E_GATEWAY_ADMIN_SECTION_NAME(defaultadmin).
v0.3.1 (2026-08-21)¶
Features¶
spec.hosts.rootRedirect— the bare/on the public host can now answer a 301 instead of a 503.https://<host>/returning 503 was correct but unhelpful:/is blocked because Keycloak redirects it to/admin/, so serving it on the public host would hand out the admin console. Naming a safe landing path answers that better than an error page:
The redirect is served by the Gateway itself (a RequestRedirect filter, no
backend) and stays on the host and scheme the client used. /admin is
untouched and still answers 503. Empty keeps the previous behaviour exactly,
so no existing CR changes.
GatewayAPI mode only. In Ingress mode / keeps answering 503 whatever the
field says: networking.k8s.io/v1 has no controller-agnostic redirect, and
the operator deliberately will not emit a controller-specific annotation to
fake one — a hardened ingress-nginx (allow-snippet-annotations=false)
rejects the entire Ingress object when such an annotation is present, so the
field would work on one controller and break the Ingress outright on another.
The value is validated at admission and in the controller, because it becomes
a Location header the Gateway emits on your public host: absolute path with
at least one segment, no // anywhere, not under /admin, max 256
characters.
- KeycloakProfile.spec.rootRedirect supplies the default org-wide;
instance wins. It is the only part of spec.hosts a profile can reach —
hostnames are per-instance by nature, so ProfileSpec has no hosts block.
Upgrade notes¶
- Purely additive.
rootRedirectunset renders exactly what v0.3.0 rendered, and there is no behaviour change for Ingress-mode instances at all.
v0.3.0 (2026-08-21)¶
Features¶
- Gateway API serving mode.
spec.ingress.modeselects how aKeycloakInstanceis served:Ingress(the default, unchanged) orGatewayAPI, which renders twogateway.networking.k8s.io/v1HTTPRoutes attached to a platform-ownedGatewaynamed inspec.ingress.gateway. The operator never creates aGateway,GatewayClassorReferenceGrant. See the Serving guide for the mode comparison, the Gateway-owner prerequisites and the migration recipe. parentRefcarries the public route;adminParentRefcarries the admin route and is required wheneverspec.hosts.adminHostis set — a separate reference on purpose, so the admin host lands on an internal Gateway instead of the public one.- Routing policy is identical in both modes:
/adminand the bare/on the public host still reach the endpoint-less sink Service, so a conformant implementation answers503. See Admin-console isolation. - TLS terminates at the Gateway listener;
class,adminClassandclusterIssuerare rejected in this mode rather than silently ignored. - Verified on Envoy Gateway v1.9.0 / Gateway API v1.6.1, on kind and on a Gardener shoot (Cilium, Kubernetes 1.35) with managed Percona PostgreSQL and a Let's Encrypt (staging) certificate via cert-manager's Gateway solver. Other conformant implementations are expected to work but are not tested by b'nerd.
RoutesAcceptedcondition mirrors the Gateway controller's verdict onto the CR (NotAllowedByListeners,BackendNotFound, ...).Readyis not gated on it — seeRoutesAcceptedis not True.- Capability gate: GatewayAPI mode on a cluster without
HTTPRouteatv1fails loudly withGatewayAPIUnavailableinstead of stalling, and recovers on its own once the CRDs are installed. - NetworkPolicy ingress-controller defaults follow the mode — the nginx namespaces (now documented as legacy defaults) in Ingress mode, the Gateway's namespace in GatewayAPI mode. See Network isolation.
MODEprint column onkubectl get kci.
Fixes¶
- The operator no longer crash-loops on clusters without Gateway API, found
by the kind e2e before release: the informer cache resolved an
HTTPRouteRESTMapping while building the manager, before any capability gate could run. The probe now happens first. Nothing in v0.3.0 requires Gateway API. RoutesAcceptedno longer reports a dead host as healthy. A Gateway controller leaves the status entry for a parent the route no longer references, so an instance repointed at a nonexistent Gateway reportedTruewhile serving nothing. The verdict is now correlated against the route's ownparentRefs.- Version-map resolution distinguishes an absent
KeycloakVersionMap(Pending, polls) from an unreadable one (VersionResolutionError, backoff). - CRD capability probes are cached per process (5-minute TTL), so Gateway
API,
ServiceMonitorand Percona no longer each list every CRD on every reconcile. postgres.nodesisint32; the CRD schema gains onlyformat: int32.
Upgrade notes¶
- v0.3.0 starts on clusters with or without Gateway API installed, and logs which one it found.
- A new validation can reject an EXISTING CR on its next update.
ingress.gateway.adminParentRefmust now name a different Gateway thanparentRef. CRD changes are additive and stored objects are not re-validated until they are written, so an instance created before v0.3.0 keeps running — but the nextkubectl apply/patch against it will be refused until the admin route is moved to its own Gateway. Check withkubectl get kci -A -o jsonpath='{range .items[?(@.spec.ingress.mode=="GatewayAPI")]}{.metadata.namespace}/{.metadata.name}{"\t"}{.spec.ingress.gateway.parentRef.name}{"\t"}{.spec.ingress.gateway.adminParentRef.name}{"\n"}{end}'before upgrading. - The CRD change is additive:
modeunset meansIngressand renders exactly what v0.2.0 rendered. - Installing Gateway API after the operator is running requires an operator restart for the HTTPRoute watch — the condition itself clears within ~5 minutes. See Installing Gateway API later.
- The admin console is at
/admin/master/console/; a bare/admin/returns404from Keycloak itself. - Re-check
networkPolicy.ingressControllerSelectorwhen moving an existing instance to GatewayAPI mode.
v0.2.0 (2026-08-17)¶
Features¶
KeycloakProfileCRD (cluster-scoped,kcprofile): a reusable preset ofKeycloakInstancedefaults, referenced by a new optionalspec.profilefield. The operator merges the profile's fields beneath the instance spec (the instance always wins field-by-field), so a shared org profile can carry ingress classes, resources, placement, managed-Postgres presets — and above all, default provider JARs (a company theme or SPI provider injected into every instance that references the profile) — while instance CRs stay minimal. See the KeycloakInstance guide and the CRD Reference.- Provider sources without an image build.
spec.providers[]now supportsurl(HTTPS + mandatorysha256, checksum-verified before the JAR is placed) andconfigMapRef/secretRef(for small JARs), alongside the existingimagesource — themes and SPI providers no longer require building and pushing a carrier image. - Dynamic managed-Postgres readiness. When the Percona PostgreSQL Operator CRD is present at operator start, a managed backend becoming ready is picked up immediately instead of waiting out the bounded 30-second requeue.
- Network isolation, default-on. Every instance now gets an owned
NetworkPolicy that default-denies traffic to/from its pods except the
ingress controller (public and, when
adminHostis set, the separate admin ingress controller), intra-instance cache traffic, Prometheus scraping, DNS, and its Postgres backend. A newspec.networkPolicy.egressWhitelistfield admits egress to specific external destinations (CIDR + port) — the ergonomic option for any outbound call a realm is configured to make (external Identity Provider federation and an SMTP relay for realm email are the two common cases), which the defaults don't cover. See Network isolation and, if upgrading an existing instance, the pre-upgrade checklist. - Opt-in cache transport encryption.
spec.cacheMtls: trueenables mutual TLS on the embedded cache (JGroups) — zero-config, Keycloak manages the certificates itself. - Faster feedback on obvious spec mistakes. An invalid
replicas,postgres.topology, orpostgres.nodesvalue is now rejected immediately bykubectl apply, instead of being accepted and only failing once the operator reconciles it.
Fixes¶
- Foreign objects are never touched. Deleting an Ingress, the admin-block Service, or a ServiceMonitor when a feature is turned off now checks that this operator actually owns the object first — a same-named object created by something else is left alone.
- No more stale
Ready=Trueafter a backend outage. If a previously-Readyinstance's Postgres backend became unavailable, the status phase correctly reflected it but theReadycondition could stay stuck atTrue. Fixed. - Fixed a regression of the admin-block sink Service's cleanup. A cache optimization elsewhere in this release had silently made the Service always look absent to its own deletion check, so disabling ingress after having had it enabled no longer cleaned it up. Fixed; every Service the operator renders is now checked against this to prevent a recurrence.
dbEgressCIDR/egressWhitelist[].cidrnow reject a malformed value immediately (atkubectl apply), instead of silently freezing the instance's status while the operator retries in the background.- DNS egress fixed on enforcing CNIs — no configuration needed. On
certain CNIs (confirmed on Calico), the default DNS egress rule did not
reliably reach CoreDNS, which broke DNS resolution for the instance and
everything that depends on it, including its own database connection, with
no obvious cause in the logs. The DNS egress rule now scopes its peer to
CoreDNS's own pods and opens both port 53 and CoreDNS's real container port
(commonly
8053, cluster-dependent) — this combination is verified working reliably against a real Calico cluster and needs no per-instance configuration. If you're diagnosing DNS on your own egress rule on another CNI, check whether it opens only port 53 and omits CoreDNS's real container port.spec.networkPolicy.dnsEgressCIDRis available as an escape hatch for an unusual DNS setup, but you should not need to set it for the ordinary case. See Network isolation for details.
v0.1.1 (2026-07-11)¶
Correctness and input-hardening fast-follow on v0.1.0. No CRD/API changes.
- Admin-console block on the public host no longer depends on an nginx
snippet. The admin console and Admin REST API (
/adminand the bare/) are kept off the public host by routing those paths to a sink Service that selects no Pods, so a conformant controller answers503. This replaces the previous nginxconfiguration-snippet403block and works on any Ingress class, including hardenedingress-nginxand non-nginx classes. The console is reachable only through the separate internal admin Ingress created whenspec.hosts.adminHostis set (adminHostmust differ fromhost). - Orphaned Ingresses cleaned up. Disabling
spec.ingress.enabledor clearingspec.hosts.adminHostafter creation now deletes the affected Ingress objects instead of leaving them serving. - Provider path hardening.
spec.providers[].pathis single-quoted in the copy command and shell metacharacters are rejected at validation. - Status condition hygiene. Entering
Failed/Pendingclears a staleReady=True/Progressing; a genuine in-flightUpgradeInProgressis kept.
v0.1.0 (2026-07-10)¶
Initial release of the bnerd-keycloak-operator.
Features¶
-
KeycloakInstance CRD (namespaced,
kci): single CR = one Keycloak deployment, reconciled as raw resources (StatefulSet, Services, Ingresses, Secrets) — no Flux/Helm indirection, unlikebnerd-gitlab-operator. Spec covers version, replicas, hosts, ingress (class/TLS via cert-manager), Postgres backend, admin bootstrap credentials, provider JAR injection, image-pull secrets, realm import, resource requests/limits, placement, and a raw env escape hatch. -
KeycloakVersionMap CRD (cluster-scoped,
kcvm): maps Keycloak version strings to container images, with alias support (latest,stable,"26"). -
Reference-faithful StatefulSet: rolling one-by-one updates (protects Infinispan in-memory session state), headless discovery Service for JGroups DNS_PING, the full
KC_*env contract (proxy headers, hostname/strict, health/metrics,ispn/kubernetescache stack, Postgres DB vars), and startup/readiness/liveness probes on the management port (9000). -
First-class provider (JAR) injection:
spec.providersrenders one initContainer per entry, copying*.jarfrom the provider image into a shared/opt/keycloak/providersvolume — covers themes, SPI providers, and custom functions with a single mechanism. -
Capability-gated managed Postgres:
spec.postgres.managed: trueapplies aPerconaPGCluster(<name>-pg) via server-side-apply, gated on thepgv2.percona.comCRD being present —Failed/PerconaCRDMissingwith no silent fallback when it is not. Its generated credentials Secret (<name>-pg-db-credentials) is consumed directly as the DB credentials Secret. -
BYO Postgres:
managed: falsereadshost/dbname/user/password(+ optionalport) fromspec.postgres.credentialsSecret. -
Admin bootstrap credentials always via Secret (
KC_BOOTSTRAP_ADMIN_*viasecretKeyRef), never inline values.spec.adminSecretset = BYO Secret used verbatim; empty = the operator generates and tracks<name>-admin-credentials(32-char random password, generated once, never rotated). -
Optional realm import:
spec.realmImport.secretNamemounts the realm JSON Secret at/opt/keycloak/data/importand adds--import-realm. -
Status synthesis:
phase(Pending/Provisioning/Deploying/Ready/Failed),host,observedVersion,observedGeneration,conditions(Validated/BackendsReady/Progressing/Ready/UpgradeInProgress/MetricsExporterReady) with a stable reason vocabulary — seedocs/CRD-reference.md. A Kubernetes Event (reason: Phase<NewPhase>) is emitted on everystatus.phasetransition. -
Finalizer GC: removes operator-owned Secrets (tracked in
status.secrets) on deletion. The managedPerconaPGClusteris intentionally retained (no owner reference) — same data-loss-prevention policy asbnerd-gitlab-operator. -
Version resolution: transient (
VersionResolutionPending, 30s requeue) rather than permanentlyFailedwhenKeycloakVersionMap/defaultis missing or the requested version/alias is not yet present in it. -
Operator Helm chart (
charts/bnerd-keycloak-operator): ClusterRole + ClusterRoleBinding, namespaced leader-election Role/RoleBinding, Deployment, ServiceAccount, CRDs incrds/, and an optional defaultKeycloakVersionMap(versionMap.installDefault: true). -
HA / production hardening: an owned
PodDisruptionBudget(maxUnavailable: 1) when desired replicas>= 2— removed on scale-to-1 and while a cross-minor upgrade drains; soft pod anti-affinity across nodes; pod/container security context (runAsNonRoot,seccompProfile: RuntimeDefault,allowPrivilegeEscalation: false,capabilities.drop: [ALL]on the Keycloak container and every provider initContainer;runAsUserdeliberately unset); explicitRollingUpdateStatefulSet strategy;terminationGracePeriodSeconds: 60; and amanagement(9000) port on the client Service for metrics scraping. -
spec.postgres.nodes: overrides the managed Postgres instance replica count derived fromtopology(0= topology default;> 0wins). Managed-only and>= 0, enforced at validation. -
Optional ServiceMonitor (
spec.metrics.serviceMonitor: true): capability-gated on themonitoring.coreos.comServiceMonitorCRD. Present → an ownedServiceMonitorscraping themanagementport/metricsevery 30s; absent →MetricsExporterReady=False/ServiceMonitorCRDMissingwarning without failing the instance. -
Upgrade orchestration: same-minor changes roll one-by-one in place; cross-minor/major changes are drain-first recreates (
UpgradeInProgress→UpgradeCompleted); downgrades are refused (VersionDowngradeBlocked) unless thek8s.bnerd.com/allow-downgradebreak-glass annotation is set, in which case a cross-minor downgrade is also a drain-first recreate. Readiness is gated on the StatefulSet rollout converging onto the new revision, soReady/observedVersionnever advance while old-revision pods still serve. -
envtest suite: controller tests covering validation, version resolution, BYO + managed Postgres paths (including invalid-Secret requeue + recovery), child-resource rendering, status phases + rollout-convergence readiness, phase event de-duplication, deletion GC, upgrade orchestration (patch rolling, cross-minor recreate, no-downgrade guard + break-glass), PDB lifecycle, and the ServiceMonitor capability gate (present, absent, and toggle-off).
-
Live kind e2e: a full integration suite (6 tests) runs the operator in-cluster against real Keycloak + Postgres pods — happy path, BYO + managed backends, deletion, patch rolling upgrade (watch-verified never scaled to zero), cross-minor recreate + no-downgrade guard + break-glass, and provider-JAR injection. Driven by
make kind-up && make kind-test && make kind-down.
Known limitations¶
See Known Limitations for the full list.