Composition functions
Real logic in compositions.
A classic Crossplane Composition behaved like a fill-in-the-blank form. You listed a fixed set of resources, then drew little arrows called patches that pulled a value out of the claim (the short request a developer files to ask for infrastructure) and dropped it into a slot on each resource. That works right up until you need the form to think. One subnet for each availability zone (a separate datacenter inside a cloud region)? A form cannot count. Create the database only when a flag is set? A form cannot decide. The shape is frozen the moment you write it, and every real platform eventually needs a shape that bends to its input.
Composition functions replace the form with an assembly line. Every station on the line is a small program. A tray rides down the belt carrying two things: the observed state (what actually exists in the cloud right now) and the desired state (everything the earlier stations have asked to build so far). Each station reads the tray, adds or edits resources, and passes it along. Whatever sits on the tray when it reaches the end is the full set of resources Crossplane hands to the providers to create for real. The Composition stops being a template and becomes a program you control.
A Pipeline Of Small Programs
You opt in by setting mode: Pipeline on the Composition and listing steps top to bottom. Each step names an installed Function. A Function is packaged and installed exactly like a provider: it ships as an OCI image (Open Container Initiative, the standard format for a container image), and Crossplane runs it as a long-lived pod (a running container managed by Kubernetes) that speaks gRPC (a way for one program to call a function inside another over the network). Reconcile is the loop where Crossplane keeps checking what exists against what you asked for. On every pass it sends each step a RunFunctionRequest: a message that carries the observed composite resource (the XR, your one high-level object, like 'a network') plus the managed resources already created under it (the real cloud objects Crossplane tracks on your behalf). The step reads that and answers with the desired state it wants. Because the steps run in order and the desired state piles up as it goes, step two sees exactly what step one produced, and can build on it or overwrite it.
# functions.yaml - Functions install like providersapiVersion: pkg.crossplane.io/v1kind: Functionmetadata:name: function-patch-and-transformspec:package: xpkg.upbound.io/crossplane-contrib/function-patch-and-transform:v0.8.0---apiVersion: pkg.crossplane.io/v1kind: Functionmetadata:name: function-go-templatingspec:package: xpkg.upbound.io/crossplane-contrib/function-go-templating:v0.9.2
Apply the manifest and Crossplane pulls each image, starts its pod, and marks the Function HEALTHY once the gRPC server answers. Check that before you build anything on top of it. A Function stuck on INSTALLED: False almost always means a bad image reference or a registry your cluster cannot reach.
kubectl apply -f functions.yamlkubectl get functions
function.pkg.crossplane.io/function-patch-and-transform createdfunction.pkg.crossplane.io/function-go-templating createdNAME INSTALLED HEALTHY PACKAGE AGEfunction-go-templating True True xpkg.upbound.io/crossplane-contrib/function-go-templating:v0.9.2 38sfunction-patch-and-transform True True xpkg.upbound.io/crossplane-contrib/function-patch-and-transform:v0.8.0 38s
function-patch-and-transform does the old field-copying job, now as one station on the line instead of the whole factory. Here is a one-step pipeline that builds a VPC (virtual private cloud, your own isolated network inside the cloud) and copies the claim's CIDR (Classless Inter-Domain Routing, the a.b.c.d/nn notation for an address range) onto it.
# composition.yaml - the pipeline replaces the old spec.resources listapiVersion: apiextensions.crossplane.io/v1kind: Compositionmetadata:name: xnetworks.example.orgspec:compositeTypeRef:apiVersion: example.org/v1alpha1kind: XNetworkmode: Pipelinepipeline:- step: patch-and-transformfunctionRef:name: function-patch-and-transform # matches the Function name aboveinput:apiVersion: pt.fn.crossplane.io/v1beta1kind: Resourcesresources:- name: vpcbase:apiVersion: ec2.aws.upbound.io/v1beta1kind: VPCspec:forProvider:region: us-east-1cidrBlock: 10.0.0.0/16patches:- type: FromCompositeFieldPathfromFieldPath: spec.cidr # copy the claim's cidr...toFieldPath: spec.forProvider.cidrBlock # ...onto the VPC
Real Logic: Loops And Branches
The reason to reach for functions is the thing a form can never do: dynamic counts and real branching. function-go-templating hands you a Go text/template (the same templating language Helm, Kubernetes' package manager, uses) with the whole request in scope. You can range over a list in the claim to fan out one Subnet (a slice of the VPC's address range, its own smaller network) per availability zone, or wrap a resource in an if so it only appears when a flag is set. go-templating is one choice among several: there is also function-kcl (a compact configuration language), a Python SDK for writing a function in real code, and function-cel-filter for dropping desired resources with a CEL expression (Common Expression Language, a small language for boolean rules). Pick whichever fits the step and chain them. Add the subnet step second and it sees the VPC the first step already put on the tray.
# composition.yaml - add this second step under pipeline:- step: render-subnetsfunctionRef:name: function-go-templatinginput:apiVersion: gotemplating.fn.crossplane.io/v1beta1kind: GoTemplatesource: Inlineinline:template: |{{- $xr := .observed.composite.resource }}{{- range $i, $az := $xr.spec.zones }}---apiVersion: ec2.aws.upbound.io/v1beta1kind: Subnetmetadata:annotations:# index-based name is stable across reconcilesgotemplating.fn.crossplane.io/composition-resource-name: subnet-{{ $i }}spec:forProvider:region: {{ $xr.spec.region }}availabilityZone: {{ $az }}cidrBlock: 10.0.{{ $i }}.0/24{{- end }}
Look closely at that annotation. Every object a function renders has to carry a name that stays the same run after run, because that name is the identity Crossplane uses to match a freshly rendered object to the managed resource it already created last time. In go-templating you set it with gotemplating.fn.crossplane.io/composition-resource-name; in the render output you will see it flattened to the plain crossplane.io/composition-resource-name. Keep it stable and Crossplane updates the same VPC and the same three subnets on every reconcile. Let it drift and you get a very expensive kind of bug, which is exactly what the next box is about.
crossplane render run twice is the cheapest proof the names hold still.See It Before The Cluster Does
Because a function is a gRPC contract behind a container image, you can run the entire pipeline on your laptop before it ever touches a cluster. crossplane render takes three files: an example composite, the Composition, and the Functions manifest. It pulls each function image, runs it locally with Docker, calls it over gRPC, and prints the exact managed resources the pipeline would produce. No apply. No waiting on reconciliation. No cloud API calls and no bill. It is the fastest feedback loop Crossplane has, and the right place to catch a broken template or a dropped resource.
# xr.yaml - an example XNetwork to feed the pipelineapiVersion: example.org/v1alpha1kind: XNetworkmetadata:name: platform-netspec:region: us-east-1cidr: 10.0.0.0/16zones:- us-east-1a- us-east-1b- us-east-1c
crossplane render xr.yaml composition.yaml functions.yaml
---apiVersion: example.org/v1alpha1kind: XNetworkmetadata:name: platform-net---apiVersion: ec2.aws.upbound.io/v1beta1kind: Subnetmetadata:annotations:crossplane.io/composition-resource-name: subnet-0generateName: platform-net-labels:crossplane.io/composite: platform-netspec:forProvider:availabilityZone: us-east-1acidrBlock: 10.0.0.0/24region: us-east-1---apiVersion: ec2.aws.upbound.io/v1beta1kind: Subnetmetadata:annotations:crossplane.io/composition-resource-name: subnet-1generateName: platform-net-labels:crossplane.io/composite: platform-netspec:forProvider:availabilityZone: us-east-1bcidrBlock: 10.0.1.0/24region: us-east-1---apiVersion: ec2.aws.upbound.io/v1beta1kind: Subnetmetadata:annotations:crossplane.io/composition-resource-name: subnet-2generateName: platform-net-labels:crossplane.io/composite: platform-netspec:forProvider:availabilityZone: us-east-1ccidrBlock: 10.0.2.0/24region: us-east-1---apiVersion: ec2.aws.upbound.io/v1beta1kind: VPCmetadata:annotations:crossplane.io/composition-resource-name: vpcgenerateName: platform-net-labels:crossplane.io/composite: platform-netspec:forProvider:cidrBlock: 10.0.0.0/16region: us-east-1
Read that output like a diff of your intent: three zones went in, three subnets came out, named subnet-0 through subnet-2, each carrying the stable annotation and a generateName Crossplane will turn into a real cluster name. The VPC sorts last because render prints composed resources in name order, not pipeline order, so the same input always yields byte-for-byte the same output. Two flags turn render into a proper test bench. --observed-resources=observed/ feeds the pipeline a directory of live cluster state, so you can see how it reacts to resources that already exist instead of always starting from blank. And when a step hits a fatal error, a broken template, a missing field, render fails loudly and names the step that broke instead of leaving a half-built stack in your control plane. Put the command in CI (continuous integration, the automated checks that run on every pull request; the runner needs Docker, since render starts each function as a container) so a bad Composition fails the pull request, not production.
# render a version whose template is missing its {{- end }}crossplane render xr.yaml composition-broken.yaml functions.yaml
crossplane: error: cannot render composite resource: pipeline step "render-subnets" returned a fatal result: cannot parse the provided templates: template: :15: unexpected EOF
You Are Running Someone Else's Code
A function is code, not configuration. It comes from a registry, runs as a pod inside your control plane, and on every reconcile it gets handed your composite's full state and decides what infrastructure should exist. A friendly function fans out subnets. A backdoored one could quietly add an IAM role (identity and access management, the cloud's who-can-do-what system) with admin rights, or open a security group to the whole internet, on every composite it touches, and your YAML would look completely normal. These functions are live workloads sitting in your cluster. You can see them. That means you can watch them, and you should.
kubectl get deploy -n crossplane-system
NAME READY UP-TO-DATE AVAILABLE AGEcrossplane 1/1 1 1 12dcrossplane-rbac-manager 1/1 1 1 12dfunction-go-templating-7c9f4b2a1d 1/1 1 1 4mfunction-patch-and-transform-5b8e3c6f90 1/1 1 1 4m
Harden the pipeline the way you would any dependency you run in production. Pin each package by digest (the @sha256:... form) rather than a moving tag like :v0.9.2, so the image cannot change under you between deploys. Read the source of a function before you install it, and prefer the public, reviewed crossplane-contrib ones over a random registry. Run crossplane render in CI and diff its output on every change, so a step that suddenly emits an extra IAM role shows up in the pull request as added lines a human has to approve. Watch the managed-resource count in the cluster: a pipeline producing more resources than the claim asked for is either a bug or an attack, and the two look identical until you go read the render diff. Function results also surface on the XR itself, so kubectl describe on a composite shows you what each step decided and warned about on the last reconcile.
subnet-{{ randAlphaNum 5 }} instead of subnet-{{ $i }}. What happens on the second reconcile?mode: Pipeline Composition with two steps, how does the second step relate to the first on a single reconcile?:latest.Before you push a Composition, run crossplane render on it twice with the same input and diff the two outputs. If a single resource name moves between runs, fix it now, while it costs you nothing but a second look, instead of later when it costs you a pile of orphaned subnets and the afternoon you spend hunting them down.
Try this
Run kubectl apply -f functions.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: unstable names silently leak real infrastructure. Check that on your own systems before you need to, because it is cheaper to find on a quiet afternoon than during an incident.