Skip to content

Changelog

v0.5.0 (2026-08-25)

⚠ Breaking

  • spec.providers[].url is REMOVED. The fourth provider-source variant is now objectRef (bucket/key, S3-compatible object storage), not a direct HTTPS URL. sha256 moves with it — still mandatory on the fetched-and-verified variants, still forbidden on image/configMapRef/secretRef. Any existing KeycloakInstance using spec.providers[].url will 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 CEL exists_one union 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 using url on 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[].objectRef replaces the removed url variant — an S3-compatible bucket/key fetched, checksum-verified against sha256, 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 both KC_BOOTSTRAP_ADMIN_* env vars entirely; a CEL rule refuses spec.adopt and spec.adminSecret together 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 trusting kc.sh's exit code. (bootstrap-admin user --no-prompt exits 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 on requestID (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 pulls image@sha256:<digest> instead of the bare tag, and reports a VersionPinned condition (True/VersionDigestPinned or False/VersionMapUnpinned) so an unpinned entry in the version map is visible on the instance rather than silently trusted.
  • ImageOverrideActive condition — when spec.image bypasses the version map entirely (as it always has), the instance now says so explicitly rather than leaving it implicit; mutually exclusive with VersionPinned, 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 — no update, no patch). The recovery-admin Job cannot be created without it. The chart's ClusterRole is hand-maintained and is what actually deploys; config/rbac/role.yaml is 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 podSelector egress rule. A plain ipBlock: 0.0.0.0/0 port-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).
  • sha256 and key are NOT enforced by the apiserver. The only CEL rule on spec.providers[] is the exactly-one-of-image/objectRef/configMapRef/secretRef union check — so nothing there enforces MORE than that, and a client must not assume it does. The "sha256 required with objectRef, forbidden otherwise" and "key required with configMapRef/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 iff objectRef) 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.4 versionmap 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 when spec.postgres.managed is 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 shape spec.postgres credentials 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.
  • BackupHealthy status condition, with a staleness threshold derived from the new scheduleCadence field (hourly/daily/weekly, default daily) rather than a fixed 48h — a weekly schedule no longer reads permanently False between runs. scheduleCadence is declared, not parsed from the cron string; the two must agree.
  • Backup Jobs carry explicit CPU/memory bounds (limits 200m/512Mi, requests 50m/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 when spec.backups.database is configured, so an instance that never enables backups renders exactly what it did before.
  • PGInput.RestoreFrom resolves the restore-CR shape for a Percona PG restore driven from a spec.backups.database-configured instance. This is the CR shape only. It is not a proven restore path: the shape is unit-tested and the dataSource variant 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 DatabaseBackupIgnored condition, never wires an unencrypted or empty-passphrase repo2).

Fixed

  • Grants the RBAC the controller needs to read perconapgbackups (list/watch — not get, list-derived reconciliation only) for backup status.

