Crossplane is the control-plane way to build the abstraction from section 3.1: you define a new API (XRD), write the recipe that expands it into real resources (Composition), and developers create instances (XR) that reconcile forever like any operator-managed object.
make up apiOrientation
The lab's three files in examples/crossplane/ are one complete platform API: AppEnvironment in, namespace plus quota out. Read them in the order XRD → Composition → XR and the whole model falls out.
XRD = the API you promise (schema + names). Composition = the recipe that fulfills it (a pipeline of functions producing composed resources). XR = an instance a developer creates. Crossplane generates the CRD from the XRD, then reconciles each XR by running the composition and applying the result. It does that forever, so drift on the composed resources is corrected like any other controller's.
The v2 model, because versions matter here
This lab runs Crossplane v2, and the differences from v1 content you may find online are exactly the things that will bite:
| Topic | v1 (most tutorials) | v2 (this lab) |
|---|---|---|
| Scope | XRs are cluster-scoped | XRs can be namespaced (spec.scope: Namespaced in the XRD) |
| Developer-facing object | a Claim (namespaced) referencing a cluster XR | no claims for the v2 scopes: developers create the XR directly; scope: LegacyCluster still keeps claim behavior for migrating v1 APIs |
| Composition style | inline resources[] with patch lists | mode: Pipeline, a list of functions |
kubectl explain composition.spec settles it in three seconds. Practicing that recovery is worth more than memorizing either dialect, because every fast-moving tool in this curriculum will eventually hand you a version-skew problem.
Walk the chain in the files
xrd.yamldefines schema and names forAppEnvironment; this is what generated the CRD you inspected in 3.2. The schema here is the same OpenAPI you hand-wrote there, which is the payoff of doing 3.2 first.composition.yamldeclarescompositeTypeRefback to that kind, then a pipeline.function-patch-and-transformrenders two provider-kubernetesObjects, one wrapping a Namespace and one a ResourceQuota.FromCompositeFieldPathpatches pullspec.teamand the quota fields from the XR, and oneToCompositeFieldPathpatch flows the namespace name back into XR status. Thenfunction-auto-readymarks the XR Ready when its composed resources are.xr.yamlis the ten-line developer experience the other two files buy.
XRD AppEnvironment ──generates──▶ CRD appenvironments.platform.lab.local
▲
XR team-c-dev spec: {team, cpuQuota, memoryQuota} ─┘
│ reconciled by Crossplane through…
▼
Composition (mode: Pipeline)
├─ function-patch-and-transform ─▶ Object/ns → Namespace team-c
│ Object/quota → ResourceQuota tenant-quota
└─ function-auto-ready ──────────▶ XR Ready when composed resources are Ready
Providers, functions, and package health
Crossplane's own extension points are packages: Providers (controllers for external APIs: AWS, GCP, Kubernetes, Helm) and Functions (the pipeline steps that render composed resources). A ProviderConfig (or ClusterProviderConfig) tells a provider how to authenticate.
Step zero of any Crossplane diagnosis: kubectl get providers.pkg.crossplane.io,functions.pkg.crossplane.io; every package must be Installed and Healthy before anything downstream can possibly work. A composition referencing a function that never came up produces an XR stuck with a fairly opaque message. Thirty seconds of package checking saves twenty minutes of reading manifests.
- A namespaced XR may not compose cluster-scoped resources (ownerReferences forbid it), so the composition uses the namespaced Object variant (
kubernetes.m.crossplane.io, notkubernetes.crossplane.io) even though the manifest inside it is a cluster-scoped Namespace. Wrong-variant errors read ascannot apply cluster scoped composed resource. - That namespaced variant authenticates via
ClusterProviderConfig, notProviderConfig. Same words, different kind, and the failure is an auth error rather than a schema error.
Recognizing those two error messages on sight saves time on the exam.
Operating a Crossplane platform API
Failure bubbles bottom-up: a composed resource fails, its condition says why, the XR's Ready goes False with a summarized message, and the developer sees only the XR. So the read path for every Crossplane incident is: XR conditions → composed resource conditions → the underlying object's own error.
crossplane resource trace appenvironment team-c-dev # the whole tree with statuses, one shot
kubectl get appenvironment team-c-dev -o jsonpath='{.status.conditions}' | jq
kubectl -n default get objects.kubernetes.m.crossplane.io -o yaml | grep -A3 message
kubectl get providers.pkg.crossplane.io,functions.pkg.crossplane.iooutputcaptured 2026-08-26
$ crossplane resource trace appenvironment team-c-dev # the whole tree with statuses, one shot
NAME SYNCED READY STATUS
AppEnvironment/team-c-dev (default) True True Available
├─ Object/team-c-dev-b4bcdb64391c (default) True True Available
└─ Object/team-c-dev-e7e1d26b0016 (default) True True Available
$ kubectl get appenvironment team-c-dev -o jsonpath='{.status.conditions}' | jq
[
{
"lastTransitionTime": "2026-08-27T01:53:00Z",
"observedGeneration": 4,
"reason": "ReconcileSuccess",
"status": "True",
"type": "Synced"
},
{
"lastTransitionTime": "2026-08-27T01:53:06Z",
"observedGeneration": 4,
"reason": "Available",
"status": "True",
"type": "Ready"
},
{
"lastTransitionTime": "2026-08-27T01:53:00Z",
"observedGeneration": 4,
"reason": "WatchCircuitClosed",
"status": "True",
"type": "Responsive"
}
]
$ kubectl -n default get objects.kubernetes.m.crossplane.io -o yaml | grep -A3 message
$ kubectl get providers.pkg.crossplane.io,functions.pkg.crossplane.io
NAME INSTALLED HEALTHY PACKAGE AGE
provider.pkg.crossplane.io/provider-helm True True xpkg.crossplane.io/crossplane-contrib/provider-helm:v1.4.0 45m
provider.pkg.crossplane.io/provider-kubernetes True True xpkg.crossplane.io/crossplane-contrib/provider-kubernetes:v1.3.0 45m
NAME INSTALLED HEALTHY PACKAGE AGE
function.pkg.crossplane.io/function-auto-ready True True xpkg.crossplane.io/crossplane-contrib/function-auto-ready:v0.7.0 45m
function.pkg.crossplane.io/function-patch-and-transform True True xpkg.crossplane.io/crossplane-contrib/function-patch-and-transform:v0.10.9 45mTwo more operational facts worth carrying:
- Composition selection. An XR picks its composition by
compositionRef, bycompositionSelectorlabels, or by the XRD's default. When the wrong recipe runs, that is where to look, not in the composition itself. - Changing the recipe changes existing instances. Edit a composition and every XR using it reconciles to the new definition without anyone touching them. That is the platform team's side of the contract, and it is also the blast radius: a bad composition edit hits every tenant at once. Version compositions (and pin XRs with
compositionUpdatePolicy: Manual) when that matters.
An operator encodes domain behavior in Go (failover, backups, upgrades); Crossplane encodes composition in YAML (this API expands into those resources). If the task is "keep Postgres alive", you want an operator. If the task is "offer teams a one-field way to ask for a Postgres, a namespace and a quota together", you want Crossplane wrapping that operator.
XRD and XR fields that decide behavior
Most Crossplane tasks are a manifest with two or three fields wrong. These are the fields.
| XRD field | Decides | Trap |
|---|---|---|
| spec.scope | Namespaced (v2 default), Cluster, or LegacyCluster (v1 semantics with claims) | immutable after creation; a namespaced XR cannot own cluster-scoped composed resources |
| spec.group / spec.names | the API group and kind/plural of the generated CRD | the CRD name is <plural>.<group>; that string is what kubectl explain and RBAC use |
| spec.versions[].served | whether the API server accepts that version | served: false rejects every XR written at that version |
| spec.versions[].referenceable | the one version Compositions may point at | exactly one version; changing it means updating every Composition's compositeTypeRef.apiVersion |
| spec.versions[].schema.openAPIV3Schema | the developer-facing contract | never write status.conditions or anything under spec.crossplane/status.crossplane: those are reserved and ignored |
| spec.defaultCompositionRef | the Composition used when an XR names none | only consulted at XR creation; existing XRs keep their compositionRef |
| spec.enforcedCompositionRef | forces one Composition for every XR of the kind | overrides whatever the XR asked for; the read path for "the wrong recipe ran even though I set compositionRef" |
| spec.connectionSecretKeys | which connection detail keys the XR may expose | a key the Composition writes but the XRD does not list is dropped silently |
| spec.claimNames | claim kind for LegacyCluster XRDs only | invalid on Namespaced/Cluster XRDs |
An XRD reports Established when its CRD is installed and Offered when a claim CRD exists (legacy only). kubectl get xrd shows both columns; a false Established almost always means a schema the API server rejected, and the condition message quotes the reason.
What the developer's XR controls
In v2 the Crossplane-specific knobs live under spec.crossplane (legacy cluster-scoped XRs and claims keep them at the top of spec):
spec:
team: team-c # your schema
crossplane:
compositionRef: { name: appenvironment-small } # or compositionSelector.matchLabels
compositionUpdatePolicy: Manual # Automatic is the default
compositionRevisionRef: { name: appenvironment-small-7c9a2 }
writeConnectionSecretToRef: { name: team-c-conn }- Selection order.
enforcedCompositionRefon the XRD wins; otherwise the XR'scompositionRef, thencompositionSelectorlabels, then the XRD'sdefaultCompositionRef. Crossplane writes the resolvedcompositionRefback onto the XR, so an XR created before you changed the default keeps the old recipe. - Revisions. Every Composition edit creates an immutable
CompositionRevision(kubectl get compositionrevision -l crossplane.io/composition-name=<name>). WithcompositionUpdatePolicy: AutomaticXRs follow the newest revision; withManualthey stay on thecompositionRevisionRefCrossplane pinned at creation until you edit it. That is the blast-radius control the previous panel promised. - Pausing. The annotation
crossplane.io/paused: "true"on an XR or a managed resource stops reconciliation without deleting anything; the lab's "paused XR" fault is exactly this, and the XR'sSyncedcondition saysReconcilePaused. - Conditions.
Syncedmeans Crossplane could run the Composition and apply the result to the API server;Readymeans the composed resources report ready.Synced=Falseis a recipe or permission problem (read the message: unknown function, invalid patch path, forbidden);Ready=FalsewithSynced=Trueis a downstream problem (the provider or the external API). Deciding which of the two is false is the first branch of every Crossplane diagnosis.
Inside a Pipeline composition
Each pipeline[] step names a functionRef and passes input. The functions you will meet: function-patch-and-transform (the declarative one: resources[] with base and patches), function-go-templating (Go templates rendering YAML, often with {{ .observed.composite.resource.spec.x }}), function-kcl and function-cue (typed configuration languages), function-environment-configs (loads EnvironmentConfig objects by Reference or Selector into the pipeline context; native environment patching was removed from core in v1.18), and function-auto-ready, which should be last so the XR becomes Ready only when every composed resource is. Patch types worth naming: FromCompositeFieldPath (XR to composed), ToCompositeFieldPath (composed to XR status), CombineFromComposite (several XR fields into one string with fmt), and the FromEnvironmentFieldPath pair. Transforms sit on a patch: map, match, math, string (fmt, convert, trimPrefix, regexp) and convert (string to int and back). A patch whose fromFieldPath does not exist is skipped unless policy.fromFieldPath: Required, which turns a silent miss into a Synced=False message you can read.
"Developers must not be able to pick a different Composition" is enforcedCompositionRef. "Existing instances must not change when the platform team updates the recipe" is compositionUpdatePolicy: Manual. "Expose the generated namespace name to the developer" is a ToCompositeFieldPath patch into status, with that status field present in the XRD schema. Each is a one-field answer if you know where the field lives.
Managed resources, packages and the CLI
Managed resource fields
spec.forProvideris the desired external state and is enforced: Crossplane reverts drift on those fields.spec.initProvidersets values only at creation and never enforces them (autoscaled sizes, for example).spec.managementPolicies(beta, on by default) is a list drawn fromObserve,Create,Update,Delete,LateInitialize; the default is["*"].["Observe"]is observe-only: import an existing resource by creating an MR with that policy plus thecrossplane.io/external-nameannotation, and Crossplane fillsstatus.atProviderwithout ever changing or deleting the real thing. DroppingDeletemeans deleting the MR orphans the external resource. An empty list pauses the resource.spec.deletionPolicy(Deletedefault,Orphan) is the older single knob for the same orphaning decision; when both are set,managementPolicieswithoutDeletewins.spec.providerConfigRefnames the credentials object. In v2 the namespaced MR kinds (API groups ending.m.upbound.ioor.m.crossplane.io) reference a namespacedProviderConfigor aClusterProviderConfig; the wrong kind name is the auth-looking error the lab already documents. The default ref name isdefault, so a missingProviderConfig/defaultis the classic first failure of a fresh provider.- Conditions.
Syncedwith reasonReconcileErrorcarries the provider's message;Readyfollows the external resource.kubectl get managedlists every MR of every provider in one table withREADY,SYNCEDandEXTERNAL-NAMEcolumns.
Usage: deletion protection and ordering
A Usage (protection.crossplane.io/v1beta1) declares that resource of is used by another (or only protected, with a reason). While it exists, a delete of the protected object is rejected by Crossplane's admission webhook with a 409 whose message quotes the reason; deleting the user first releases it, and replayDeletion: true re-issues the blocked delete afterwards. "The database must never be deleted while the app that uses it exists" is a Usage, not RBAC.
Packages
| Field / object | Means |
|---|---|
| Provider / Configuration / Function (pkg.crossplane.io/v1) | the three package kinds; each creates a revision object (ProviderRevision, ConfigurationRevision, FunctionRevision) that holds the real health |
| spec.package | OCI reference; pin by tag or @sha256: digest |
| spec.packagePullPolicy | IfNotPresent (default), Always, Never |
| spec.revisionActivationPolicy | Automatic activates the newest revision; Manual installs it inactive so you can flip it deliberately |
| spec.revisionHistoryLimit | how many old revisions to keep (default 1) |
| spec.skipDependencyResolution / ignoreCrossplaneConstraints | escape hatches for a Configuration whose crossplane.yaml declares dependsOn providers or a Crossplane version range you do not satisfy |
| spec.runtimeConfigRef | points at a DeploymentRuntimeConfig (replaces the deprecated ControllerConfig) to set the provider pod's SA, resources, args and metrics annotations |
| Lock (pkg.crossplane.io) | the singleton the package manager uses to record resolved dependencies; kubectl get lock -o yaml shows what it decided |
kubectl get providers prints INSTALLED and HEALTHY. Unhealthy usually resolves to one of: image pull failure (private registry, add packagePullSecrets), a dependency that cannot be satisfied (the Lock and the revision's UnhealthyPackageRevision reason say which), or the provider pod itself crashing, which is a plain pod diagnosis in the crossplane-system namespace. Providers built on crossplane-runtime export crossplane_managed_resource_ready, crossplane_managed_resource_synced, crossplane_managed_resource_first_time_to_readiness_seconds and crossplane_managed_resource_drift_seconds on port 8080, which is what a "monitor the platform API" task scrapes with a PodMonitor.
The CLI's three jobs
- Render offline.
crossplane composition render xr.yaml composition.yaml functions.yamlruns the pipeline in Docker and prints the composed resources, no cluster needed (v1 docs call itcrossplane beta render). It is how you check a patch path before applying, and the fastest way to see what a stranger's Composition produces. - Trace live. The lab's
crossplane resource trace(older docs:crossplane beta trace) prints the XR, its composed resources and theirSynced/Readystatus as a tree;-o wideadds the messages. - Build and push.
crossplane xpkg buildandcrossplane xpkg pushpackage an XRD plus Compositions (andcrossplane.yamlwithdependsOn) into a Configuration image, which is how a platform API becomes a versioned, installable artefact.
The crossplane version in the exam may be a v1.x release where these commands live under beta; crossplane --help settles it in one second.
Crossplane's RBAC manager (crossplane-rbac-manager) creates aggregated ClusterRoles for every XRD and provider so that crossplane-admin, crossplane-edit and crossplane-view pick up new kinds automatically. A provider "stripped of RBAC" (the lab's fault) shows as Synced=False on every MR with a forbidden message from the provider's ServiceAccount, not as an unhealthy package. The fix is the missing ClusterRole or ClusterRoleBinding, not a reinstall.
Exercises
kubectl apply -f examples/crossplane/xr.yaml (or confirm team-c-dev exists from install), then trace the expansion:
kubectl get appenvironment team-c-dev -o jsonpath='{.status.conditions}' | jq
kubectl get appenvironment team-c-dev -o jsonpath='{.status.namespace}{"\n"}'
kubectl -n default get objects.kubernetes.m.crossplane.io
kubectl get ns team-c && kubectl -n team-c get resourcequota tenant-quota -o jsonpath='{.spec.hard}'outputcaptured 2026-08-26
$ kubectl get appenvironment team-c-dev -o jsonpath='{.status.conditions}' | jq
[
{
"lastTransitionTime": "2026-08-27T01:53:00Z",
"observedGeneration": 4,
"reason": "ReconcileSuccess",
"status": "True",
"type": "Synced"
},
{
"lastTransitionTime": "2026-08-27T01:53:06Z",
"observedGeneration": 4,
"reason": "Available",
"status": "True",
"type": "Ready"
},
{
"lastTransitionTime": "2026-08-27T01:53:00Z",
"observedGeneration": 4,
"reason": "WatchCircuitClosed",
"status": "True",
"type": "Responsive"
}
]
$ kubectl get appenvironment team-c-dev -o jsonpath='{.status.namespace}{"\n"}'
team-c
$ kubectl -n default get objects.kubernetes.m.crossplane.io
NAME KIND PROVIDERCONFIG SYNCED READY AGE
team-c-dev-b4bcdb64391c Namespace default True True 43m
team-c-dev-e7e1d26b0016 ResourceQuota default True True 43m
$ kubectl get ns team-c && kubectl -n team-c get resourcequota tenant-quota -o jsonpath='{.spec.hard}'
NAME STATUS AGE
team-c Active 43m
{"requests.cpu":"4","requests.memory":"8Gi"}cpuQuota: "4"), not the composition's placeholders. That last check proves the patches ran, which is the difference between "installed" and "works".Edit the composition to also set requests.memory default differently or to add a label to the namespace, apply, and watch existing XRs reconcile to the new recipe without anyone touching them.
kubectl get ns team-c --show-labels gains your label within a minute. Compositions are the platform team's side of the contract, changeable without touching the API or its instances.Add podQuota (integer, default 10) to the XRD schema, patch it into the quota's spec.hard[pods] in the composition, and set it in a new XR team-f-dev. Order matters: XRD first, wait Established, then composition, then XR.
kubectl -n team-f get resourcequota tenant-quota -o jsonpath='{.spec.hard.pods}' prints your number. You have now done a schema change, a recipe change, and a consumer change as three separate actors, which is the whole platform-API workflow.Create an XR with team: Team-G (capital letter, invalid DNS label for a namespace). The XR will not go Ready. Find the failure without guessing: XR conditions first, then the composed Object's conditions (kubectl -n default get objects.kubernetes.m.crossplane.io -o yaml | grep -A3 message), where the API server's rejection of the namespace name surfaces. crossplane resource trace appenvironment <name> (the CLI is installed) draws the whole tree with statuses in one shot.
An XRD has to be accepted twice: the composite type has to be established, and the claim or namespaced API has to be offered. The columns say which, and a half-installed XRD is the most common reason a developer's manifest is rejected as an unknown kind.
kubectl get xrd
kubectl get xrd appenvironments.platform.lab.local -o jsonpath='{.status.conditions}' | jq '.[] | {type, status, reason, message}'
kubectl api-resources --api-group=platform.lab.localoutputcaptured 2026-09-12
$ kubectl get xrd
NAME ESTABLISHED OFFERED AGE
appenvironments.platform.lab.local True 99m
$ kubectl get xrd appenvironments.platform.lab.local -o jsonpath='{.status.conditions}' | jq '.[] | {type, status, reason, message}'
{
"type": "Established",
"status": "True",
"reason": "WatchingCompositeResource",
"message": null
}
$ kubectl api-resources --api-group=platform.lab.local
NAME SHORTNAMES APIVERSION NAMESPACED KIND
appenvironments platform.lab.local/v1alpha1 true AppEnvironmentapi-resources. If one condition is False, read its message before touching the Composition; the XRD is upstream of everything else on this page.Every edit to a Composition creates a revision, and XRs follow the latest one by default. Manual freezes an XR where it is, which is how you roll a platform API change out to one tenant before all of them.
kubectl get compositionrevisions
kubectl patch composition appenvironment-kubernetes --type merge -p '{"metadata":{"labels":{"edited":"once"}}}'
kubectl get compositionrevisions -l crossplane.io/composition-name=appenvironment-kubernetes
kubectl apply -f - <<'EOF'
apiVersion: platform.lab.local/v1alpha1
kind: AppEnvironment
metadata: { name: team-f-dev, namespace: default }
spec:
team: team-f
cpuQuota: "2"
memoryQuota: 4Gi
crossplane:
compositionUpdatePolicy: Manual
EOF
sleep 20
kubectl get appenvironment team-f-dev -o jsonpath='{.spec.crossplane.compositionRevisionRef}{"\n"}'
kubectl get appenvironment team-c-dev -o jsonpath='{.spec.crossplane.compositionRevisionRef}{"\n"}'
kubectl patch composition appenvironment-kubernetes --type merge -p '{"metadata":{"labels":{"edited":"twice"}}}'
sleep 20
kubectl get appenvironment team-f-dev -o jsonpath='{.spec.crossplane.compositionRevisionRef}{"\n"}'
kubectl get appenvironment team-c-dev -o jsonpath='{.spec.crossplane.compositionRevisionRef}{"\n"}'
# team-f is this exercise's, not the lab's
kubectl delete appenvironment team-f-dev --ignore-not-found
kubectl delete ns team-f --ignore-not-found --wait=falseoutputcaptured 2026-09-12
$ kubectl get compositionrevisions
NAME REVISION XR-KIND XR-APIVERSION AGE
appenvironment-kubernetes-2cfe70e 3 AppEnvironment platform.lab.local/v1alpha1 5h18m
appenvironment-kubernetes-670ddf1 1 AppEnvironment platform.lab.local/v1alpha1 21h
appenvironment-kubernetes-69b60a2 2 AppEnvironment platform.lab.local/v1alpha1 5h19m
$ kubectl patch composition appenvironment-kubernetes --type merge -p '{"metadata":{"labels":{"edited":"once"}}}'
composition.apiextensions.crossplane.io/appenvironment-kubernetes patched
$ kubectl get compositionrevisions -l crossplane.io/composition-name=appenvironment-kubernetes
NAME REVISION XR-KIND XR-APIVERSION AGE
appenvironment-kubernetes-2cfe70e 3 AppEnvironment platform.lab.local/v1alpha1 5h18m
appenvironment-kubernetes-670ddf1 1 AppEnvironment platform.lab.local/v1alpha1 21h
appenvironment-kubernetes-69b60a2 4 AppEnvironment platform.lab.local/v1alpha1 5h19m
$ kubectl apply -f - <<'EOF'
apiVersion: platform.lab.local/v1alpha1
kind: AppEnvironment
metadata: { name: team-f-dev, namespace: default }
spec:
team: team-f
cpuQuota: "2"
memoryQuota: 4Gi
crossplane:
compositionUpdatePolicy: Manual
EOF
appenvironment.platform.lab.local/team-f-dev unchanged
$ sleep 20
$ kubectl get appenvironment team-f-dev -o jsonpath='{.spec.crossplane.compositionRevisionRef}{"\n"}'
{"name":"appenvironment-kubernetes-69b60a2"}
$ kubectl get appenvironment team-c-dev -o jsonpath='{.spec.crossplane.compositionRevisionRef}{"\n"}'
{"name":"appenvironment-kubernetes-69b60a2"}
$ kubectl patch composition appenvironment-kubernetes --type merge -p '{"metadata":{"labels":{"edited":"twice"}}}'
composition.apiextensions.crossplane.io/appenvironment-kubernetes patched
$ sleep 20
$ kubectl get appenvironment team-f-dev -o jsonpath='{.spec.crossplane.compositionRevisionRef}{"\n"}'
{"name":"appenvironment-kubernetes-69b60a2"}
$ kubectl get appenvironment team-c-dev -o jsonpath='{.spec.crossplane.compositionRevisionRef}{"\n"}'
{"name":"appenvironment-kubernetes-2cfe70e"}
$ # team-f is this exercise's, not the lab's
$ kubectl delete appenvironment team-f-dev --ignore-not-found
appenvironment.platform.lab.local "team-f-dev" deleted from default namespace
$ kubectl delete ns team-f --ignore-not-found --wait=false
namespace "team-f" deletedA tenant who can pick the Composition can pick a cheaper one. enforcedCompositionRef on the XRD overrides whatever they asked for, silently and at every reconcile.
kubectl patch xrd appenvironments.platform.lab.local --type merge -p '{"spec":{"enforcedCompositionRef":{"name":"appenvironment-kubernetes"}}}'
kubectl apply -f - <<'EOF'
apiVersion: platform.lab.local/v1alpha1
kind: AppEnvironment
metadata: { name: team-i-dev, namespace: default }
spec:
team: team-i
crossplane:
compositionRef: { name: something-else }
EOF
sleep 20
kubectl get appenvironment team-i-dev -o jsonpath='{.spec.crossplane.compositionRef}{"\n"}'
kubectl patch xrd appenvironments.platform.lab.local --type json -p '[{"op":"remove","path":"/spec/enforcedCompositionRef"}]'
kubectl delete appenvironment team-i-devoutputcaptured 2026-09-13
$ kubectl patch xrd appenvironments.platform.lab.local --type merge -p '{"spec":{"enforcedCompositionRef":{"name":"appenvironment-kubernetes"}}}'
compositeresourcedefinition.apiextensions.crossplane.io/appenvironments.platform.lab.local patched
$ kubectl apply -f - <<'EOF'
apiVersion: platform.lab.local/v1alpha1
kind: AppEnvironment
metadata: { name: team-i-dev, namespace: default }
spec:
team: team-i
crossplane:
compositionRef: { name: something-else }
EOF
appenvironment.platform.lab.local/team-i-dev created
$ sleep 20
$ kubectl get appenvironment team-i-dev -o jsonpath='{.spec.crossplane.compositionRef}{"\n"}'
{"name":"appenvironment-kubernetes"}
$ kubectl patch xrd appenvironments.platform.lab.local --type json -p '[{"op":"remove","path":"/spec/enforcedCompositionRef"}]'
compositeresourcedefinition.apiextensions.crossplane.io/appenvironments.platform.lab.local patched
$ kubectl delete appenvironment team-i-dev
appenvironment.platform.lab.local "team-i-dev" deleted from default namespaceThe pause annotation is the safe way to stop a controller touching an object while you investigate. The Synced condition says paused rather than lying about being up to date, which is the part that makes it safe.
# start from a known value: without this the patch below is a no-op and nothing can move
kubectl annotate appenvironment team-c-dev crossplane.io/paused- --overwrite 2>/dev/null || true
kubectl patch appenvironment team-c-dev --type merge -p '{"spec":{"cpuQuota":"4"}}'
sleep 60
kubectl -n team-c get resourcequota -o jsonpath='{.items[0].spec.hard}{"\n"}'
kubectl annotate appenvironment team-c-dev crossplane.io/paused=true --overwrite
kubectl patch appenvironment team-c-dev --type merge -p '{"spec":{"cpuQuota":"16"}}'
sleep 30
kubectl get appenvironment team-c-dev -o jsonpath='{.status.conditions}' | jq '.[] | {type, status, reason}'
kubectl -n team-c get resourcequota -o jsonpath='{.items[0].spec.hard}{"\n"}'
kubectl annotate appenvironment team-c-dev crossplane.io/paused-
sleep 30
kubectl -n team-c get resourcequota -o jsonpath='{.items[0].spec.hard}{"\n"}'
kubectl patch appenvironment team-c-dev --type merge -p '{"spec":{"cpuQuota":"4"}}'outputcaptured 2026-09-12
$ # start from a known value: without this the patch below is a no-op and nothing can move
$ kubectl annotate appenvironment team-c-dev crossplane.io/paused- --overwrite 2>/dev/null || true
appenvironment.platform.lab.local/team-c-dev annotated
$ kubectl patch appenvironment team-c-dev --type merge -p '{"spec":{"cpuQuota":"4"}}'
appenvironment.platform.lab.local/team-c-dev patched (no change)
$ sleep 60
$ kubectl -n team-c get resourcequota -o jsonpath='{.items[0].spec.hard}{"\n"}'
{"requests.cpu":"4","requests.memory":"8Gi"}
$ kubectl annotate appenvironment team-c-dev crossplane.io/paused=true --overwrite
appenvironment.platform.lab.local/team-c-dev annotated
$ kubectl patch appenvironment team-c-dev --type merge -p '{"spec":{"cpuQuota":"16"}}'
appenvironment.platform.lab.local/team-c-dev patched
$ sleep 30
$ kubectl get appenvironment team-c-dev -o jsonpath='{.status.conditions}' | jq '.[] | {type, status, reason}'
{
"type": "Synced",
"status": "False",
"reason": "ReconcilePaused"
}
{
"type": "Ready",
"status": "True",
"reason": "Available"
}
{
"type": "Responsive",
"status": "True",
"reason": "WatchCircuitClosed"
}
$ kubectl -n team-c get resourcequota -o jsonpath='{.items[0].spec.hard}{"\n"}'
{"requests.cpu":"4","requests.memory":"8Gi"}
$ kubectl annotate appenvironment team-c-dev crossplane.io/paused-
appenvironment.platform.lab.local/team-c-dev annotated
$ sleep 30
$ kubectl -n team-c get resourcequota -o jsonpath='{.items[0].spec.hard}{"\n"}'
{"requests.cpu":"16","requests.memory":"8Gi"}
$ kubectl patch appenvironment team-c-dev --type merge -p '{"spec":{"cpuQuota":"4"}}'
appenvironment.platform.lab.local/team-c-dev patchedReconcilePaused, and the change lands the moment you remove it.The render command runs the composition pipeline locally against the same files the cluster uses. It is the fastest feedback loop Crossplane has, and it is the right answer when a task says "show what this XR would create".
cat > /tmp/functions.yaml <<'EOF'
apiVersion: pkg.crossplane.io/v1
kind: Function
metadata:
name: function-patch-and-transform
spec:
package: xpkg.crossplane.io/crossplane-contrib/function-patch-and-transform:v0.10.9
---
apiVersion: pkg.crossplane.io/v1
kind: Function
metadata:
name: function-auto-ready
spec:
package: xpkg.crossplane.io/crossplane-contrib/function-auto-ready:v0.7.0
EOF
crossplane composition render examples/crossplane/xr.yaml examples/crossplane/composition.yaml /tmp/functions.yaml
kubectl get functions.pkg.crossplane.io
kubectl get function -o jsonpath='{range .items[*]}{.metadata.name} {.spec.package}{"\n"}{end}'
rm -f /tmp/functions.yamloutputcaptured 2026-09-13
$ cat > /tmp/functions.yaml <<'EOF'
apiVersion: pkg.crossplane.io/v1
kind: Function
metadata:
name: function-patch-and-transform
spec:
package: xpkg.crossplane.io/crossplane-contrib/function-patch-and-transform:v0.10.9
---
apiVersion: pkg.crossplane.io/v1
kind: Function
metadata:
name: function-auto-ready
spec:
package: xpkg.crossplane.io/crossplane-contrib/function-auto-ready:v0.7.0
EOF
$ crossplane composition render examples/crossplane/xr.yaml examples/crossplane/composition.yaml /tmp/functions.yaml
---
apiVersion: platform.lab.local/v1alpha1
kind: AppEnvironment
metadata:
name: team-c-dev
namespace: default
spec:
crossplane:
resourceRefs:
- apiVersion: kubernetes.m.crossplane.io/v1alpha1
kind: Object
name: team-c-dev-3108b38c5d8e
- apiVersion: kubernetes.m.crossplane.io/v1alpha1
kind: Object
name: team-c-dev-e07b1179d2fb
status:
conditions:
- lastTransitionTime: "2024-01-01T00:00:00Z"
reason: WatchCircuitClosed
status: "True"
type: Responsive
- lastTransitionTime: "2024-01-01T00:00:00Z"
reason: ReconcileSuccess
status: "True"
type: Synced
- lastTransitionTime: "2024-01-01T00:00:00Z"
message: 'Unready resources: namespace, quota'
reason: Creating
status: "False"
type: Ready
---
apiVersion: kubernetes.m.crossplane.io/v1alpha1
kind: Object
metadata:
annotations:
crossplane.io/composition-resource-name: namespace
generateName: team-c-dev-
labels:
crossplane.io/composite: team-c-dev
name: team-c-dev-e07b1179d2fb
... 54 more lines
$ kubectl get functions.pkg.crossplane.io
NAME INSTALLED HEALTHY PACKAGE AGE
function-auto-ready True True xpkg.crossplane.io/crossplane-contrib/function-auto-ready:v0.7.0 17h
function-patch-and-transform True True xpkg.crossplane.io/crossplane-contrib/function-patch-and-transform:v0.10.9 17h
$ kubectl get function -o jsonpath='{range .items[*]}{.metadata.name} {.spec.package}{"\n"}{end}'
function-auto-ready xpkg.crossplane.io/crossplane-contrib/function-auto-ready:v0.7.0
function-patch-and-transform xpkg.crossplane.io/crossplane-contrib/function-patch-and-transform:v0.10.9
$ rm -f /tmp/functions.yamlteam-c patched into the namespace name and the quota's namespace, and nothing is applied. The top-level render command is gone in the v2 CLI this lab installs, and so is the beta group: it is crossplane composition render, and the third argument is a functions file you build from the Function objects the next two commands list. Render runs those functions in Docker rather than asking the cluster, which is what makes it work without one.A provider is a controller with its own ServiceAccount, and the composed resource's Synced condition is where its permission problems surface. This is the fault the lab's own drill injects, so meet it deliberately first.
kubectl get managed
kubectl describe providerrevisions | head -40
kubectl get clusterrolebinding -o name | grep -i provider-kubernetes
CRB=$(kubectl get clusterrolebinding -o name | grep -i provider-kubernetes | head -1)
kubectl get "$CRB" -o yaml > /tmp/provider-crb.yaml
kubectl delete "$CRB"
kubectl patch appenvironment team-c-dev --type merge -p '{"spec":{"cpuQuota":"6"}}'
sleep 45
kubectl -n default get objects.kubernetes.m.crossplane.io -o jsonpath='{range .items[*]}{.metadata.name} {.status.conditions[?(@.type=="Synced")].reason} {.status.conditions[?(@.type=="Synced")].message}{"\n"}{end}'
kubectl apply -f /tmp/provider-crb.yaml
sleep 45
kubectl -n default get objects.kubernetes.m.crossplane.io -o jsonpath='{range .items[*]}{.metadata.name} {.status.conditions[?(@.type=="Synced")].status}{"\n"}{end}'
kubectl patch appenvironment team-c-dev --type merge -p '{"spec":{"cpuQuota":"4"}}'outputcaptured 2026-09-13
$ kubectl get managed
NAME KIND PROVIDERCONFIG SYNCED READY AGE
object.kubernetes.m.crossplane.io/team-c-dev-91fc2ee050c5 ResourceQuota default True True 17h
object.kubernetes.m.crossplane.io/team-c-dev-f9c700a787d0 Namespace default True True 17h
object.kubernetes.m.crossplane.io/team-f-dev-6d59b5cab364 Namespace default True True 55m
object.kubernetes.m.crossplane.io/team-f-dev-933e06392888 ResourceQuota default True True 55m
$ kubectl describe providerrevisions | head -40
Name: provider-helm-fdbfef781539
Namespace:
Labels: pkg.crossplane.io/package=provider-helm
Annotations: friendly-name.meta.crossplane.io: Provider Helm
meta.crossplane.io/description:
The Helm Crossplane provider enables resource management of Helm Releases
on Kubernetes Clusters, typically provisioned by Crossplane.
meta.crossplane.io/license: Apache-2.0
meta.crossplane.io/maintainer: Crossplane Maintainers <info@crossplane.io>
meta.crossplane.io/readme:
`provider-helm` is a Crossplane Provider that enables deployment and
management of [Helm](https://helm.sh) Releases on Kubernetes clusters
typically provisioned by Crossplane.
If you encounter an issue please reach out on
[slack.crossplane.io](https://slack.crossplane.io) and create an issue in
the
[crossplane-contrib/provider-helm](https://github.com/crossplane-contrib/provider-helm)
repo.
meta.crossplane.io/source: github.com/crossplane-contrib/provider-helm
API Version: pkg.crossplane.io/v1
Kind: ProviderRevision
Metadata:
Creation Timestamp: 2026-09-12T19:12:45Z
Finalizers:
revision.pkg.crossplane.io
Generation: 1
Owner References:
API Version: pkg.crossplane.io/v1
Block Owner Deletion: true
Controller: true
Kind: Provider
Name: provider-helm
UID: 56132175-2bae-4e3f-86ad-77855951c315
Resource Version: 363228
UID: 16360176-329d-4c59-9baa-08e4193b6e85
Spec:
Desired State: Active
Ignore Crossplane Constraints: false
Image: xpkg.crossplane.io/crossplane-contrib/provider-helm:v1.4.0
$ kubectl get clusterrolebinding -o name | grep -i provider-kubernetes
clusterrolebinding.rbac.authorization.k8s.io/crossplane-provider-kubernetes-17261e579786
clusterrolebinding.rbac.authorization.k8s.io/crossplane:provider:provider-kubernetes-17261e579786:system
$ CRB=$(kubectl get clusterrolebinding -o name | grep -i provider-kubernetes | head -1)
$ kubectl get "$CRB" -o yaml > /tmp/provider-crb.yaml
$ kubectl delete "$CRB"
clusterrolebinding.rbac.authorization.k8s.io "crossplane-provider-kubernetes-17261e579786" deleted
$ kubectl patch appenvironment team-c-dev --type merge -p '{"spec":{"cpuQuota":"6"}}'
appenvironment.platform.lab.local/team-c-dev patched
$ sleep 45
$ kubectl -n default get objects.kubernetes.m.crossplane.io -o jsonpath='{range .items[*]}{.metadata.name} {.status.conditions[?(@.type=="Synced")].reason} {.status.conditions[?(@.type=="Synced")].message}{"\n"}{end}'
team-c-dev-91fc2ee050c5 ReconcileError observe failed: cannot get object: resourcequotas "tenant-quota" is forbidden: User "system:serviceaccount:crossplane-system:provider-kubernetes-17261e579786" cannot get resource "resourcequotas" in API group "" in the namespace "team-c"
team-c-dev-f9c700a787d0 ReconcileSuccess
team-f-dev-6d59b5cab364 ReconcileSuccess
team-f-dev-933e06392888 ReconcileSuccess
$ kubectl apply -f /tmp/provider-crb.yaml
clusterrolebinding.rbac.authorization.k8s.io/crossplane-provider-kubernetes-17261e579786 created
$ sleep 45
$ kubectl -n default get objects.kubernetes.m.crossplane.io -o jsonpath='{range .items[*]}{.metadata.name} {.status.conditions[?(@.type=="Synced")].status}{"\n"}{end}'
team-c-dev-91fc2ee050c5 True
team-c-dev-f9c700a787d0 True
team-f-dev-6d59b5cab364 True
team-f-dev-933e06392888 True
$ kubectl patch appenvironment team-c-dev --type merge -p '{"spec":{"cpuQuota":"4"}}'
appenvironment.platform.lab.local/team-c-dev patchedSynced False with a forbidden message naming the provider's ServiceAccount, while the XR above it just looks unhappy. Always read the composed resource, not the composite, when the composite is vague.managementPolicies: ["Observe"] reads an existing object into Crossplane's model without creating, updating or deleting it. It is how you bring brownfield infrastructure under a platform API without risking it.
kubectl create ns observed-only --dry-run=client -o yaml | kubectl apply -f -
kubectl label ns observed-only owner=not-crossplane --overwrite
kubectl apply -f - <<'EOF'
apiVersion: kubernetes.crossplane.io/v1alpha2
kind: Object
metadata:
name: observe-ns
annotations:
crossplane.io/external-name: observed-only
spec:
managementPolicies: ["Observe"]
providerConfigRef: { name: default }
forProvider:
manifest:
apiVersion: v1
kind: Namespace
metadata: { name: observed-only }
EOF
sleep 30
kubectl get object observe-ns -o jsonpath='{.status.atProvider.manifest.metadata.labels}{"\n"}'
kubectl get object observe-ns -o jsonpath='{.status.conditions}' | jq '.[] | {type, status, reason}'
kubectl get ns observed-only -o jsonpath='{.metadata.ownerReferences}{"\n"}'
kubectl delete object observe-ns
sleep 10
kubectl get ns observed-only
kubectl delete ns observed-onlyoutputcaptured 2026-09-12
$ kubectl create ns observed-only --dry-run=client -o yaml | kubectl apply -f -
namespace/observed-only created
$ kubectl label ns observed-only owner=not-crossplane --overwrite
namespace/observed-only labeled
$ kubectl apply -f - <<'EOF'
apiVersion: kubernetes.crossplane.io/v1alpha2
kind: Object
metadata:
name: observe-ns
annotations:
crossplane.io/external-name: observed-only
spec:
managementPolicies: ["Observe"]
providerConfigRef: { name: default }
forProvider:
manifest:
apiVersion: v1
kind: Namespace
metadata: { name: observed-only }
EOF
object.kubernetes.crossplane.io/observe-ns created
$ sleep 30
$ kubectl get object observe-ns -o jsonpath='{.status.atProvider.manifest.metadata.labels}{"\n"}'
{"kubernetes.io/metadata.name":"observed-only","owner":"not-crossplane"}
$ kubectl get object observe-ns -o jsonpath='{.status.conditions}' | jq '.[] | {type, status, reason}'
{
"type": "Synced",
"status": "True",
"reason": "ReconcileSuccess"
}
{
"type": "Ready",
"status": "True",
"reason": "Available"
}
$ kubectl get ns observed-only -o jsonpath='{.metadata.ownerReferences}{"\n"}'
$ kubectl delete object observe-ns
object.kubernetes.crossplane.io "observe-ns" deleted
$ sleep 10
$ kubectl get ns observed-only
NAME STATUS AGE
observed-only Active 44s
$ kubectl delete ns observed-only
namespace "observed-only" deletedstatus.atProvider reflects the live namespace including the label Crossplane did not set, the namespace has no ownerReference, and deleting the Object leaves it alone. That last line is the whole safety property.A platform API is a production system, and its controllers export metrics like any other. Wiring one PodMonitor turns "is Crossplane healthy" from a feeling into a query.
kubectl -n crossplane-system get pods -l pkg.crossplane.io/provider -o wide
kubectl apply -f - <<'EOF'
apiVersion: monitoring.coreos.com/v1
kind: PodMonitor
metadata: { name: crossplane-providers, namespace: crossplane-system, labels: { release: prometheus } }
spec:
selector:
matchExpressions:
- { key: pkg.crossplane.io/provider, operator: Exists }
podMetricsEndpoints:
- port: metrics
EOF
sleep 60
kubectl -n monitoring port-forward svc/prometheus-kube-prometheus-prometheus 9090:9090 & PF1=$!
sleep 5
curl -sG localhost:9090/api/v1/query --data-urlencode 'query=up{job=~".*provider.*"}' | jq '.data.result[] | {metric: .metric.pod, value: .value[1]}'
curl -sG localhost:9090/api/v1/query --data-urlencode 'query=crossplane_managed_resource_ready' | jq '.data.result | length'
kill $PF1
kubectl -n crossplane-system delete podmonitor crossplane-providers --ignore-not-foundoutputcaptured 2026-09-12
$ kubectl -n crossplane-system get pods -l pkg.crossplane.io/provider -o wide
NAME READY STATUS RESTARTS AGE IP NODE NOMINATED NODE READINESS GATES
provider-helm-fdbfef781539-755f57bf99-jvrrp 1/1 Running 0 21h 10.244.2.2 cnpe-worker2 <none> <none>
provider-kubernetes-17261e579786-54cfdf7bf8-ffp75 1/1 Running 0 21h 10.244.2.37 cnpe-worker2 <none> <none>
$ kubectl apply -f - <<'EOF'
apiVersion: monitoring.coreos.com/v1
kind: PodMonitor
metadata: { name: crossplane-providers, namespace: crossplane-system, labels: { release: prometheus } }
spec:
selector:
matchExpressions:
- { key: pkg.crossplane.io/provider, operator: Exists }
podMetricsEndpoints:
- port: metrics
EOF
podmonitor.monitoring.coreos.com/crossplane-providers unchanged
$ sleep 60
$ kubectl -n monitoring port-forward svc/prometheus-kube-prometheus-prometheus 9090:9090 & PF1=$!
$ sleep 5
Forwarding from 127.0.0.1:9090 -> 9090
Forwarding from [::1]:9090 -> 9090
$ curl -sG localhost:9090/api/v1/query --data-urlencode 'query=up{job=~".*provider.*"}' | jq '.data.result[] | {metric: .metric.pod, value: .value[1]}'
Handling connection for 9090
{
"metric": "provider-kubernetes-17261e579786-54cfdf7bf8-ffp75",
"value": "1"
}
{
"metric": "provider-helm-fdbfef781539-755f57bf99-jvrrp",
"value": "1"
}
$ curl -sG localhost:9090/api/v1/query --data-urlencode 'query=crossplane_managed_resource_ready' | jq '.data.result | length'
Handling connection for 9090
4
$ kill $PF1
$ kubectl -n crossplane-system delete podmonitor crossplane-providers --ignore-not-found
podmonitor.monitoring.coreos.com "crossplane-providers" deleted from crossplane-system namespaceSelf-check
Name the three objects and who owns each.
XRD (platform team: the promised API and its schema), Composition (platform team: the recipe, changeable without touching the API), XR (developer: an instance). The generated CRD is Crossplane's doing, not yours, which is the whole labor saving.
An XR is stuck not-Ready and the message is unhelpful. Diagnostic order?
Packages first (providers, functions Installed + Healthy), then crossplane resource trace, then the composed resources' own conditions, then the underlying API error inside them. Bottom-up, because that is the direction failure propagates.
Why can a namespaced XR not compose a cluster-scoped resource directly?
Ownership rules: a namespaced object cannot own a cluster-scoped one, and Crossplane relies on ownerReferences for lifecycle. The workaround the lab uses is a namespaced Object (provider-kubernetes) that contains the cluster-scoped manifest, authenticated with a ClusterProviderConfig.
You edit a Composition. What happens to the twelve XRs already using it?
They all reconcile to the new recipe, without anyone touching them: powerful, and a wide blast radius. If that matters, pin instances to a composition revision or version the composition and migrate deliberately.
When would you choose kro or a plain operator over Crossplane?
kro when you want the same declarative expansion with far less machinery and can accept a young API (3.6). A plain operator when the value is domain behavior over time (failover, backup, upgrade orchestration) rather than composing existing resources. Crossplane's sweet spot is composing many things, including external cloud APIs, behind one abstraction.
An XR shows Synced=True, Ready=False. Another shows Synced=False. What is each telling you?
Synced=False: Crossplane could not render or apply the composition (unknown function, bad patch path, forbidden, invalid manifest); the message on the XR names it and the composed resources may not exist yet. Synced=True, Ready=False: the recipe applied, but a composed resource is not ready; descend into that resource's own conditions (crossplane resource trace draws the tree). The first is a platform-team bug, the second is usually the provider or the external system.
You must import an existing cloud database into Crossplane without risking any change to it. Fields?
A managed resource with spec.managementPolicies: ["Observe"] and the crossplane.io/external-name annotation set to the real resource's identifier. Crossplane populates status.atProvider and never creates, updates or deletes. Later you can widen the policy (add Update, Delete) to take over management step by step.
Which one field on the XRD makes every instance use one specific Composition regardless of what the XR asks for, and what happens to compositionRef on the XR?
spec.enforcedCompositionRef.name. It overrides compositionRef, compositionSelector and defaultCompositionRef; Crossplane sets the XR's compositionRef to the enforced Composition. defaultCompositionRef only fills a blank at creation time and never overrides an explicit choice.
A provider shows HEALTHY: False. Name the three usual causes and where each is visible.
Image pull (a private registry without packagePullSecrets): visible on the revision's conditions and the provider pod events. Unsatisfied dependency or Crossplane version constraint: the ProviderRevision condition reason UnhealthyPackageRevision and the Lock object. The provider pod crashing: plain pod diagnosis in crossplane-system. If the package is healthy but MRs are Synced=False with forbidden, the problem is RBAC, not the package.
Docs to know your way around
- docs.crossplane.io: Composite Resource Definitions, Compositions (the function pipeline page), and provider-kubernetes's
Objectdocumentation on the Upbound marketplace. - Offline:
crossplane resource trace --help, the single most useful diagnostic in the ecosystem, pluskubectl explain composition.specandkubectl explain xrd.spec. - docs.crossplane.io/latest/composition/composite-resource-definitions (scope, versions, default and enforced composition refs) and /composition/composition-revisions (Automatic vs Manual): the two pages behind most "make the XR do X" tasks.
- docs.crossplane.io/latest/managed-resources/managed-resources: the managementPolicies table, and /guides/import-existing-resources for observe-only import.
- Offline:
kubectl get xrd(ESTABLISHED column),kubectl get managed,kubectl get compositionrevisions, andcrossplane --helpto learn whether render and trace live underbetaon the exam's version.