Crossplane v2 - XRD and Composition Version Management

Recently we got to grips with Crossplane v2 and how to create both bespoke CRDs and make use of it’s Composition system to create bespoke provisioning workflows. What we didn’t get to cover though is how to manage XRDs and Compositions on an ongoing basis, adding new features and offering to them to our platforms as the need arises.

A lot of systems like this get quickly deployed without much foresight and platform teams are unlikely to build a perfect system on the first try. It’s easy to run in to a wall when you have to start adding new features that you didn’t anticipate only to realise that the release of a new version doesn’t work in the way that you first thought. Crossplane does offer several versioning systems which are pretty flexible but this flexibility means there are multiple ways of doing things that you should be aware of when getting started. So…let’s look at that.

What Are We Working With?

In a previous article we looked at how XRDs and Compositions work. I’m going to assume you already know that and not get bogged down in the fundamentals here. If you don’t, the previous article covers this off in detail.

Below is a basic example of an XRD being offered on our platform. A single version is currently offered; v1alpha1 and a set of string inputs are accepted as part of the schema:

apiVersion: apiextensions.crossplane.io/v2
kind: CompositeResourceDefinition
metadata:
  name: s3buckets.aws.platform.tinfoilcipher.com #--Platform specific endpoint
spec:
  group: aws.platform.tinfoilcipher.com
  names:
    kind: S3Bucket
    plural: s3buckets
  scope: Namespaced
  versions:
    #--Current version and spec
    - name: v1alpha1
      served: true
      referenceable: true
      schema:
        openAPIV3Schema:
          type: object
          properties:
            spec:
              type: object
              properties:
                parameters:
                  type: object
                  properties:
                    bucketName:
                      type: string
                    region:
                      type: string
                    versioning:
                      type: string
                  required:
                    - bucketName
                    - region
                    - versioning
              required:
                - parameters

XRD Versions - The Devil Is In The Detail

Generally, it is a bad idea to update something once it already has a version defined and released. There are exceptions to this however. A still in-development version of a system can be considered prone to change at any time, but stable releases should not see sudden change.

The Crossplane Documentation on XRD Versions suggests aligning XRD versions and evolution to the broader Kubernetes API Versioning guidelines, where:

  • alpha (I.E. v1alpha1) releases are unstable, and prone to change at any time
  • beta (I.E. v1beta1) releases are “considered stable and breaking changes are strongly discouraged”, sensible if pretty vague language
  • stable (I.E. v1) - releases are stable, and should not have breaking changes introduced

This model gives us something to work with, but how does this map on to XRDs in reality? The *XRD spec allows for the serving of multiple Versions at the same time as part of a single object:

apiVersion: apiextensions.crossplane.io/v2
kind: CompositeResourceDefinition
metadata:
  name: s3buckets.aws.platform.tinfoilcipher.com
spec:
  group: aws.platform.tinfoilcipher.com
  names:
    kind: S3Bucket
    plural: s3buckets
  scope: Namespaced
  #--Available XRD Versions
  versions:
    - name: v1alpha1
    ...
    - name: v1alpha2
    ...
    - name: v1alpha3
    ...

Makes sense. But if you just try and add multiple new version schemas you will probably run in to a whole host of errors very quickly.

I found it useful to read this Crossplane Blog Post, which discusses in detail the underlying theory of how Kubernetes objects are actually read and written to the Kubernetes data store (etcd). I would strongly recommend reading this as it will serve as a good basis for understanding WHY we face the limitations we do here, it doesn’t however touch on the realities of trying to release a new version of an XRD and how they interact with Compositions, so let’s take a look at that.

Introducing A New XRD Version

If for example we were to modify the inputs on v1alpha1 to include or remove a new mandatory parameter; any XRs which are calling s3buckets.aws.platform.tinfoilcipher.com/v1alpha1 would stop working and workflows would be interrupted. Much more importantly, any associated Compositions may cease to function, as expected input parameters may no longer exist.