Changed

  • Version map gains 26.3.4 (plus a 26.3 alias). 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 to 26.3.4; one that first comes up on stable cannot later be re-pinned down without the break-glass annotation.
  • hack/mirror-versions.sh mirrors curated upstream images into Harbor, digest-pinned, via skopeo copy --all. --all rather 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 (and adminParentRef.kind). Lets a parentRef name something other than a Gateway — e.g. a per-app ListenerSet, needed for the custom-domains attachment. Empty is unchanged from every prior release (Gateway API's own Gateway default). Passed through to the rendered HTTPRoute verbatim, unvalidated: a Gateway implementation that cannot resolve the referent surfaces that through the existing RoutesAccepted condition, the same way an unpinned sectionName already does. See the CRD reference.

v0.3.3 (2026-08-22)

Security

  • The admin-pin CEL rule now also fires on an ABSENT hosts.adminExposure when the instance references no KeycloakProfile. v0.3.2 shipped it scoped to an explicit adminExposure: Public; an instance that left the field unset (which also means Public, per the default) and pinned its public parentRef.sectionName could 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 supply Internal, so judging it against Public before the merge would reject the documented "adminExposure on the profile, bare instances" pattern — ValidateWrittenSpec (instance as authored) now defers that case to ValidateSpec (effective, post-merge spec), which the controller always runs. See Admin-console isolation.

Features

  • PublicRoutePinned status condition (GatewayAPI mode). Warns — Warning, not a rejection — when ingress.gateway.parentRef has no sectionName. The operator has no RBAC to read Gateway objects (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 :80 kept for ACME, serving the login form in cleartext. A genuinely single-listener Gateway with an unpinned parentRef (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 — https once GatewayAPI mode or ingress.clusterIssuer is set), status.realmImport: {state: Pending|Imported, message}, status.providers[]: {name, state: Pending|Ready, message}. adminHost/ urls reflect 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-realm runs synchronously at Keycloak's own startup before it opens its port, so Ready is real evidence of success — not a guess. Failed is 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-runtime 0.17 → 0.18, k8s.io/* 0.29 → 0.30, sigs.k8s.io/gateway-api 1.0.0 → 1.1.0, ENVTEST_K8S_VERSION 1.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.0 in go.mod; the CI/Docker golang:1.21 images move to golang:1.22 to match.

v0.3.2 (2026-08-21)

Features

  • spec.hosts.adminExposure: Public | Internal decides how strictly the admin host is separated from the public entry point.
  • Internal is the isolation v0.3.0 introduced: ingress.gateway.adminParentRef (GatewayAPI) or ingress.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.adminClass may be omitted (the admin Ingress then takes the public class), and gateway.adminParentRef may be omitted while parentRef is unpinned. Once parentRef.sectionName is set, adminParentRef and its own sectionName are required — see the security note below.
  • Available on KeycloakProfile.spec.adminExposure to set estate-wide; an instance can opt out with an explicit Public.
  • The public host's /admin block is unchanged under both values. The admin console is never reachable on spec.hosts.host. adminExposure governs 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.

  1. Ingress mode: the admin Ingress class default changes from nginx-internal to the PUBLIC class. An instance with adminHost set and no ingress.adminClass previously rendered its admin Ingress on nginx-internal; it now renders on ingress.class (or the nginx default). 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.
  2. GatewayAPI mode: adminParentRef is no longer required and no longer has to name a different Gateway. Previously an adminHost without 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:

spec:
  hosts:
    adminExposure: Internal
  ingress:
    adminClass: nginx-internal

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: Public and no adminParentRef, the admin route was attached to the public Gateway with no sectionName. A route without a sectionName attaches to every listener that admits it and whose hostname intersects — including the hostname-less :80 listener 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: Public in GatewayAPI mode, if ingress.gateway.parentRef.sectionName is set, then ingress.gateway.adminParentRef and its sectionName are 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 with sectionName, rootRedirect set, managed Percona standalone. The Gateway owner's half — two HTTPS listeners plus the :80 one 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, since adminExposure does not exist in the CRD before it. The Internal topology, 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 in examples/ 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 :80 one. 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 Public attachments now: adminParentRef omitted 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 (default admin).

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:
spec:
  hosts:
    rootRedirect: /realms/master/account/

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. rootRedirect unset 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.mode selects how a KeycloakInstance is served: Ingress (the default, unchanged) or GatewayAPI, which renders two gateway.networking.k8s.io/v1 HTTPRoutes attached to a platform-owned Gateway named in spec.ingress.gateway. The operator never creates a Gateway, GatewayClass or ReferenceGrant. See the Serving guide for the mode comparison, the Gateway-owner prerequisites and the migration recipe.
  • parentRef carries the public route; adminParentRef carries the admin route and is required whenever spec.hosts.adminHost is 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: /admin and the bare / on the public host still reach the endpoint-less sink Service, so a conformant implementation answers 503. See Admin-console isolation.
  • TLS terminates at the Gateway listener; class, adminClass and clusterIssuer are 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.
  • RoutesAccepted condition mirrors the Gateway controller's verdict onto the CR (NotAllowedByListeners, BackendNotFound, ...). Ready is not gated on it — see RoutesAccepted is not True.
  • Capability gate: GatewayAPI mode on a cluster without HTTPRoute at v1 fails loudly with GatewayAPIUnavailable instead 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.
  • MODE print column on kubectl 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 HTTPRoute RESTMapping while building the manager, before any capability gate could run. The probe now happens first. Nothing in v0.3.0 requires Gateway API.
  • RoutesAccepted no 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 reported True while serving nothing. The verdict is now correlated against the route's own parentRefs.
  • 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, ServiceMonitor and Percona no longer each list every CRD on every reconcile.
  • postgres.nodes is int32; the CRD schema gains only format: 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.adminParentRef must now name a different Gateway than parentRef. 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 next kubectl apply/patch against it will be refused until the admin route is moved to its own Gateway. Check with kubectl 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: mode unset means Ingress and 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/ returns 404 from Keycloak itself.
  • Re-check networkPolicy.ingressControllerSelector when moving an existing instance to GatewayAPI mode.

v0.2.0 (2026-08-17)

Features

  • KeycloakProfile CRD (cluster-scoped, kcprofile): a reusable preset of KeycloakInstance defaults, referenced by a new optional spec.profile field. 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 supports url (HTTPS + mandatory sha256, checksum-verified before the JAR is placed) and configMapRef/secretRef (for small JARs), alongside the existing image source — 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 adminHost is set, the separate admin ingress controller), intra-instance cache traffic, Prometheus scraping, DNS, and its Postgres backend. A new spec.networkPolicy.egressWhitelist field 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: true enables 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, or postgres.nodes value is now rejected immediately by kubectl 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=True after a backend outage. If a previously-Ready instance's Postgres backend became unavailable, the status phase correctly reflected it but the Ready condition could stay stuck at True. 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[].cidr now reject a malformed value immediately (at kubectl 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.dnsEgressCIDR is 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 (/admin and the bare /) are kept off the public host by routing those paths to a sink Service that selects no Pods, so a conformant controller answers 503. This replaces the previous nginx configuration-snippet 403 block and works on any Ingress class, including hardened ingress-nginx and non-nginx classes. The console is reachable only through the separate internal admin Ingress created when spec.hosts.adminHost is set (adminHost must differ from host).
  • Orphaned Ingresses cleaned up. Disabling spec.ingress.enabled or clearing spec.hosts.adminHost after creation now deletes the affected Ingress objects instead of leaving them serving.
  • Provider path hardening. spec.providers[].path is single-quoted in the copy command and shell metacharacters are rejected at validation.
  • Status condition hygiene. Entering Failed/Pending clears a stale Ready=True/Progressing; a genuine in-flight UpgradeInProgress is 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, unlike bnerd-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/kubernetes cache stack, Postgres DB vars), and startup/readiness/liveness probes on the management port (9000).

  • First-class provider (JAR) injection: spec.providers renders one initContainer per entry, copying *.jar from the provider image into a shared /opt/keycloak/providers volume — covers themes, SPI providers, and custom functions with a single mechanism.

  • Capability-gated managed Postgres: spec.postgres.managed: true applies a PerconaPGCluster (<name>-pg) via server-side-apply, gated on the pgv2.percona.com CRD being present — Failed/PerconaCRDMissing with 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: false reads host/dbname/user/password (+ optional port) from spec.postgres.credentialsSecret.

  • Admin bootstrap credentials always via Secret (KC_BOOTSTRAP_ADMIN_* via secretKeyRef), never inline values. spec.adminSecret set = 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.secretName mounts the realm JSON Secret at /opt/keycloak/data/import and 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 — see docs/CRD-reference.md. A Kubernetes Event (reason: Phase<NewPhase>) is emitted on every status.phase transition.

  • Finalizer GC: removes operator-owned Secrets (tracked in status.secrets) on deletion. The managed PerconaPGCluster is intentionally retained (no owner reference) — same data-loss-prevention policy as bnerd-gitlab-operator.

  • Version resolution: transient (VersionResolutionPending, 30s requeue) rather than permanently Failed when KeycloakVersionMap/default is 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 in crds/, and an optional default KeycloakVersionMap (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; runAsUser deliberately unset); explicit RollingUpdate StatefulSet strategy; terminationGracePeriodSeconds: 60; and a management (9000) port on the client Service for metrics scraping.

  • spec.postgres.nodes: overrides the managed Postgres instance replica count derived from topology (0 = topology default; > 0 wins). Managed-only and >= 0, enforced at validation.

  • Optional ServiceMonitor (spec.metrics.serviceMonitor: true): capability-gated on the monitoring.coreos.com ServiceMonitor CRD. Present → an owned ServiceMonitor scraping the management port /metrics every 30s; absent → MetricsExporterReady=False/ServiceMonitorCRDMissing warning without failing the instance.

  • Upgrade orchestration: same-minor changes roll one-by-one in place; cross-minor/major changes are drain-first recreates (UpgradeInProgressUpgradeCompleted); downgrades are refused (VersionDowngradeBlocked) unless the k8s.bnerd.com/allow-downgrade break-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, so Ready / observedVersion never 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.