Introducing The New Crossplane CLI Developer Experience
Crossplane is a powerful tool for building platforms, but building a platform on top of Crossplane has never been as easy as it should be. First, there's the conceptual complexity: how do composite resource definitions, compositions, composition functions, and managed resources all fit together? Then comes the practical challenge: how should a platform team build, test, package, and distribute all the parts that make up a Crossplane platform? Many platform teams have invented their own solutions to this problem.
Crossplane CLI v2.3 introduced a set of developer experience (DevEx) tools built around a first-class concept called a Project: an opinionated, on-disk layout for everything that makes up a platform API. The CLI provides commands to scaffold, generate, test, and run projects locally. This post explains why we built these tools and walks through building a small platform API from scratch with them.
Why we built this
A minimal Crossplane platform looks like configuration: a set of YAML files that get packaged and deployed into a cluster. However, as a platform grows more complex, its logic often outgrows the configuration paradigm and starts being encapsulated in functions. At this point, a platform starts looking less like configuration and more like software.
Software projects generally have a clear distinction between the development artifact (source code) and the deployment artifact (binaries, packages, or container images). The Crossplane developer experience design started from this question: what should the development artifact for a platform look like? Our answer is the Crossplane Project.
A Project encapsulates configuration (XRDs, Compositions, Operations, etc.) and code (functions) into one development artifact. A single CLI command builds the Project into a set of Crossplane packages: a Configuration containing the configuration resources, and one or more Functions. Dependencies are managed automatically by the tooling, versioning the whole Project as one unit.
The tooling can also generate language bindings for managed resources from Crossplane providers, arbitrary CRDs, and core Kubernetes APIs. This makes building composition functions easier and safer by allowing you to use a wide variety of existing development tools for your preferred language. It also lets AI agents more effectively build, review, and debug Crossplane platforms with their extensive training on the general purpose programming languages we support. Today, those languages are Go, templated YAML (with Go templating syntax), Python, and KCL. TypeScript support is in progress and we welcome contributions of other languages from the community.
Prerequisites
You'll need the Crossplane CLI and a Docker-compatible container runtime. The CLI builds your functions and spins up a local development control plane in a KIND cluster, both of which need Docker. You don't need an existing Kubernetes cluster.
The platform we'll build exposes a WebApp API. A user creates a WebApp:
apiVersion: platform.example.com/v1alpha1
kind: WebApp
metadata:
name: podinfo
namespace: default
spec:
image: docker.io/stefanprodan/podinfo:6.11.0
replicas: 3
ports: [9898]
Crossplane will turn the WebApp into a Deployment and a Service. This is a simple platform, but enough to show the power of the tooling.
Create the project
A project is a directory with a standardized layout and some metadata. The init command scaffolds it for you:
crossplane project init example-project-webapp
cd example-project-webapp
That gives you some empty directories and a crossplane-project.yaml metadata file:
example-project-webapp
├── apis
├── crossplane-project.yaml
├── examples
├── functions
├── operations
└── tests
Every project looks essentially the same: apis/ holds your XRDs and compositions, functions/ holds embedded function source, examples/ holds sample resources you render and test against, and crossplane-project.yaml records metadata and dependencies. The directory names are customizable in the metadata file if you don't like the defaults.
Define the API
Crossplane calls a composition-powered custom resource a composite resource (XR), and you describe its schema with a composite resource definition (XRD). Rather than write the XRD by hand, you can describe the API you want and let the CLI generate it. Here we'll write a SimpleSchema document for our WebApp API to apis/webapps/schema.yaml:
apiVersion: platform.example.com/v1alpha1
kind: WebApp
spec:
image: string | required=true description="OCI image for the webapp"
replicas: integer | default=1 minimum=1 maximum=100 description="Number of replicas to run"
ports: "[]integer | default=[80] description=\"Ports to expose from the application container\""
Then generate the XRD:
crossplane xrd generate apis/webapps/schema.yaml --from simpleschema
The CLI writes a complete XRD to apis/webapps/definition.yaml, translating your types, defaults, validation rules, and descriptions into an OpenAPI schema. If you'd rather not learn SimpleSchema, you can point xrd generate at an example XR instead and it will infer the types from the values. Either way, you don't have to write the OpenAPI schema by hand.
Generate the composition
A composition tells Crossplane what to do when someone creates a WebApp. Generate a starting point from the XRD:
crossplane composition generate apis/webapps/definition.yaml
This produces a composition with a single pipeline step running function-auto-ready, which marks the WebApp ready once its composed resources are ready. Notice that the command also added function-auto-ready to your project's dependencies automatically.
Add a dependency
Our function is going to build Deployment and Service objects, so it needs the Kubernetes core API schemas. Add them as a dependency:
crossplane dependency add k8s:v1.35.0
The dependency add command generates language bindings for the dependency and records it in crossplane-project.yaml. These bindings give you autocompletion, hover docs, and type checking when you write your function. If your platform composes cloud resources you can add a Crossplane provider; for our example we just need Kubernetes.
Write the function — in your language
The biggest simplification we've made to platform development with Projects is that the functions live in the project, not a separate codebase. The CLI can scaffold an embedded function and wires it into your composition pipeline.
For this walkthrough we'll use Python.
crossplane function generate compose-webapp apis/webapps/composition.yaml --language python
That scaffolds functions/compose-webapp/ and adds a pipeline step to the composition. Paste the following code into functions/compose-webapp/fn/fn.py:
"""A Crossplane composition function."""
import grpc
from crossplane.function import logging, response, resource
from crossplane.function.proto.v1 import run_function_pb2 as fnv1
from crossplane.function.proto.v1 import run_function_pb2_grpc as grpcv1
from models.com.example.platform.webapp import v1alpha1
from models.io.k8s.api.apps import v1 as appsv1
from models.io.k8s.api.core import v1 as corev1
from models.io.k8s.apimachinery.pkg.apis.core.meta import v1 as metav1
from models.io.k8s.apimachinery.pkg.util import intstr
class FunctionRunner(grpcv1.FunctionRunnerService):
"""A FunctionRunner handles gRPC RunFunctionRequests."""
def __init__(self):
"""Create a new FunctionRunner."""
self.log = logging.get_logger()
async def RunFunction(
self, req: fnv1.RunFunctionRequest, _: grpc.aio.ServicerContext
) -> fnv1.RunFunctionResponse:
"""Run the function."""
log = self.log.bind(tag=req.meta.tag)
log.info("Running function")
rsp = response.to(req)
xr = v1alpha1.WebApp(**resource.struct_to_dict(req.observed.composite.resource))
assert xr.metadata is not None
assert xr.metadata.name is not None
assert xr.spec.ports is not None
labels = {"app.kubernetes.io/name": xr.metadata.name}
ports = xr.spec.ports
dply = appsv1.Deployment(
metadata=metav1.ObjectMeta(
labels=labels,
),
spec=appsv1.DeploymentSpec(
selector=metav1.LabelSelector(
matchLabels=labels,
),
replicas=xr.spec.replicas,
template=corev1.PodTemplateSpec(
metadata=metav1.ObjectMeta(
labels=labels,
),
spec=corev1.PodSpec(
containers=[
corev1.Container(
name="app",
image=xr.spec.image,
ports=[corev1.ContainerPort(containerPort=p) for p in ports],
)
]
),
),
),
)
resource.update(rsp.desired.resources["deployment"], dply)
svc = corev1.Service(
metadata=metav1.ObjectMeta(
labels=labels,
),
spec=corev1.ServiceSpec(
ports=[corev1.ServicePort(port=p, targetPort=intstr.IntOrString(p)) for p in ports],
selector=labels,
),
)
resource.update(rsp.desired.resources["service"], svc)
return rsp
The function reads the observed WebApp XR, then builds a Deployment and a Service from its spec. The models packages are the type bindings the CLI generated when you added the Kubernetes dependency, so you build the resources with typed Python classes instead of raw dictionaries. Because the function is a regular Python project, you can use any tools you like from the Python ecosystem, depend on other Python packages, and take advantage of AI tools that excel at writing Python code.
Render before you run
Before spinning anything up, preview what your composition produces:
crossplane composition render examples/webapps/podinfo.yaml apis/webapps/composition.yaml
Inside a project, render discovers and builds your embedded functions automatically, then runs the exact same composition logic a real control plane would. It prints the Deployment and Service your function generates, plus the updates Crossplane would make to the WebApp itself. This is the tightest feedback loop in the workflow: change your function code, re-run render, and see the result. Because the function is embedded in your project, you don't need to build or run it separately.
Run it on a local control plane
When the rendered output looks right, run the whole thing for real:
crossplane project run
The project run command creates a local development control plane in a KIND cluster with a local OCI registry, builds your embedded function into a Function package, builds a Configuration package from your XRD and composition (with dependencies on your embedded function and function-auto-ready), pushes both to the local registry, installs the configuration, and points your kubectl context at the new control plane. The first run takes a little while to create the cluster; subsequent runs will be faster as the cluster gets reused.
Now create a WebApp:
kubectl apply -f examples/webapps/podinfo.yaml
Once the composition runs, you'll see the Deployment and Service show up in the default namespace:
$ kubectl get deployment,service -l app.kubernetes.io/name=podinfo
NAME READY UP-TO-DATE AVAILABLE AGE
deployment.apps/podinfo 3/3 3 3 45s
NAME TYPE CLUSTER-IP PORT(S) AGE
service/podinfo ClusterIP 10.96.142.110 9898/TCP 45s
And when they become ready, function-auto-ready will mark the XR as ready, too:
$ kubectl get webapp podinfo
NAME SYNCED READY COMPOSITION AGE
podinfo True True webapps.platform.example.com 45s
Edit the WebApp's replicas or image, apply it again, and Crossplane reconciles the Deployment to match. When you're done, delete the WebApp and tear the control plane down:
kubectl delete -f examples/webapps/podinfo.yaml
crossplane project stop
What's next
The initial set of Crossplane DevEx commands covers the basics of building a platform: defining APIs, building compositions, writing functions, and testing them manually. But, platform teams building real things need tools that go beyond day one.
We're working on adding automated testing to the set of tools. This will encompass both render-based tests (see the xprintool for inspiration), and end-to-end tests that build on top of crossplane project run to validate the behavior of a platform in a real cluster.
Work has also started on simulations, giving users the ability to preview changes without actually applying them at both the Crossplane level and the provider level. See the design document for details.
We would love contributions from the Crossplane community on both these efforts, and more. Once you've tried out the current tooling we would love to hear what's missing and what will make it an even better experience for building platforms. Join us in the Crossplane Slack (DevEx is is mostly discusssed in #sig-cli) or file an issue in the crossplane/cli GitHub project.
Try it out
The Crossplane CLI DevEx tools standardize the workflow of building platforms, so that you don't have to reinvent it for your own team. You get a standard project layout, embedded functions in the language of your choice, generated bindings for building functions, a fast local render loop, and a one-command development control plane.
For a more detailed tutorial, including examples in all supported languages, follow the Get Started with Control Plane Projects guide.