feat(keycloak): replace Keycloak realm setup with keycloak-config-cli #289
feat(keycloak): replace Keycloak realm setup with keycloak-config-cli #289viniciusdc wants to merge 8 commits into
Conversation
|
Two small in-manifest follow-ups surfaced while talking through user/group lifecycle, both worth doing but holding off this PR to keep the diff focused on the shell-to-kcc swap. Filing here so they don't get lost. 1.
|
|
One more note on kcc's capabilities relevant to future scope: identity providers are first-class in kcc's input schema. Setting up SSO (Google, GitHub, GitLab, generic OIDC/SAML, etc.) is the same shape as everything else — a top-level field on the realm with secret values via realms:
- realm: nebari
# ...
identityProviders:
- alias: github
providerId: github
enabled: true
trustEmail: true
config:
clientId: "$(env:GITHUB_CLIENT_ID)"
clientSecret: "$(env:GITHUB_CLIENT_SECRET)"
defaultScope: "user:email"
identityProviderMappers:
- identityProviderAlias: github
name: email-mapper
identityProviderMapper: github-user-attribute-mapper
config:
jsonField: email
userAttribute: emailNot in scope for this PR (Phase 1a is defaults-only), but the shape is what users get in Phase 1b/1c when they put an Two reasons this is worth surfacing now:
|
Phase 1a of the declarative Keycloak configuration design proposed in #154. Replaces the imperative shell script in realm-setup-job.yaml with a declarative YAML applied by keycloak-config-cli (kcc) at PostSync. Changes: - Add `realm-config-cm.yaml`: ConfigMap carrying the kcc-format YAML for the nebari realm. Parity with the previous shell setup, including the `groups` client scope + group-membership mapper (load-bearing for operator-managed app authorization), the `argocd` OIDC client (PR #234), the `argocd-admins`/`argocd-viewers` groups, and the admin user's `argocd-admins` membership. - Rewrite `realm-setup-job.yaml`: pulls `quay.io/adorsys/keycloak-config-cli:6.5.0-26.5.5`, mounts the ConfigMap, and applies it against Keycloak. Secrets are mounted from the existing `nebari-realm-admin-credentials` and `argocd-oidc-client-secret` Secrets and substituted into the realm YAML by kcc at apply time via `$(env:VAR)` placeholders. Operator coexistence: - `IMPORT_REMOTESTATE_ENABLED=true` (kcc default since v6.x): kcc tracks resources it created in a Keycloak realm attribute. Re-runs only reconcile what kcc itself owns; operator-managed OAuth2 clients created at runtime via NebariApplication CRDs are invisible to kcc's delete logic. - `IMPORT_MANAGED_CLIENT=no-delete`: belt-and-braces. Even if a future input change accidentally removes the argocd client from `clients:`, kcc will not delete unlisted clients. The strict "omit `clients:` entirely" pattern from my #154 review was too restrictive: NIC owns the `argocd` client at bootstrap, so it has to be in the input. The two-layer protection above is the practical shape of the coexistence story. Phase 1a scope (per #154 review): only replaces the existing realm setup. No `keycloak:` section in `config.yaml`, no NIC-side env-var scanning, no merge engine. Defaults are static. Future phases add the user-facing schema (1b) and the user-secrets pipeline (1c). Migration: on existing deployments, kcc detects existing resources by their natural keys (realm name, role name, username, scope name) and updates them in place, adding them to remote state. First run on an already-configured cluster is a no-op for existing managed values; the pre-generated argocd OIDC client secret (same value on both sides) is re-applied verbatim. Refs #154.
- Bump kcc availability check timeout from 120s to 300s to match the 5-minute readiness window of the previous shell script. Keycloak's first-start Quarkus augmentation can push past 2 minutes. - Pin the Keycloak image version (26.5.5) in keycloak.yaml app values so the kcc image and Keycloak image versions are co-located. Comment in both files calls out the bump-in-lock-step constraint. - Add TestKeycloakRealmConfigCM to assert the load-bearing fields of the rendered realm-config ConfigMap. The kcc input is what makes the realm-setup-job produce a valid Keycloak state, and regressions would otherwise be silent (YAML renders fine but Keycloak gets misconfigured).
Two fixes surfaced during E2E testing on a fresh kind cluster: - The earlier `6.5.0-26.5.5` kcc image tag does not exist on quay.io. The actual built tags for kcc v6.5.0 stop at Keycloak `26.5.4` (confirmed via the adorsys/keycloak-config-cli release assets and quay.io tag listing). Bump both the Keycloak chart image and the kcc image to `26.5.4` so the pair matches a real released combination. - Add `BeforeHookCreation` to the realm-setup Job's `argocd.argoproj.io/hook-delete-policy`. Without it, a Job that fails for any reason (bad image, network blip, kcc parsing error) stays in the cluster forever: Kubernetes Jobs are immutable post-create, and ArgoCD's PostSync hook reconciler won't recreate one that hasn't succeeded. The `HookSucceeded`-only policy on main has the same fragility. With `BeforeHookCreation` added, every fresh sync first deletes any existing Job, then creates a new one from the current manifest — making upgrades safe even from a previously-failed run. Note this annotation alone does NOT unblock a cluster where ArgoCD's hook-finalizer is already stuck on a terminating Job from a prior run; that requires manual finalizer removal. But it prevents the class of issue going forward.
The previous version of realm-config-cm.yaml declared `defaultDefaultClientScopes: [groups]` at the realm level and `defaultClientScopes: [groups]` on the argocd client. kcc applies list fields as full replaces, not merges, so this stripped the built-in default scopes (basic, profile, email, roles, web-origins, acr) from the realm — tokens would be missing email/profile/roles claims that ArgoCD's UI and RBAC depend on. E2E test confirmed the regression on a fresh kind cluster: after the kcc Job ran successfully, the realm's default-default scopes contained only [groups] and the argocd client's default scopes were the same. The previous shell script avoided this by issuing PUTs against the specific scope path, which adds without touching the rest of the list. Fix: - Realm-level: list all built-in default scopes plus `groups` in `defaultDefaultClientScopes` so kcc's replace semantics produces the same end state as a merge would. - argocd client: drop the explicit `defaultClientScopes` entry entirely. Keycloak auto-applies the realm's default-defaults to new clients, which now correctly includes everything we need. Explicitly setting the field would replace that auto-applied list. Test (writer_test.go) updated to assert each built-in scope is present in the rendered ConfigMap and that the argocd client does not set `defaultClientScopes`. Caught at unit-test time going forward.
Follow-on to the realm-level scope fix in the previous commit. E2E re-test confirmed half of the fix landed: realm defaultDefaultClientScopes now correctly contains all 7 scopes after kcc applied the updated YAML. But the argocd client's defaultClientScopes was still just [groups] — Keycloak's realm default-defaults are auto-applied to new clients *at creation time only*, not retroactively on subsequent updates. So an existing argocd client never picked up the new realm defaults. Fix: explicitly list the full scope set on the argocd client too. kcc applies it as replace, but since the list is the complete intended state, the end result is correct. Redundant with the realm-level defaults for fresh-creation cases, load-bearing for re-runs. Test asserts the argocd client's defaultClientScopes line and its first entry (basic) are both present in the rendered ConfigMap.
…write Aligns Phase 1a with the agreed design in PR #154's rewrite (commit 669458a on design/declarative-keycloak-config). Previous structure put the kcc realm input in a ConfigMap rendered into the gitops repo. The rewrite's "Why no ConfigMap in the gitops repo" section explicitly rejects that pattern: - The gitops repo is treated as untrusted. Placeholder-only contracts ("file in repo only contains $(env:...), never literals") create a footgun where one operator mistake leaks a secret into git history. - Realm content beyond raw secrets is still sensitive (IdP names, redirect URI patterns, credential hashes from kc.sh export). New structure: - pkg/argocd/keycloak_defaults.yaml (new) — kcc realm input in native format, embedded into the NIC binary via //go:embed. Same content as the previous gitops ConfigMap data field, no ConfigMap wrapping. - pkg/argocd/keycloak_defaults.go (new) — RenderKeycloakDefaults() helper renders the template with TemplateData (Domain substitutes into the argocd redirect URI). - pkg/argocd/foundational.go — InstallFoundationalServices now renders the embedded defaults with cfg.Domain and writes the result to a new in-cluster Secret keycloak-config-import (keycloak namespace). createKeycloakImportSecret added as a sibling of the existing Secret creators; uses createSecret's no-op-if-exists semantics so re-deploys preserve any value already present in the cluster. - templates/manifests/keycloak/realm-setup-job.yaml — volume changed from a ConfigMap reference (keycloak-realm-config, deleted) to a Secret reference (keycloak-config-import). Everything else stays: same kcc image, same env-var sourcing, same hook annotations. - templates/manifests/keycloak/realm-config-cm.yaml (deleted) — the gitops repo no longer carries realm content. Only the Job manifest remains there. Phase 1a scope is unchanged. Phase 1b will introduce a user-supplied keycloak.yaml file path that overrides these embedded defaults; the Secret-vs-ConfigMap structural decision now makes that path direct. Test: - keycloak_defaults_test.go (new) asserts the load-bearing fields of the rendered kcc input — realm settings, scope lists, groups, argocd client, the oidc-group-membership-mapper, the full defaultDefaultClientScopes list, and that no unresolved Go-template markers leak into the output. - writer_test.go TestKeycloakRealmConfigCM removed (it tested the deleted ConfigMap template).
kcc resolves $(env:VAR) placeholders via Apache Commons StringSubstitutor over the whole file BEFORE parsing as YAML, so even comments are scanned for unresolved variables. The previous comment block in keycloak_defaults.yaml literally described the placeholder pattern as "$(env:VAR)", which kcc treated as a real reference and failed with "Cannot resolve variable 'env:VAR' (enableSubstitutionInVariables=true)" before applying anything. Rewords the comment to describe the syntax without writing the literal shape, and adds an explicit warning so future editors don't reintroduce the trap. Caught during E2E on the refactored branch: the new keycloak-config-import Secret carried the original comment verbatim, so the kcc Job failed-fast on every retry until the comment was sanitized. Post-fix E2E (kind-nebari-local, fresh deploy) ran the kcc Job to completion in seconds and produced the expected realm state (all 7 default scopes on both realm and argocd client, both argocd-admins/argocd-viewers groups, admin user member of argocd-admins).
- Extract labelPartOf/labelManagedBy/labelManagedByValue constants so the import-secret labels stop tripping goconst (4th occurrence of the label keys). Fixes the lint failure blocking CI. - Instrument RenderKeycloakDefaults with an OTel span and thread ctx through from InstallFoundationalServices, per the pkg/ instrumentation convention. - Render with missingkey=error so a stray template field fails loudly instead of emitting "<no value>" into the realm config; add a guard assertion. - Expand TestRenderKeycloakDefaults to cover the security-relevant scalars (sslRequired, registrationAllowed, bruteForceProtected), the realm roles, the multivalued groups-mapper flag, the argocd client flags, and webOrigins. - Drop the dead doc path from keycloak_defaults.yaml; point at #154.
91931f7 to
d57e8b7
Compare
Summary
Phase 1a of the declarative Keycloak configuration approach proposed in #154. Replaces the imperative shell script in
realm-setup-job.yaml(chains ofkcadmcalls with|| truefor idempotency) with a declarative pipeline using keycloak-config-cli (kcc).Structurally aligned with the design rewrite Chuck pushed to #154 on 2026-05-12: kcc input lives in an in-cluster Secret created by NIC at bootstrap from embedded defaults. The gitops repo never holds realm content — only the Job manifest.
Changes
pkg/argocd/keycloak_defaults.yaml(new) — kcc realm input in native format. Parity with the previous shell setup: nebari realm settings,admin/userrealm roles, admin user with both roles +argocd-adminsmembership,groupsclient scope +oidc-group-membership-mapperregistered asdefaultDefaultClientScopes(load-bearing for operator-managed app authorization viaNebariApplication.allowedGroups),argocdOIDC client wired by ArgoCD SSO via Keycloak OIDC #234,argocd-admins/argocd-viewersgroups.pkg/argocd/keycloak_defaults.go(new) —//go:embedof the realm YAML +RenderKeycloakDefaults(TemplateData)helper.{{ .Domain }}is substituted at deploy time (used in the argocd client's redirect URI).pkg/argocd/foundational.go— newcreateKeycloakImportSecretwrites the rendered content to an in-cluster Secret namedkeycloak-config-importin thekeycloaknamespace.InstallFoundationalServicesrenders the defaults withcfg.Domainand calls the creator at bootstrap, immediately after the existing Keycloak secret creators.pkg/argocd/templates/manifests/keycloak/realm-setup-job.yaml(rewritten) — kcc Job pullingquay.io/adorsys/keycloak-config-cli:6.5.0-26.5.4. Volume mounts the newkeycloak-config-importSecret (was the deleted gitops ConfigMap). Existing env-var passthrough fornebari-realm-admin-credentialsandargocd-oidc-client-secretSecrets unchanged.pkg/argocd/templates/apps/keycloak.yaml— Keycloak chart image pinned to26.5.4to match the kcc build target.pkg/argocd/templates/manifests/keycloak/realm-config-cm.yaml(deleted) — the gitops ConfigMap is gone; realm content no longer leaks into git.Scope (per #154 review)
Phase 1a only:
keycloak.yamluser-supplied file yet$(env:*)scanning yetremote-state)User-supplied schema (Phase 1b) and the user-secrets pipeline (Phase 1c) follow in separate PRs.
Why an in-cluster Secret, not a gitops-repo ConfigMap
Original implementation used a ConfigMap rendered into the gitops repo with
$(env:VAR)placeholders. The #154 design rewrite (commit669458aondesign/declarative-keycloak-config) explicitly rejected that pattern:Refactored to the Secret model accordingly. Side benefit for Phase 1b: when a user supplies their own
keycloak.yaml, the same Secret carrier accepts it without restructuring.Operator coexistence
Three independent layers so operator-managed OAuth2 clients (created at runtime by NebariApplication CRDs) are never touched by kcc:
clients:in the kcc input only contains theargocdclient. Operator-managed clients are not declared.IMPORT_REMOTESTATE_ENABLED=true(default in kcc v6.x) — kcc tracks resources it created in a Keycloak realm attribute. Operator-created clients are invisible to kcc's delete logic.IMPORT_MANAGED_CLIENT=no-delete— belt-and-braces. Even if the input ever changes shape, kcc still won't delete unlisted clients.My earlier #154 review said "omit
clients:entirely" — that was too strict. NIC owns theargocdclient at bootstrap and has to declare it. The three-layer protection above is the practical shape.Migration from the shell-based setup
On existing deployments where the previous
realm-setup-jobshell script ran successfully, kcc detects the existing realm/users/groups/scope/clients by their natural keys and updates them in place, adding them to remote state. First run is effectively a no-op for already-correct values. The pre-generated argocd OIDC client secret is the same value across both K8s Secrets and Keycloak's stored value (verified during testing), so the OIDC trust survives.Testing
Verified end-to-end on a fresh kind cluster (
kind-nebari-local):keycloak-config-importSecret created at bootstrap from the embedded defaultsmanifests/keycloak/directory contains onlyrealm-setup-job.yaml(no realm content)secretName: keycloak-config-import(not the deleted ConfigMap)HookSucceedednebarirealm with all 7defaultDefaultClientScopes(basic,profile,email,roles,web-origins,acr,groups); argocd client default scopes same set;argocd-adminsandargocd-viewersgroups created; admin user member ofargocd-adminspkg/argocd/keycloak_defaults_test.go::TestRenderKeycloakDefaults(new) asserts each load-bearing field of the rendered kcc input + that no unresolved Go-template markers leak into the output. Catches regressions at unit-test time before they hit the cluster.Findings the testing surfaced (and is now baked into source)
Five non-obvious-from-the-design things came up during real deploys:
6.5.0-26.5.4. Both Keycloak chart image and kcc image pinned to26.5.4.HookSucceeded-only delete policy traps stuck Jobs. A kcc Job that fails (bad image, network blip, parsing error) never succeeds, soHookSucceedednever fires its cleanup, and ArgoCD's hook-finalizer keeps the Job around indefinitely. Subsequent reconciles can't update the Job's pod template either (Jobs are immutable post-create). AddedBeforeHookCreationto the policy so every fresh sync first deletes any existing Job.groupstodefaultDefaultClientScopesvia a PUT on the specific scope path (additive). kcc's declarativedefaultDefaultClientScopes: [groups]replaces the entire list. All built-in default scopes have to be listed alongsidegroups, both at realm level and on the argocd client.$(env:VAR)placeholder shape tripped the substitutor withCannot resolve variable 'env:VAR'. Comments now describe the syntax without writing the literal pattern, plus a warning so the next editor doesn't reintroduce it.kubectl patch app <name> --type=merge -p '{"operation":null,"status":{"operationState":null}}'followed by a fresh sync.Refs