CoursesCrossplaneComposition functions

Composition functions

Real logic in compositions.

Advanced14 min · lesson 9 of 12

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.yaml - Functions install like providers
apiVersion: pkg.crossplane.io/v1
kind: Function
metadata:
name: function-patch-and-transform
spec:
package: xpkg.upbound.io/crossplane-contrib/function-patch-and-transform:v0.8.0
---
apiVersion: pkg.crossplane.io/v1
kind: Function
metadata:
name: function-go-templating
spec:
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.

terminal
kubectl apply -f functions.yaml
kubectl get functions
output
function.pkg.crossplane.io/function-patch-and-transform created
function.pkg.crossplane.io/function-go-templating created
NAME INSTALLED HEALTHY PACKAGE AGE
function-go-templating True True xpkg.upbound.io/crossplane-contrib/function-go-templating:v0.9.2 38s
function-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
# composition.yaml - the pipeline replaces the old spec.resources list
apiVersion: apiextensions.crossplane.io/v1
kind: Composition
metadata:
name: xnetworks.example.org
spec:
compositeTypeRef:
apiVersion: example.org/v1alpha1
kind: XNetwork
mode: Pipeline
pipeline:
- step: patch-and-transform
functionRef:
name: function-patch-and-transform # matches the Function name above
input:
apiVersion: pt.fn.crossplane.io/v1beta1
kind: Resources
resources:
- name: vpc
base:
apiVersion: ec2.aws.upbound.io/v1beta1
kind: VPC
spec:
forProvider:
region: us-east-1
cidrBlock: 10.0.0.0/16
patches:
- type: FromCompositeFieldPath
fromFieldPath: 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
# composition.yaml - add this second step under pipeline:
- step: render-subnets
functionRef:
name: function-go-templating
input:
apiVersion: gotemplating.fn.crossplane.io/v1beta1
kind: GoTemplate
source: Inline
inline:
template: |
{{- $xr := .observed.composite.resource }}
{{- range $i, $az := $xr.spec.zones }}
---
apiVersion: ec2.aws.upbound.io/v1beta1
kind: Subnet
metadata:
annotations:
# index-based name is stable across reconciles
gotemplating.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.

Unstable names silently leak real infrastructure
The composition-resource-name is how Crossplane correlates a rendered object with the managed resource it already built. Key it off a timestamp, a random suffix, or unordered map iteration and every reconcile looks brand new: Crossplane creates a duplicate and orphans the old one, quietly burning cloud spend and leaving live resources nobody is watching. Derive the name from stable input (a loop index, a field on the claim) and range over slices, not maps, when you need ordered, repeatable output. 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
# xr.yaml - an example XNetwork to feed the pipeline
apiVersion: example.org/v1alpha1
kind: XNetwork
metadata:
name: platform-net
spec:
region: us-east-1
cidr: 10.0.0.0/16
zones:
- us-east-1a
- us-east-1b
- us-east-1c
terminal
crossplane render xr.yaml composition.yaml functions.yaml
output
---
apiVersion: example.org/v1alpha1
kind: XNetwork
metadata:
name: platform-net
---
apiVersion: ec2.aws.upbound.io/v1beta1
kind: Subnet
metadata:
annotations:
crossplane.io/composition-resource-name: subnet-0
generateName: platform-net-
labels:
crossplane.io/composite: platform-net
spec:
forProvider:
availabilityZone: us-east-1a
cidrBlock: 10.0.0.0/24
region: us-east-1
---
apiVersion: ec2.aws.upbound.io/v1beta1
kind: Subnet
metadata:
annotations:
crossplane.io/composition-resource-name: subnet-1
generateName: platform-net-
labels:
crossplane.io/composite: platform-net
spec:
forProvider:
availabilityZone: us-east-1b
cidrBlock: 10.0.1.0/24
region: us-east-1
---
apiVersion: ec2.aws.upbound.io/v1beta1
kind: Subnet
metadata:
annotations:
crossplane.io/composition-resource-name: subnet-2
generateName: platform-net-
labels:
crossplane.io/composite: platform-net
spec:
forProvider:
availabilityZone: us-east-1c
cidrBlock: 10.0.2.0/24
region: us-east-1
---
apiVersion: ec2.aws.upbound.io/v1beta1
kind: VPC
metadata:
annotations:
crossplane.io/composition-resource-name: vpc
generateName: platform-net-
labels:
crossplane.io/composite: platform-net
spec:
forProvider:
cidrBlock: 10.0.0.0/16
region: 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.

terminal
# render a version whose template is missing its {{- end }}
crossplane render xr.yaml composition-broken.yaml functions.yaml
output
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.

terminal
kubectl get deploy -n crossplane-system
output
NAME READY UP-TO-DATE AVAILABLE AGE
crossplane 1/1 1 1 12d
crossplane-rbac-manager 1/1 1 1 12d
function-go-templating-7c9f4b2a1d 1/1 1 1 4m
function-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.

One reconcile through the pipeline
1Observed state
the XR plus its existing managed resources
2RunFunctionRequest
Crossplane sends that state into the pipeline
3Step 1: patch-and-transform
desires the VPC from the claim's cidr
4Step 2: go-templating
ranges spec.zones, desires N subnets
5Accumulated desired state
VPC + subnets, each with a stable name
6Providers reconcile
real cloud resources created or updated
Each step reads the tray and adds to it; the last step's output is what providers build.
Quick check
01You change your go-templating step to name each subnet subnet-{{ randAlphaNum 5 }} instead of subnet-{{ $i }}. What happens on the second reconcile?
Incorrect — Crossplane correlates by the composition-resource-name annotation, not cloud IDs, so a new name reads as a new resource.
Correct — The name is the identity across reconciles; a random name every run makes each subnet look brand new, leaking infrastructure and cost.
Incorrect — Random alphanumeric names are valid strings; nothing rejects them, so the damage is silent rather than a hard failure.
Incorrect — Crossplane does not consolidate resources by shape; the unmatched old ones are orphaned, not cleaned up.
02In a mode: Pipeline Composition with two steps, how does the second step relate to the first on a single reconcile?
Incorrect — the desired state accumulates as the pipeline runs; step two receives what step one produced.
Incorrect — a pipeline is sequential, not try/catch; every step runs each reconcile.
Incorrect — the lesson stresses steps run in order precisely so a later one can build on an earlier one's output.
Correct — this ordered accumulation is exactly how the lesson describes the pipeline.
03You maintain pipeline Compositions that pull in third-party functions and want any change that makes one emit an extra IAM (identity and access management) role caught before it reaches the cluster. Which practice does the lesson recommend?
Incorrect — the lesson warns a backdoored function decides infrastructure while your YAML looks completely normal.
Incorrect — HEALTHY only means the function's server is answering; a healthy function can still emit whatever it wants.
Correct — the lesson recommends exactly this so a step that suddenly emits an extra role shows up in the pull request.
Incorrect — a moving tag lets the image change under you; the lesson says pin by digest, the opposite of chasing :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.

Related