Whilst XRDs do allow for the offering of multiple available versions at the same time, only a single version can act as the Referenceable version. There is some complexity to how this works under the hood, but for brevity we can think of the Referenceable version as the preferred, standard version that should be used when calling an XRD, in simpler language we might call this the Default version.

In the example below, we will leave version v1alpha1 available, and add a new version v1alpha2 which adds a new optional input parameter:

apiVersion: apiextensions.crossplane.io/v2
kind: CompositeResourceDefinition
metadata:
  name: s3buckets.aws.platform.tinfoilcipher.com #--Platform specific endpoint
spec:
  group: aws.platform.tinfoilcipher.com
  names:
    kind: S3Bucket
    plural: s3buckets
  scope: Namespaced
  versions:
    - name: v1alpha1 #--Current version, being deprecated
      served: true #--Available for Compositions
      referenceable: false #--Available, not default. This must be set to false to release a new version
      schema:
        openAPIV3Schema:
        ...
          type: object
          properties:
            spec:
              type: object
              properties:
                parameters:
                  type: object
                  properties:
                    bucketName: #--User input
                      type: string
                    region: #--User input
                      type: string
                  required: #--Mandatory inputs
                    - bucketName
                    - region
    - name: v1alpha2 #--New version
      served: true #--Available for Compositions
      referenceable: true #--Available, default
      schema:
        openAPIV3Schema:
        ...
          type: object
          properties:
            spec:
              type: object
              properties:
                parameters:
                  type: object
                  properties:
                    bucketName:
                      type: string
                    region:
                      type: string
                    #--Newly added changes to schema
                    forceDeletion:
                      type: boolean
                  required: #--Mandatory inputs, unchanged
                    - bucketName
                    - region

After configuring and saving, we can see the available versions with:

kubectl get crd s3buckets.aws.platform.tinfoilcipher.com -o=jsonpath='{.spec.versions[*].name}'
# v1alpha1, v1alpha2

It is important to point out here that the Crossplane Documentation is very clear on the handling of breaking changes. Any attempt to introduce a breaking change should not be handled as a new version on the same XRD, this MUST be done as an entirely separate XRD. There are some interesting technical reasons for this, but I won’t cover them here, they are detailed in this post if you are interested.

XRD Versions and Compositions Are Joined At The Hip

So we know how to introduce a new version to our XRD, but this runs in to a serious limitation when we start working with an existing Composition. If we look at the below Composition we can see that the XRD being called is referenced via the field spec.compositeTypeRef.apiVersion.

apiVersion: apiextensions.crossplane.io/v1
kind: Composition
metadata:
  name: s3bucket.aws.platform.tinfoilcipher.com
spec:
  compositeTypeRef:
    apiVersion: aws.platform.tinfoilcipher.com/v1alpha1 #--XRD Endpoint
    kind: S3Bucket
  mode: Pipeline
  pipeline:
    ...

This field is immutable, meaning that it cannot be changed following the creation of a Composition. The only option we have here is to create a NEW Composition and point it to our new XRD Version. This of course has downstream effects, as depending on the configuration of our XRs they might suddenly start reading a newly deployed Composition, or they might know nothing about it.

Composition and Referenceable XRD Versions - There Can Be Only One!

Another complexity here that I could not find documented, is that Compositions only appear to be able to make use of the current Referenceable XRD version. Making the idea of serving multiple XRD Versions of limited use to some degree.

The practical effect here is that if you change the Referenceable Version of an XRD, any XRs which are currently managed by a Composition linked to a previous version will become de-synchronised and future changes will fail to be written back to your cloud provider until they are migrated to a newer Composition. This could have serious consequences if not properly understood and managed.

In the below example, we have an XRs which is using a Composition linked to XRD version v1alpha1:

apiVersion: aws.platform.tinfoilcipher.com/v1alpha1
kind: S3Bucket
metadata:
  name: tinfoil-example-bucket-12-11-25-tenant-1
  namespace: tenant1
