Composite resources & compositions
Bundle many resources into one API.
Order a combo meal and you say one thing, "number three," and the kitchen assembles a burger, fries, and a drink out of sight. You didn't order three items. You ordered one, and the bundle knew what it held.
A Crossplane Composite Resource, or XR (a single custom Kubernetes object that stands in for a whole group of real cloud resources), is that combo. Crossplane itself is an add-on that turns a Kubernetes cluster into a control plane for cloud infrastructure. The previous lesson showed the Managed Resource, or MR (a Kubernetes object that maps one-to-one onto a single real cloud resource, like one database or one bucket). Real platforms are never one resource, though. A usable database wants storage, a subnet group (which network segments it lives in), a parameter group, and access rules, and all of those have to agree. Hand every developer those four pieces to wire up by hand and you have given each of them four chances to misconfigure production. Give them one XR instead, and a recipe fans it out into the real resources underneath.
One object that stands for many
An XR is a custom Kubernetes object whose entire job is to represent a bundle. You apply one XR. Crossplane creates and owns the many managed resources it composes. The XR carries the caller's intent in spec.parameters, a small set of knobs, and nothing else. It does not know it is AWS. It does not know what a subnet group is. That knowledge lives in the recipe, not in the request.
The type XPostgresInstance does not exist until you register it with a Composite Resource Definition, or XRD (the object that publishes your new API and the fields it allows). That is the next lesson. For now, assume the type is registered and look at what one instance costs the person asking for it: almost nothing.
# XPostgresInstance is a type your XRD publishes (next lesson).apiVersion: platform.example.org/v1alpha1kind: XPostgresInstancemetadata:name: orders-dbspec:parameters:storageGB: 50 # a knob the caller turnsregion: us-east-1 # another onecompositionRef:name: xpostgres.aws.platform.example.org # which recipe to use
That is the whole request. Two parameters and a pointer at a recipe. Everything hard is about to happen somewhere the caller never sees.
The Composition is the recipe
A Composition is a cluster-scoped object (it lives at the level of the whole cluster, not inside one namespace) that answers two questions. Which XR type does it serve? That is compositeTypeRef. And what real resources should it render? Modern Crossplane answers the second question by running a pipeline of functions rather than a fixed list, the same way a kitchen runs a line of stations instead of one cook doing everything.
The workhorse function for plain mapping is function-patch-and-transform (a Crossplane Function package you install once). It takes a base template for each managed resource, then patches values from the XR onto that base at render time. The base is the MR exactly as the earlier lesson taught it. The patches copy fields off the composite and drop them into the base. Loops, conditionals, and generating a variable number of resources are what full composition functions are for, and that is a later lesson. Here you template each MR and wire the caller's parameters in.
apiVersion: apiextensions.crossplane.io/v1kind: Compositionmetadata:name: xpostgres.aws.platform.example.orgspec:compositeTypeRef:apiVersion: platform.example.org/v1alpha1kind: XPostgresInstancemode: Pipelinepipeline:- step: patch-and-transformfunctionRef:name: function-patch-and-transform # installed as a Function packageinput:apiVersion: pt.fn.crossplane.io/v1beta1kind: Resourcesresources:- name: rdsbase:apiVersion: rds.aws.upbound.io/v1beta1kind: Instancespec:forProvider:engine: postgresinstanceClass: db.t3.microallocatedStorage: 20 # overwritten by the patch belowpatches:- type: FromCompositeFieldPathfromFieldPath: spec.parameters.storageGBtoFieldPath: spec.forProvider.allocatedStoragepolicy:fromFieldPath: Required # block this MR if the field is missing- type: FromCompositeFieldPathfromFieldPath: spec.parameters.regiontoFieldPath: spec.forProvider.region- name: subnet-groupbase:apiVersion: rds.aws.upbound.io/v1beta1kind: SubnetGroupspec:forProvider:region: us-east-1description: managed by crossplane# subnetIds / subnetIdSelector trimmed for brevity
Read the rds resource top to bottom. The base says every database this recipe makes is Postgres on a db.t3.micro with 20 GB. The first patch then overwrites that 20 with whatever the caller put in storageGB, so orders-db comes up at 50. The second patch copies the region across. FromCompositeFieldPath means read from the XR, write onto the child. The policy.fromFieldPath: Required line on the storage patch is the interesting one, and we come back to why it matters.
Watching one become many
Apply the XR:
kubectl apply -f orders-db.yaml
xpostgresinstance.platform.example.org/orders-db created
The interesting part is what you did not have to write. That one object turns into a tree, and the cleanest way to see the tree is the Crossplane command-line tool.
crossplane resource trace xpostgresinstance orders-db
NAME SYNCED READY STATUSXPostgresInstance/orders-db True True Available├─ Instance/orders-db-x7fq2 True True Available└─ SubnetGroup/orders-db-9dk4m True True Available
One XR, two managed resources beneath it, each with its own generated name. SYNCED means Crossplane reconciled the object with the provider. READY means the real cloud resource reports itself usable. You can get the same children with plain kubectl if you do not have the crossplane CLI installed.
kubectl get managed
NAME SYNCED READY EXTERNAL-NAME AGEinstance.rds.aws.upbound.io/orders-db-x7fq2 True True orders-db-x7fq2 4msubnetgroup.rds.aws.upbound.io/orders-db-9dk4m True True orders-db-9dk4m 4m
For an operator this listing is a live inventory. Every real cloud resource Crossplane owns shows up here, with a column telling you whether it is in sync. A row stuck at SYNCED=False means the last reconcile failed: wrong or expired credentials, a missing IAM permission (Identity and Access Management, the cloud's own access-control system), or a field the cloud API rejected. A managed resource that shows up with no XR above it is worth a hard look, because someone created cloud infrastructure outside the recipe, which is exactly the blind spot a Composition is supposed to remove.
One owner, one delete
Here is the part that matters at 2 a.m. Every managed resource the recipe made carries an owner reference (a small pointer in its metadata that says this object belongs to that one). The owner is the XR. Look at one child:
kubectl get instance.rds.aws.upbound.io orders-db-x7fq2 \-o jsonpath='{range .metadata.ownerReferences[*]}{.kind}{" "}{.name}{"\n"}{end}'
XPostgresInstance orders-db
Kubernetes uses that pointer for garbage collection (its built-in cleanup of objects whose owner is gone). Delete the one XR and every child it owns is deleted with it. No orphaned database quietly billing you for a year. No subnet group nobody remembers creating. The blast radius of a cleanup is exactly the bundle, and it is exactly the bundle you can see.
That same owner reference is your audit trail. Ask any cloud resource in the cluster what created it and the answer is the XR, and the XR spec shows exactly which parameters were requested. Pair that with the cluster audit log (Kubernetes' record of who changed what, where it is enabled) and you can walk a resource back to the person who applied it. When an attacker or a careless script spins something up, the first question a defender asks is what owns this, and was it supposed to exist. The owner reference answers the first half in one field.
Choosing the recipe, and rolling it out
Many recipes can serve the same XR type. An AWS recipe, a GCP recipe, a cheap-dev recipe that skips the expensive parts. The XR picks in one of two ways. Pin one by name with compositionRef, which is what the first example did. Or describe the recipe you want with compositionSelector and let Crossplane match by labels.
spec:# Option A: match by labels instead of a hard-coded namecompositionSelector:matchLabels:provider: awstier: standard# Option B: pin a specific frozen revision, update on your termscompositionUpdatePolicy: Manual # default is AutomaticcompositionRevisionRef:name: xpostgres.aws.platform.example.org-a1b2c3d
Behind the scenes Crossplane never lets an XR point straight at a mutable Composition. Every time you save a Composition, Crossplane freezes a copy as a CompositionRevision (an immutable, numbered snapshot), the way a version history keeps every past draft of a document. The XR runs against a revision, not the live object. Edit the recipe and you get a new revision beside the old one:
kubectl get compositionrevision
NAME REVISION XR-KIND XR-APIVERSION AGExpostgres.aws.platform.example.org-a1b2c3d 1 XPostgresInstance platform.example.org/v1alpha1 18mxpostgres.aws.platform.example.org-e4f5a6b 2 XPostgresInstance platform.example.org/v1alpha1 90s
compositionUpdatePolicy decides what an XR does with that new revision 2. The default, Automatic, moves every XR onto the newest revision as soon as it exists. Set it to Manual with a compositionRevisionRef and the XR stays pinned until you move it by hand. That is how you promote a recipe change to one database first, watch it, and only then let the fleet follow. It is the platform version of a canary release (ship to one, prove it, then ship to all).
Why Required on a patch is a safety line
Go back to policy.fromFieldPath: Required on the storage patch. The default for a patch is Optional, and Optional is quietly dangerous. If the source path is wrong or missing, an Optional patch does nothing at all. The child resource comes up anyway, using the base default. So a database you asked to be 50 GB comes up at 20, nothing errors, and you find out from a bill or an outage. Required flips that. If the field is missing, Crossplane fails the render and reports it, so the mistake surfaces at apply time instead of in production. Put Required on any patch whose value you cannot afford to have silently skipped.
Before you touch a Composition in a live cluster, run kubectl get xpostgresinstance and read the COMPOSITION column. Every row pointing at the recipe you are about to change is a real resource your next save reconciles into. If that is more than one and you are not certain of the edit, move the ones you cannot afford to break onto compositionUpdatePolicy: Manual first, then save.
Try this
Run kubectl apply -f orders-db.yaml on a scratch host or disposable cluster and read the output against what this lesson described. Then change one input so it fails, and re-run: the error you get is the one you will meet in production.
Takeaway
The trap worth remembering here: a Composition edit ships to every consumer at once. Check that on your own systems before you need to, because it is cheaper to find on a quiet afternoon than during an incident.