Skip to main content

Crossplane Ep 11: Writing Composition Functions in Go

Rachmat Hidayat
Author
Rachmat Hidayat
Learn & sharing insights on TypeScript, Go, Kubernetes, DevOps, DevSecOps, SRE, Platform Engineering, AI/ML Engineering, and MLOps.
crossplane - This article is part of a series.
Part 11: This Article
The true power of Crossplane is unleashed when you write your own Composition Functions. By using Go, you gain access to for loops, strict type safety, HTTP clients for calling external APIs, and the entire Go ecosystem. Let’s write a function that takes subnetCount: 3 and dynamically outputs 3 Subnet MRs.

1. The Anatomy of a Function
#

A Crossplane Composition Function is a standard gRPC server. Crossplane sends a RunFunctionRequest containing the current state of the XR and any existing MRs. Your function reads the request, executes its logic, and returns a RunFunctionResponse containing the new desired MRs.

Because writing gRPC servers from scratch is tedious, Upbound provides a highly abstracted Go SDK.


2. Setting Up the Go Project
#

First, we need to initialize a new Go project and install the Crossplane Function SDK.

mkdir function-dynamic-subnets
cd function-dynamic-subnets
go mod init github.com/acmecorp/function-dynamic-subnets

# Install the Crossplane Function SDK
go get github.com/crossplane/function-sdk-go

3. Writing the Go Logic
#

Create a file named main.go. This file will contain our gRPC server and the mutation logic.

package main

import (
	"context"
	"fmt"
	"log"

	"github.com/crossplane/function-sdk-go/proto/v1beta1"
	"github.com/crossplane/function-sdk-go/request"
	"github.com/crossplane/function-sdk-go/response"
	"github.com/crossplane/function-sdk-go"
)

// RunFunction is the entrypoint that Crossplane calls.
func RunFunction(ctx context.Context, req *v1beta1.RunFunctionRequest) (*v1beta1.RunFunctionResponse, error) {
	// 1. Initialize a Response object
	rsp := response.To(req, response.DefaultTTL)

	// 2. Read the XR from the Request
	xr, err := request.GetObservedCompositeResource(req)
	if err != nil {
		return rsp, err
	}

	// 3. Extract the `subnetCount` parameter from the Developer's Claim
	subnetCount, err := xr.Resource.GetNumber("spec.parameters.subnetCount")
	if err != nil {
		// If the developer didn't provide it, we gracefully default to 1
		subnetCount = 1
	}

	// 4. The Power of Go: A dynamic FOR loop!
	for i := 0; i < int(subnetCount); i++ {
		
		// Generate a dynamic name and CIDR block for each subnet
		subnetName := fmt.Sprintf("subnet-%d", i)
		cidrBlock := fmt.Sprintf("10.0.%d.0/24", i)

		// Create the Managed Resource JSON structure in memory
		subnetJson := map[string]interface{}{
			"apiVersion": "ec2.aws.upbound.io/v1beta1",
			"kind":       "Subnet",
			"spec": map[string]interface{}{
				"forProvider": map[string]interface{}{
					"cidrBlock": cidrBlock,
					"region":    "us-east-1",
				},
			},
		}

		// 5. Add the generated Subnet to the Response
		if err := response.SetDesiredComposedResource(rsp, subnetName, subnetJson); err != nil {
			return rsp, err
		}
	}

	// 6. Return the Response to Crossplane
	return rsp, nil
}

func main() {
	log.Println("Starting Crossplane Function: dynamic-subnets")
	// Start the gRPC Server on port 9443
	function.Serve(RunFunction, function.Listen(":9443"))
}

This tiny Go program completely replaces the need for the YAML Patcher. It is infinitely scalable, deeply testable, and strictly typed.


4. Packaging and Deploying
#

Crossplane Functions are distributed as standard OCI container images.

Step 1: Write a Dockerfile
#

Create a Dockerfile to compile and package your Go binary.

# Build Stage
FROM golang:1.21 AS builder
WORKDIR /app
COPY . .
RUN CGO_ENABLED=0 GOOS=linux go build -o function .

# Runtime Stage (Minimal scratch image for security)
FROM scratch
COPY --from=builder /app/function /function
ENTRYPOINT ["/function"]
EXPOSE 9443

Step 2: Build and Push
#

Build the image and push it to a container registry (like DockerHub or AWS ECR).

docker build -t docker.io/myusername/function-dynamic-subnets:v1 .
docker push docker.io/myusername/function-dynamic-subnets:v1

Step 3: Install the Function into Crossplane
#

Now we tell Crossplane to download and run our custom function in the Kubernetes cluster.

Create install-function.yaml:

apiVersion: pkg.crossplane.io/v1beta1
kind: Function
metadata:
  name: function-dynamic-subnets
spec:
  # Point to your DockerHub image
  package: docker.io/myusername/function-dynamic-subnets:v1

Apply it:

kubectl apply -f install-function.yaml

Crossplane will spin up a pod running your Go binary.


5. Using the Function in a Composition
#

Finally, we update our Composition YAML to call the custom function using the Pipeline architecture we learned in Episode 10.

apiVersion: apiextensions.crossplane.io/v1
kind: Composition
metadata:
  name: dynamic-network
spec:
  compositeTypeRef:
    apiVersion: network.acmecorp.com/v1alpha1
    kind: XNetwork

  mode: Pipeline
  pipeline:
    # Call our Custom Go Function!
    - step: generate-subnets
      functionRef:
        name: function-dynamic-subnets

That’s it. No resources block. No patches block.

When a developer submits a claim with subnetCount: 3, Crossplane sends it to your Go container, the container runs the for loop, and 3 physical AWS Subnets are instantly provisioned.


Troubleshooting & Common Errors
#

  1. Function is not HEALTHY

    • Root Cause: The Pod running your Go binary crashed. Usually, this is because you didn’t compile the Go binary statically (CGO_ENABLED=0) before putting it in a scratch container.
    • Solution: Verify your Dockerfile compilation flags.
  2. cannot run function: timeout

    • Root Cause: Your Go code contains an infinite loop, or you are making a slow external API call (like a slow HTTP GET request) and exceeding the Crossplane gRPC timeout (default 5 seconds).
    • Solution: Keep your Go logic extremely fast. If you must call external APIs, ensure they respond quickly.

Conclusion & Next Steps
#

You have successfully replaced static YAML with a dynamic, Turing-complete Go program. This is the zenith of Platform Engineering. You can now enforce complex organizational policies, generate resources dynamically, and integrate with internal corporate APIs directly inside the infrastructure provisioning loop.

But writing infrastructure is only half of the modern DevOps lifecycle. The other half is deployment. How do we automate the deployment of our XRs and Claims?

In Episode 12: GitOps with ArgoCD and Crossplane, we will marry Crossplane with ArgoCD to achieve a 100% automated, push-to-deploy GitOps workflow for physical cloud infrastructure.

crossplane - This article is part of a series.
Part 11: This Article