spec:
  crossplane:
    compositionRef:
      name: v1alpha1-composition #--Point an XR to a specific composition
      ...

If we examine the XR, we can see it is healthy:

kubectl get s3bucket --all-namespaces
# NAME                                       SYNCED   READY   COMPOSITION            AGE
# tinfoil-example-bucket-12-11-25-tenant-1   True     True    v1alpha1-composition   1m2s

Now if we set v1alpha2 as the Referenceable XRD Version, we will see our XR remains in a READY state, meaning that they are still in existence in our cloud provider, but that they are not SYNCED. This means we can read the object, but not write changes back to our cloud provider:

kubectl get s3bucket --all-namespaces
# NAME                                       SYNCED   READY   COMPOSITION            AGE
# tinfoil-example-bucket-12-11-25-tenant-1   False    True    v1alpha1-composition   1m10s

Our XR will remain in this state until we reconfigure it to:

  • Use a Composition that calls the XRD version v1alpha2
  • Use v1Alpha2 as it’s own apiVersion

At which point they will become SYNCED again:

apiVersion: aws.platform.tinfoilcipher.com/v1alpha2
kind: S3Bucket
metadata:
  name: tinfoil-example-bucket-12-11-25-tenant-1
  namespace: tenant1
spec:
  crossplane:
    compositionRef:
      name: v1alpha2-composition #--Point an XR to a specific composition
      ...

Checking, we can see the XR is not back in sync:

kubectl get s3bucket --all-namespaces
# NAME                                       SYNCED   READY   COMPOSITION            AGE
# tinfoil-example-bucket-12-11-25-tenant-1   True     True    v1alpha2-composition   3m12s

With all this in mind, it should go without saying that managing Composition changes needs to be done with serious care and consideration for impact. By default, a change made to an active Composition will be automatically served to all XRs which are consuming it. If, for example, you make a change in your Composition to provision (or worse, destroy) some Managed Resources, you might quickly find yourself in a situation where all of your platform tenants are creating or destroying real world infrastructure.

There are systems in place to version control Compositions and avoid these situations and it will probably be no surprise by now that this introduces even more complexity and can be approached in a few different ways which we’ll try to cover below.

Compositions Have Their Own Versions?

Compositions have their own internal versioning system called Revisions. Every time a change is made to an individual Composition, a new version is automatically created and maintained as a separate object.

In the below example, I have modified the s3bucket.aws.platform.tinfoilcipher.com Composition to add a new Patch Function:

apiVersion: apiextensions.crossplane.io/v1
kind: Composition
metadata:
  name: s3bucket.aws.platform.tinfoilcipher.com
  #--Custom labels
  labels:
    aws.platform.tinfoilcipher.com/composition: s3
spec:
  compositeTypeRef:
    apiVersion: aws.platform.tinfoilcipher.com/v1alpha1 #--New version
    kind: S3Bucket
  mode: Pipeline
  pipeline:
    ...
          patches:
            ...
            #--Newly added patch
            - fromFieldPath: "spec.tags"
              toFieldPath: "spec.forProvider.tags"

Revisions are managed using their own object Kind; CompositionRevisions. We can see that a new Revision has been created if we look these up. We look up the object using a label automatically added by crossplane:

kubectl get compositionrevision -l crossplane.io/composition-name=s3bucket.aws.platform.tinfoilcipher.com
# NAME                                              REVISION   XR-KIND    XR-APIVERSION                             AGE
# s3bucket.aws.platform.tinfoilcipher.com-794c340   2          S3Bucket   aws.platform.tinfoilcipher.com/v1alpha1   5s
# s3bucket.aws.platform.tinfoilcipher.com-f1d929b   1          S3Bucket   aws.platform.tinfoilcipher.com/v1alpha1   23m

Managing Composition Versions - Pinning an XR To A Composition Version

