Agents
Agent Instructions for provider-keycloak
This page collects context and instructions for AI coding agents (GitHub Copilot, Cursor, Claude, etc.) working on the provider-keycloak repository.
What This Repository Is
provider-keycloak is a Crossplane provider that lets
you manage Keycloak resources as Kubernetes custom
resources. It is generated with Upjet on
top of the Keycloak Terraform Provider.
One-line flow:
Keycloak Terraform Provider → Upjet (code generator) → Crossplane provider → Kubernetes CRDsUsers declare Keycloak resources as YAML (spec.forProvider maps to Terraform arguments), and the provider reconciles them continuously against a live Keycloak instance.
Repository Layout
apis/ Crossplane API types (generated + hand-authored)
cmd/ provider and generator entry points
config/ Upjet resource configuration (external names, references, cross-resource refs)
docs/ Hugo (hextra) documentation site
examples/ Hand-authored example manifests for each managed resource
examples-generated/ Auto-generated example manifests (do not edit by hand)
package/crds/ Generated CRD YAML files (source of truth for field schemas)
internal/ Internal controller and reconciler logic
generate/ Generation scripts
cluster/ Uptest end-to-end test manifests and setup
dev/ Local development environment scripts
scripts/ Utility scriptsCore Concepts
- ProviderConfig – holds connection details for a Keycloak instance (URL, client ID, credentials secret reference).
- Managed Resources – Kubernetes CRDs that map 1:1 to Keycloak objects.
spec.forProvidermaps to Terraform resource arguments. - Reconciliation – the provider controller continuously ensures Keycloak
matches the desired state expressed in
spec.forProvider. - External Name – the Keycloak-side identifier wired in
config/external_name.go. This is the ID or name that Keycloak assigns to the resource. - References – cross-resource references (e.g.,
realmIdRef) are configured inconfig/<group>/config.go. They wire one managed resource’s external name into another resource’s field.
Key Files for Common Tasks
| Task | File(s) |
|---|---|
| Add a new resource | config/external_name.go, config/<group>/config.go |
| Change reference resolution | config/<group>/config.go |
| Update docs for a resource | docs/content/docs/using/resources/<resource>.md |
| Add/update an example manifest | examples/<group>/<resource>.yaml |
| Modify CRD generation | generate/*.go, run make generate |
| Run unit tests | make test |
| Run e2e tests | make e2e, see cluster/test/cases.txt for covered resources |
| Regenerate llms.txt/llms-full.txt | make generate (or make docs-gen) |
| Verify docs freshness | make docs-freshness-check |
Code Generation
Always run make generate after changing config/ to regenerate CRDs and Go types. Never edit files in apis/ or package/crds/ by hand — they are generated outputs.
make generate is the single entry point for all generation — code, DAG and docs. There is no need to run the individual targets by hand.
The generation pipeline:
generate/main.gocalls Upjet with the Terraform provider schema.- Upjet writes Go type definitions into
apis/<group>/<version>/. make generaterunsgo generate ./...which invokes controller-gen to write CRDs intopackage/crds/.make generatethen runse2e-index(DAG:cluster/test/e2e-index.json),generated-lst(config/generated.lst) anddocs-gen(docs/static/llms.txt,docs/static/llms-full.txt).
Testing
- Unit tests:
make test - E2E tests:
make e2e(requires a running Keycloak and Crossplane cluster) - E2E coverage is limited to resources listed in
cluster/test/cases.txt
The E2E suite uses uptest. Only resources
explicitly listed in cluster/test/cases.txt receive e2e coverage.
Adding a New Resource
Use make schema-diff OLD_PROVIDER_VERSION=<prev> (or compare
config/generated.lst against config/schema.json) to find Terraform
resources that aren’t yet exposed as managed resources. The
schema-diff-issues GitHub Actions workflow automates this and files one
issue per missing resource that isn’t already tracked.
- Add an entry to
config/external_name.go. Chooseconfig.IdentifierFromProviderwhen the Terraform provider already returns a stable ID (including composite{realm}/...IDs), or a<group>.<Resource>IdentifierFromIdentifyingPropertieshelper (seeconfig/openidclient/orconfig/group/) when the ID must be derived from identifying attributes such as name + realm. - Create or update
config/<group>/config.goto configure references and any custom behaviors. If an attribute can reference more than one resource type, useconfig/multitypes(see Multi-Type References) rather than leaving it as a raw ID field. - Run
make generateto regenerate CRDs and Go types. - Add a hand-authored example to
examples/<group>/<resource>.yaml. - Optionally add a docs page to
docs/content/docs/using/resources/<resource>.md. - Optionally add the resource to
cluster/test/cases.txtplus a chainsaw/uptest manifest undercluster/test/ordev/demos/for e2e coverage.
To allow a resource to be imported/observed by its properties (avoiding 409 on create),
wire its external_name.go entry to a lookup.BuildIdentifyingPropertiesLookup config
in the config/<group> package (see config/openidclient/config.go for an example).
Automated Schema Diff Issues
.github/workflows/schema-diff-issues.yml runs scripts/schema_diff_issues.py,
which diffs config/schema.json against config/generated.lst and files one
GitHub issue per missing resource that isn’t already tracked by an existing
open issue (matched by exact resource name in the issue title/body).
config/generated.lst is itself generated from config.ExternalNameConfigs
(make generated-lst, run as part of make generate), and the workflow
refreshes it before diffing, so an already implemented resource is never
reported as missing. make generated-lst-check fails CI when the committed
file is stale.
- On
pull_request, it only runs in dry-run mode (reports only, creates nothing) when the automation itself changes (the script or the workflow file) — not on every PR. - On a weekly
schedule, onpushtomainthat touches theMakefile(a Terraform provider version bump), and on manualworkflow_dispatch, it creates real issues.
Cross-Resource References
References are wired in config/<group>/config.go using r.References on the
Upjet resource configuration. The reference resolver fills in the referenced
resource’s external name at reconciliation time. Example pattern:
r.References["realm_id"] = config.Reference{
TerraformName: "keycloak_realm",
}Multi-Type References
r.References accepts a single TerraformName, so it cannot describe a
Terraform attribute whose value may be the ID of several different resource
types. For those attributes, use the config/multitypes package instead of
exposing a raw ID field or hand-rolling synthetic fields. It creates one
synthetic, strongly-typed field per referenceable type and consolidates the
resolved values back into the original Terraform field before the request is
sent to Terraform.
Scalar field — a role’s client_id may point at an OpenID or a SAML client:
multitypes.ApplyToWithOptions(r, "client_id",
&multitypes.Options{KeepOriginalField: true}, // keep client_id settable
multitypes.Instance{
Name: "client_id",
Reference: config.Reference{
TerraformName: "keycloak_openid_client",
Extractor: common.PathUUIDExtractor,
},
},
multitypes.Instance{
Name: "saml_client_id",
Reference: config.Reference{
TerraformName: "keycloak_saml_client",
Extractor: common.PathUUIDExtractor,
},
},
)List/set field — keycloak_openid_client_aggregate_policy.policies holds IDs
of any authorization policy type:
multitypes.ApplyToAsList(r, "policies",
multitypes.Instance{
Name: "time_policies",
Reference: config.Reference{
TerraformName: "keycloak_openid_client_time_policy",
Extractor: common.PathUUIDExtractor,
},
},
multitypes.Instance{
Name: "role_policies",
Reference: config.Reference{
TerraformName: "keycloak_openid_client_role_policy",
Extractor: common.PathUUIDExtractor,
},
},
// ... one Instance per referenceable policy type
)Rules and gotchas:
Options.KeepOriginalField: trueis required (and only allowed) when oneInstancereuses the original field name; use it to keep the existing field settable for backward compatibility. The helper panics on a mismatch.- An
Instancethat reuses the original field name may omit itsReferenceentirely. Such an “untyped” instance gets no Ref/Selector fields; the original field simply stays settable for raw IDs of types that have no managed resource yet, and its value still takes part in consolidation. Omitting theReferenceon a synthetic instance panics. - If no
Instancereuses the original name, the original field becomes computed-only (status.atProvider). As a side effect, a required Terraform field no longer emits a required-parameter CEL rule, so no CRD post-processing is needed. - For scalar fields only one synthetic field may be set at a time; the consolidation injector errors otherwise. For list fields all synthetic lists are unioned.
- Every referenced
TerraformNamemust have an entry inconfig/external_name.go, otherwisemake generatepanics withcannot find configuration for Terraform resource. - Examples:
config/role/config.goandconfig/mapper/config.go(client_id/saml_client_id),config/authentication/config.go(parent_flow_alias/parent_subflow_alias),config/openidclient/config.go(clients/saml_clientsand aggregate-policypolicies),config/identityprovider/config.go(provider_alias/identity_provider_aliaswired to every identity provider type).
Documentation Site
The docs use Hugo with the Hextra theme.
cd docs && hugo server --buildDrafts # local preview
make generate # regenerates code, DAG and llms.txt
make docs-gen # regenerate llms.txt and llms-full.txt only
make docs-freshness-check # CI: verify llms.txt is currentEvery page is available as clean Markdown at the same URL with .md appended
(e.g., /docs/using/resources/realms/index.md). This is useful for AI agents
consuming individual pages.
LLM Files
/llms.txt— brief categorized index for AI assistants/llms-full.txt— all doc pages concatenated for full-context ingestion
Known Constraints and Pitfalls
- Never edit
examples-generated/by hand. These are auto-generated. - Never edit generated files in
apis/orpackage/crds/by hand. - Never edit
config/generated.lstby hand. It is generated fromconfig.ExternalNameConfigsbymake generated-lst(part ofmake generate). github.com/keycloak/terraform-provider-keycloakupdates are grouped; major bumps require manual review. The go.mod pseudo-version and its pinned Makefile version are grouped into a single weekly Renovate PR. Minor/patch/digest updates auto-merge once tests pass; major version bumps are not auto-merged since upgrading requires deliberate schema migration.- E2E tests only cover resources in
cluster/test/cases.txt. New resources are not automatically e2e tested. - Upjet does not support
+nullablemarkers. Do not add nullable annotations to generated types; thekubebuilderOptions struct only supports Required, Minimum, Maximum, Default. - Membership ownership: Avoid managing the same group’s membership with both
a
Membershipsresource and aGroupsresource withexhaustive=trueat the same time; this can cause reconciliation loops. - E2E Crossplane startup: When waiting for Crossplane to be ready in CI/dev scripts, wait on the deployment availability rather than pods by selector — pods may not exist yet when the wait command runs.
- E2E CI versioning: Jobs that build or deploy local xpkgs must fetch git tags
(
git fetch --tags) so thatbuild/makelib/common.mkderives the correct VERSION that matches the pre-cached xpkg.
Troubleshooting Common Issues
| Symptom | Likely Cause | Fix |
|---|---|---|
| CRD fields not updating after config change | make generate not run | Run make generate |
409 Conflict on resource create | External name collision; resource already exists in Keycloak | Use lookup.BuildIdentifyingPropertiesLookup to enable import |
llms-full.txt is stale in CI | Docs changed but make generate not run | Run make generate and commit |
no matches for kind in e2e | CRD not yet established when chainsaw runs | cluster/test/setup.sh waits for MRDs; check timing |
make generate produces unexpectedly large/stale diffs | Stale local generator cache/artifacts | Remove .work/ and config/schema.json, then re-run make generate |
| E2E provider version mismatch | Git tags not fetched before build | Add git fetch --tags before make build |
| Reconciliation loop on group membership | Both Memberships and Groups (exhaustive) target same group | Use only one authoritative source per group |