Crossplane offers the ability to pin not only to a specific Composition, but to a specific Composition Revision as shown below. When provisioning an XR we can pass a crossplane configuration in our spec:

apiVersion: aws.platform.tinfoilcipher.com/v1alpha1
kind: S3Bucket
metadata:
  name: tinfoil-example-bucket-12-11-25-tenant-1
  namespace: tenant1
spec:
  crossplane:
    compositionUpdatePolicy: Manual #--If not set, XRs will take changes WHEN A COMPOSITE IS CHANGED    
    compositionRef:
      name: s3bucket.aws.platform.tinfoilcipher.com #--Specific Composition
    compositionRevisionRef:
      name: s3bucket.aws.platform.tinfoilcipher.com-f1d929b #--Pin to a specific version

Personally I wouldn’t recommend this for long term use, it feels messy and the Revision refs aren’t very user friendly for platform consumers. It is however an ideal candidate for debugging and testing out new Compositions.

Managing Composition Versions - Subscribing to Update Channels

Crossplane’s preferred way of doing most things is by leveraging Kubernetes Labels and Selectors and Composition versioning can be handled in this same manner. In the above example of a Composition I have added some Custom Labels that we can use to “subscribe” to updates as they are released, the same way as we would subscribe to an update channel for application updates.

In the example below, we are adding a label to our Composition, release_channel:

apiVersion: apiextensions.crossplane.io/v1
kind: Composition
metadata:
  name: s3bucket.aws.platform.tinfoilcipher.com #--Composition names need to be unique
  labels:
    release_channel: unstable
spec:
  compositeTypeRef:
    apiVersion: aws.platform.tinfoilcipher.com/v1alpha2 #--Uses new XRD

This is useful in gitflow or similar rapid change environments where we have multiple available versions of the same Composition, with different Composition Pipelines. One version could be offered for example as unstable, another as stable and another as experimental. What is critical to understand here is that the Composition remains a SINGLE OBJECT with multiple Revision objects. The YAML manifest used to configure the Composition is still ultimately a single file so it is critical to consider your approach to source control before trying to use this system.

Such a configuration will give us something like:

kubectl get compositionrevisions -o="custom-columns=NAME:.metadata.name,REVISION:.spec.revision,CHANNEL:.metadata.labels.release_channel"
# NAME                                              REVISION   CHANNEL
# s3bucket.aws.platform.tinfoilcipher.com-acd735a   7          experimental
# s3bucket.aws.platform.tinfoilcipher.com-bb720ba   6          experimental
# s3bucket.aws.platform.tinfoilcipher.com-38cba24   5          experimental
# s3bucket.aws.platform.tinfoilcipher.com-381653c   4          experimental
# s3bucket.aws.platform.tinfoilcipher.com-137dcff   3          unstable
# s3bucket.aws.platform.tinfoilcipher.com-794c340   2          stable
# s3bucket.aws.platform.tinfoilcipher.com-f1d929b   1          stable

Based on this, we can then subscribe several different XRs to separate Revisions based on labels as shown below. The compositionUpdatePolicy must be set to Automatic (which is the default) in order to subscribe to updates like this, as this method relies on watching for incoming Revisions on a Composition.

#--XR created from unstable channel
apiVersion: aws.platform.tinfoilcipher.com/v1alpha2
kind: S3Bucket
metadata:
  name: tinfoil-example-bucket-28-10-25-tenant-1
  namespace: tenant1
spec:
  crossplane:
    compositionUpdatePolicy: Automatic #--Set by default
    compositionRevisionSelector:
      matchLabels:
        release_channel: experimental

#--XR Created from stable channel
apiVersion: aws.platform.tinfoilcipher.com/v1alpha1
kind: S3Bucket
metadata:
  name: tinfoil-example-bucket-28-10-25-tenant-2
  namespace: tenant2
spec:
  crossplane:
    compositionUpdatePolicy: Automatic #--Set by default
    compositionRevisionSelector:
      matchLabels:
        release_channel: stable
Written on October 28, 2025