Get Started with Control Plane Projects

This document is for an unreleased version of Crossplane CLI.

This document applies to the Crossplane CLI master branch and not to the latest release v2.3.

This guide shows how to use the Crossplane CLI to build a platform API from scratch. You create a project, define a new WebApp custom resource, and configure how Crossplane composes it. When a user creates a WebApp, Crossplane creates a Kubernetes Deployment and a Service.

The Crossplane CLI scaffolds the project, generates the API and composition, and runs the project on a local development control plane so you can test it without deploying to a shared cluster.

Tip
This guide shows how to write the composition function in Go, Python, KCL, and templated YAML. You can pick your preferred language.

A WebApp custom resource looks like this:

1apiVersion: platform.example.com/v1alpha1
2kind: WebApp
3metadata:
4  name: podinfo
5  namespace: default
6spec:
7  image: docker.io/stefanprodan/podinfo:6.11.0
8  replicas: 3
9  ports: [9898]

The WebApp is the custom API your users use to deploy a containerized workload.

When a user creates a WebApp, Crossplane creates a Deployment and a Service:

 1---
 2apiVersion: apps/v1
 3kind: Deployment
 4metadata:
 5  name: podinfo
 6  namespace: default
 7  labels:
 8    app.kubernetes.io/name: podinfo  # Copied from the WebApp's name
 9spec:
10  replicas: 3  # Copied from the WebApp's spec
11  selector:
12    matchLabels:
13      app.kubernetes.io/name: podinfo
14  template:
15    metadata:
16      labels:
17        app.kubernetes.io/name: podinfo
18    spec:
19      containers:
20      - name: podinfo
21        image: docker.io/stefanprodan/podinfo:6.11.0  # Copied from the WebApp's spec
22        ports:
23        - containerPort: 9898  # Copied from the WebApp's spec
24---
25apiVersion: v1
26kind: Service
27metadata:
28  name: podinfo
29  namespace: default
30spec:
31  selector:
32    app.kubernetes.io/name: podinfo
33  ports:
34  - protocol: TCP
35    port: 9898
36    targetPort: 9898

Prerequisites

This guide requires:

The CLI builds the project’s functions and runs a local development control plane in a KIND cluster. Both require a working Docker installation. You don’t need an existing Kubernetes cluster.

Create the project

A Crossplane project is a directory that contains everything that makes up a platform API: the API definitions, compositions, embedded functions, examples, and a crossplane-project.yaml metadata file.

Initialize a new project named example-project-webapp:

1crossplane project init example-project-webapp

The init command creates a directory named after the project, containing a minimal crossplane-project.yaml and the standard project directories:

 1tree example-project-webapp
 2example-project-webapp
 3├── apis
 4├── crossplane-project.yaml
 5├── examples
 6├── functions
 7├── operations
 8└── tests
 9
106 directories, 1 file

Change into the new project directory:

1cd example-project-webapp

Run the remaining commands in this guide from the project directory.

The crossplane-project.yaml file

The crossplane-project.yaml file contains metadata about your project. This metadata influences how the Crossplane CLI builds your project into Crossplane packages (xpkgs). For now, the file sets only the project’s name and OCI repository:

1apiVersion: dev.crossplane.io/v1alpha1
2kind: Project
3metadata:
4  name: example-project-webapp
5spec:
6  repository: example.com/my-org/example-project-webapp

For the rest of this guide, you can leave the placeholder repository as is. You must update it if you want to push your project to an OCI registry later.

Define the API

Crossplane calls a custom resource that’s powered by composition a composite resource, or XR. You define the schema of an XR with a composite resource definition, or XRD.

Note

Kubernetes calls user-defined API resources custom resources.

Crossplane calls user-defined API resources that use composition composite resources.

A composite resource is a kind of custom resource.

Rather than write the XRD by hand, you can write an example XR or describe the API with SimpleSchema, then let the CLI generate the XRD.

Create an example XR at examples/webapp/podinfo.yaml showing all the fields the WebApp API should include:

1apiVersion: platform.example.com/v1alpha1
2kind: WebApp
3metadata:
4  name: podinfo
5  namespace: default
6spec:
7  image: docker.io/stefanprodan/podinfo:6.11.0
8  replicas: 3
9  ports: [9898]

The crossplane xrd generate command reads the example XR, infers the field types from their contents, and generates an appropriate XRD:

1crossplane xrd generate examples/webapp/podinfo.yaml --from xr

The command writes the generated XRD to apis/webapps/definition.yaml:

 1apiVersion: apiextensions.crossplane.io/v2
 2kind: CompositeResourceDefinition
 3metadata:
 4  name: webapps.platform.example.com
 5spec:
 6  group: platform.example.com
 7  names:
 8    categories:
 9    - crossplane
10    kind: WebApp
11    plural: webapps
12  scope: Namespaced
13  versions:
14  - name: v1alpha1
15    referenceable: true
16    schema:
17      openAPIV3Schema:
18        description: WebApp is the Schema for the WebApp API.
19        properties:
20          spec:
21            description: WebAppSpec defines the desired state of WebApp.
22            properties:
23              image:
24                type: string
25              ports:
26                items:
27                  type: number
28                type: array
29              replicas:
30                type: number
31            type: object
32          status:
33            description: WebAppStatus defines the observed state of WebApp.
34            type: object
35        required:
36        - spec
37        type: object
38    served: true
Note
Generating an XRD from an example XR doesn’t let you specify field types, defaults, validation rules, or field descriptions. SimpleSchema provides a more powerful, but more complex, way to describe your API.

Create a SimpleSchema document at apis/webapps/schema.yaml that describes the WebApp API:

1apiVersion: platform.example.com/v1alpha1
2kind: WebApp
3spec:
4  image: string | required=true description="OCI image for the webapp"
5  replicas: integer | default=1 minimum=1 maximum=100 description="Number of replicas to run"
6  ports: "[]integer | default=[80] description=\"Ports to expose from the application container\""

The SimpleSchema document lists each field of the API’s spec along with its type, validation rules, and description.

Generate the XRD from the SimpleSchema document:

1crossplane xrd generate apis/webapps/schema.yaml --from simpleschema

The command writes the generated XRD to apis/webapps/definition.yaml:

 1apiVersion: apiextensions.crossplane.io/v2
 2kind: CompositeResourceDefinition
 3metadata:
 4  name: webapps.platform.example.com
 5spec:
 6  group: platform.example.com
 7  names:
 8    categories:
 9    - crossplane
10    kind: WebApp
11    plural: webapps
12  scope: Namespaced
13  versions:
14  - name: v1alpha1
15    referenceable: true
16    schema:
17      openAPIV3Schema:
18        description: WebApp is the Schema for the WebApp API.
19        properties:
20          spec:
21            properties:
22              image:
23                description: OCI image for the webapp
24                type: string
25              ports:
26                default:
27                - 80
28                description: Ports to expose from the application container
29                items:
30                  type: integer
31                type: array
32              replicas:
33                default: 1
34                description: Number of replicas to run
35                maximum: 100
36                minimum: 1
37                type: integer
38            required:
39            - image
40            type: object
41          status:
42            type: object
43        required:
44        - spec
45        type: object
46    served: true

The XRD is the contract between your users and your platform. It defines the fields a user can set on a WebApp, the validation Crossplane applies, and the default values Crossplane fills in.

Generate the composition

A composition tells Crossplane what to do when a user creates or updates a WebApp. It contains a pipeline of functions that build the resources Crossplane creates.

Generate a composition from the XRD:

1crossplane composition generate apis/webapps/definition.yaml

The command writes the composition to apis/webapps/composition.yaml:

 1apiVersion: apiextensions.crossplane.io/v1
 2kind: Composition
 3metadata:
 4  name: webapps.platform.example.com
 5spec:
 6  compositeTypeRef:
 7    apiVersion: platform.example.com/v1alpha1
 8    kind: WebApp
 9  mode: Pipeline
10  pipeline:
11  - functionRef:
12      name: crossplane-contrib-function-auto-ready
13    step: crossplane-contrib-function-auto-ready

The generated composition contains a single pipeline step that runs function-auto-ready, which marks the WebApp ready when its composed resources are ready. The composition generate command adds function-auto-ready to the project’s dependencies automatically.

The composition doesn’t yet create any resources. In the next step you write a function that turns a WebApp into a Deployment and a Service.

Add dependencies

Your function creates Kubernetes Deployment and Service resources, so it needs the schemas for the Kubernetes core APIs. Add the Kubernetes APIs as a project dependency:

1crossplane dependency add k8s:v1.35.0

The dependency add command generates language bindings (schemas) for the dependency and records it in crossplane-project.yaml. The function uses these schemas for typed access to the Deployment and Service resources.

Note
You don’t need to add function-auto-ready as a dependency. The composition generate command added it for you in the previous step.

After this step, your project has two dependencies listed in crossplane-project.yaml:

 1apiVersion: dev.crossplane.io/v1alpha1
 2kind: Project
 3metadata:
 4  name: example-project-webapp
 5spec:
 6  dependencies:
 7  - type: xpkg
 8    xpkg:
 9      apiVersion: pkg.crossplane.io/v1
10      kind: Function
11      package: xpkg.crossplane.io/crossplane-contrib/function-auto-ready
12      version: '>=v0.0.0'
13  - k8s:
14      version: v1.35.0
15    type: k8s
16  repository: example.com/my-org/example-project-webapp

Write the function

A composition function contains the logic that turns a WebApp into the resources Crossplane creates. The CLI scaffolds an embedded function in the language you choose and adds it as a step in your composition’s pipeline.

Pick a language to write your function in.

Templated YAML is a good choice if you’re used to writing Helm charts. It doesn’t require a separate toolchain.

Generate a templated YAML function named compose-webapp and add it to the composition’s pipeline:

1crossplane function generate compose-webapp apis/webapps/composition.yaml --language go-templating

The command scaffolds the function under functions/compose-webapp/, where you write one or more .gotmpl template files. Templates render in alphabetical order by filename.

The function scaffold includes the file functions/compose-webapp/00-prelude.yaml.gotmpl, which reads the observed XR into a variable:

1# Get the observed composite resource into a variable. This can be used in any
2# subsequent templates.
3{{ $xr := getCompositeResource . }}

You can use the $xr variable to access fields from the XR in later templates.

Replace the example contents of functions/compose-webapp/01-compose.yaml.gotmpl with the following:

 1# code: language=yaml
 2# yaml-language-server: $schema=../../schemas/json/index.schema.json
 3
 4---
 5apiVersion: apps/v1
 6kind: Deployment
 7metadata:
 8  annotations:
 9    gotemplating.fn.crossplane.io/composition-resource-name: deployment
10  name: {{ $xr.metadata.name }}
11  namespace: {{ $xr.metadata.namespace }}
12  labels:
13    app.kubernetes.io/name: {{ $xr.metadata.name }}
14spec:
15  replicas: {{ $xr.spec.replicas }}
16  selector:
17    matchLabels:
18      app.kubernetes.io/name: {{ $xr.metadata.name }}
19  template:
20    metadata:
21      labels:
22        app.kubernetes.io/name: {{ $xr.metadata.name }}
23    spec:
24      containers:
25      - name: {{ $xr.metadata.name }}
26        image: {{ $xr.spec.image }}
27        ports:
28#{{- range $p := $xr.spec.ports }}
29        - containerPort: {{ $p }}
30#{{- end }}
31
32---
33apiVersion: v1
34kind: Service
35metadata:
36  annotations:
37    gotemplating.fn.crossplane.io/composition-resource-name: service
38  name: {{ $xr.metadata.name }}
39  namespace: {{ $xr.metadata.namespace }}
40spec:
41  selector:
42    app.kubernetes.io/name: {{ $xr.metadata.name }}
43  ports:
44#{{- range $p := $xr.spec.ports }}
45  - protocol: TCP
46    port: {{ $p }}
47    targetPort: {{ $p }}
48#{{- end }}

The comment lines at the top configure the YAML language server to use the JSON Schema files the CLI generated when you added the Kubernetes dependency. This lets you use editor features like hover documentation and autocompletion when writing your templates. Note that the template control flow can confuse the YAML language server. Making them YAML comments causes the language server to ignore them, but they’re still invoked by the template engine.

The composition-resource-name annotation gives each resource a stable name in the composition.

Tip
Templated YAML functions use function-go-templating as their runtime image. You can use any of the features and functions described in its documentation.

Python is a good choice for functions with dynamic logic. You can use the full Python standard library and any other Python library you need.

Generate a Python function named compose-webapp and add it to the composition’s pipeline:

1crossplane function generate compose-webapp apis/webapps/composition.yaml --language python

The command scaffolds the function under functions/compose-webapp/ and adds a pipeline step to apis/webapps/composition.yaml.

Replace the contents of functions/compose-webapp/function/fn.py with the following function logic:

 1"""A Crossplane composition function."""
 2
 3import grpc
 4from crossplane.function import logging, response, resource
 5from crossplane.function.proto.v1 import run_function_pb2 as fnv1
 6from crossplane.function.proto.v1 import run_function_pb2_grpc as grpcv1
 7
 8from models.com.example.platform.webapp import v1alpha1
 9from models.io.k8s.api.apps import v1 as appsv1
10from models.io.k8s.api.core import v1 as corev1
11from models.io.k8s.apimachinery.pkg.apis.core.meta import v1 as metav1
12from models.io.k8s.apimachinery.pkg.util import intstr
13
14class FunctionRunner(grpcv1.FunctionRunnerService):
15    """A FunctionRunner handles gRPC RunFunctionRequests."""
16
17    def __init__(self):
18        """Create a new FunctionRunner."""
19        self.log = logging.get_logger()
20
21    async def RunFunction(
22        self, req: fnv1.RunFunctionRequest, _: grpc.aio.ServicerContext
23    ) -> fnv1.RunFunctionResponse:
24        """Run the function."""
25        log = self.log.bind(tag=req.meta.tag)
26        log.info("Running function")
27
28        rsp = response.to(req)
29
30        xr = v1alpha1.WebApp(**resource.struct_to_dict(req.observed.composite.resource))
31
32        assert xr.metadata is not None
33        assert xr.metadata.name is not None
34        assert xr.spec.ports is not None
35
36        labels = {"app.kubernetes.io/name": xr.metadata.name}
37        ports = xr.spec.ports
38
39        dply = appsv1.Deployment(
40            metadata=metav1.ObjectMeta(
41                labels=labels,
42            ),
43            spec=appsv1.DeploymentSpec(
44                selector=metav1.LabelSelector(
45                    matchLabels=labels,
46                ),
47                replicas=xr.spec.replicas,
48                template=corev1.PodTemplateSpec(
49                    metadata=metav1.ObjectMeta(
50                        labels=labels,
51                    ),
52                    spec=corev1.PodSpec(
53                        containers=[
54                            corev1.Container(
55                                name="app",
56                                image=xr.spec.image,
57                                ports=[corev1.ContainerPort(containerPort=p) for p in ports],
58                            )
59                        ]
60                    ),
61                ),
62            ),
63        )
64
65        resource.update(rsp.desired.resources["deployment"], dply)
66
67        svc = corev1.Service(
68            metadata=metav1.ObjectMeta(
69                labels=labels,
70            ),
71            spec=corev1.ServiceSpec(
72                ports=[corev1.ServicePort(port=p, targetPort=intstr.IntOrString(p)) for p in ports],
73                selector=labels,
74            ),
75        )
76
77        resource.update(rsp.desired.resources["service"], svc)
78
79        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.

Go is a good choice if you want a statically typed, compiled function and access to the full Go ecosystem.

Generate a Go function named compose-webapp and add it to the composition’s pipeline:

1crossplane function generate compose-webapp apis/webapps/composition.yaml --language go

The command scaffolds the function under functions/compose-webapp/ and adds a pipeline step to apis/webapps/composition.yaml.

Replace the contents of functions/compose-webapp/fn.go with the following function logic:

  1package main
  2
  3import (
  4	"context"
  5	"encoding/json"
  6
  7	"dev.crossplane.io/models/com/example/platform/v1alpha1"
  8	appsv1 "dev.crossplane.io/models/io/k8s/apps/v1"
  9	metav1 "dev.crossplane.io/models/io/k8s/core/meta/v1"
 10	corev1 "dev.crossplane.io/models/io/k8s/core/v1"
 11	utilv1 "dev.crossplane.io/models/io/k8s/util/v1"
 12	"github.com/crossplane/crossplane-runtime/v2/pkg/errors"
 13	"github.com/crossplane/function-sdk-go/logging"
 14	fnv1 "github.com/crossplane/function-sdk-go/proto/v1"
 15	"github.com/crossplane/function-sdk-go/request"
 16	"github.com/crossplane/function-sdk-go/resource"
 17	"github.com/crossplane/function-sdk-go/resource/composed"
 18	"github.com/crossplane/function-sdk-go/response"
 19	"k8s.io/utils/ptr"
 20)
 21
 22// Function is your composition function.
 23type Function struct {
 24	fnv1.UnimplementedFunctionRunnerServiceServer
 25
 26	log logging.Logger
 27}
 28
 29// RunFunction runs the Function.
 30func (f *Function) RunFunction(_ context.Context, req *fnv1.RunFunctionRequest) (*fnv1.RunFunctionResponse, error) {
 31	f.log.Info("Running function", "tag", req.GetMeta().GetTag())
 32	rsp := response.To(req, response.DefaultTTL)
 33
 34	observedComposite, err := request.GetObservedCompositeResource(req)
 35	if err != nil {
 36		response.Fatal(rsp, errors.Wrap(err, "cannot get xr"))
 37		return rsp, nil
 38	}
 39
 40	var xr v1alpha1.WebApp
 41	if err := convertViaJSON(&xr, observedComposite.Resource); err != nil {
 42		response.Fatal(rsp, errors.Wrap(err, "cannot convert xr"))
 43		return rsp, nil
 44	}
 45
 46	// Collect the desired composed resources into this map, then convert them to
 47	// the SDK's types and set them in the response on return.
 48	desiredComposed := make(map[resource.Name]any)
 49	defer func() {
 50		desiredComposedResources, err := request.GetDesiredComposedResources(req)
 51		if err != nil {
 52			response.Fatal(rsp, errors.Wrap(err, "cannot get desired resources"))
 53			return
 54		}
 55
 56		for name, obj := range desiredComposed {
 57			c := composed.New()
 58			if err := convertViaJSON(c, obj); err != nil {
 59				response.Fatal(rsp, errors.Wrapf(err, "cannot convert %s to unstructured", name))
 60				return
 61			}
 62			desiredComposedResources[name] = &resource.DesiredComposed{Resource: c}
 63		}
 64
 65		if err := response.SetDesiredComposedResources(rsp, desiredComposedResources); err != nil {
 66			response.Fatal(rsp, errors.Wrap(err, "cannot set desired resources"))
 67			return
 68		}
 69	}()
 70
 71	var (
 72		cports []corev1.ContainerPort
 73		sports []corev1.ServicePort
 74	)
 75	if xr.Spec.Ports != nil {
 76		cports = make([]corev1.ContainerPort, len(*xr.Spec.Ports))
 77		sports = make([]corev1.ServicePort, len(*xr.Spec.Ports))
 78
 79		for i, p := range *xr.Spec.Ports {
 80			cports[i] = corev1.ContainerPort{
 81				ContainerPort: ptr.To(int32(p)),
 82			}
 83			sports[i] = corev1.ServicePort{
 84				Port:       ptr.To(int32(p)),
 85				TargetPort: new(utilv1.IntOrString),
 86			}
 87			_ = sports[i].TargetPort.FromInt(int(p))
 88		}
 89	}
 90
 91	labels := map[string]string{"app.kubernetes.io/name": *xr.Metadata.Name}
 92
 93	deployment := &appsv1.Deployment{
 94		APIVersion: ptr.To(appsv1.DeploymentAPIVersionAppsV1),
 95		Kind:       ptr.To(appsv1.DeploymentKindDeployment),
 96		Metadata: &metav1.ObjectMeta{
 97			Name:      xr.Metadata.Name,
 98			Namespace: xr.Metadata.Namespace,
 99			Labels:    &labels,
100		},
101		Spec: &appsv1.DeploymentSpec{
102			Replicas: ptr.To(int32(*xr.Spec.Replicas)),
103			Selector: &metav1.LabelSelector{
104				MatchLabels: &labels,
105			},
106			Template: &corev1.PodTemplateSpec{
107				Metadata: &metav1.ObjectMeta{
108					Labels: &labels,
109				},
110				Spec: &corev1.PodSpec{
111					Containers: &[]corev1.Container{{
112						Name:  xr.Metadata.Name,
113						Image: xr.Spec.Image,
114						Ports: &cports,
115					}},
116				},
117			},
118		},
119	}
120
121	desiredComposed["deployment"] = deployment
122
123	service := &corev1.Service{
124		APIVersion: ptr.To(corev1.ServiceAPIVersionV1),
125		Kind:       ptr.To(corev1.ServiceKindService),
126		Metadata: &metav1.ObjectMeta{
127			Name:      xr.Metadata.Name,
128			Namespace: xr.Metadata.Namespace,
129		},
130		Spec: &corev1.ServiceSpec{
131			Selector: &labels,
132			Ports:    &sports,
133		},
134	}
135
136	desiredComposed["service"] = service
137
138	return rsp, nil
139}
140
141func convertViaJSON(to, from any) error {
142	bs, err := json.Marshal(from)
143	if err != nil {
144		return err
145	}
146	return json.Unmarshal(bs, to)
147}

The function reads the observed WebApp XR, then builds a Deployment and a Service from its spec. The dev.crossplane.io/models packages are the bindings the CLI generated when you added the Kubernetes dependency, so the compiler checks the resources you create.

KCL is a good choice for functions with dynamic logic. It’s fast and sandboxed.

Generate a KCL function named compose-webapp and add it to the composition’s pipeline:

1crossplane function generate compose-webapp apis/webapps/composition.yaml --language kcl

The command scaffolds the function under functions/compose-webapp/ and adds a pipeline step to apis/webapps/composition.yaml.

Replace the contents of functions/compose-webapp/main.k with the following function logic:

 1import models.io.k8s.api.core.v1 as corev1
 2import models.com.example.platform.v1alpha1 as platformv1alpha1
 3import models.io.k8s.api.apps.v1 as appsv1
 4import models.io.k8s.apimachinery.pkg.apis.meta.v1 as metav1
 5
 6oxr = option("params").oxr # observed composite resource
 7dcds = option("params").dcds # desired composed resources
 8
 9_xr = platformv1alpha1.WebApp{**oxr}
10_replicas = int(_xr.spec.replicas)
11_ports = [int(p) for p in _xr.spec.ports]
12
13_labels = {"app.kubernetes.io/name": _xr.metadata.name}
14_metadata = lambda name: str -> any {
15    {
16        annotations = { "krm.kcl.dev/composition-resource-name" = name }
17        labels = _labels
18    }
19}
20
21_items = [
22    appsv1.Deployment{
23        metadata: _metadata("deployment")
24        spec: appsv1.DeploymentSpec{
25            replicas: _replicas
26            selector: metav1.LabelSelector{
27                matchLabels: _labels
28            }
29            template: corev1.PodTemplateSpec{
30                metadata: metav1.ObjectMeta{
31                    labels: _labels
32                }
33                spec: corev1.PodSpec{
34                    containers: [
35                        corev1.Container{
36                            name: _xr.metadata.name
37                            image: _xr.spec.image
38                            ports: [corev1.ContainerPort{containerPort: p} for p in _ports]
39                        }
40                    ]
41                }
42            }
43        }
44    },
45    corev1.Service{
46        metadata: _metadata("service")
47        spec: corev1.ServiceSpec{
48            selector: _labels
49            ports: [corev1.ServicePort{
50                protocol: "TCP"
51                port: p
52                targetPort: p
53            } for p in _ports]
54        }
55    }
56]
57items = _items

The function reads the observed WebApp XR through option("params").oxr, 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.

The composition now has two pipeline steps: your compose-webapp function, which creates the Deployment and Service, followed by function-auto-ready:

 1apiVersion: apiextensions.crossplane.io/v1
 2kind: Composition
 3metadata:
 4  name: webapps.platform.example.com
 5spec:
 6  compositeTypeRef:
 7    apiVersion: platform.example.com/v1alpha1
 8    kind: WebApp
 9  mode: Pipeline
10  pipeline:
11  - functionRef:
12      name: example-project-webappcompose-webapp
13    step: compose-webapp
14  - functionRef:
15      name: crossplane-contrib-function-auto-ready
16    step: crossplane-contrib-function-auto-ready

Add an example

If you generated your XRD from SimpleSchema, create an example WebApp so you can render and run the project against a realistic input. If you generated your XRD from an example XR, you already have the example and can skip this step.

Create examples/webapp/podinfo.yaml:

1apiVersion: platform.example.com/v1alpha1
2kind: WebApp
3metadata:
4  name: podinfo
5  namespace: default
6spec:
7  image: docker.io/stefanprodan/podinfo:6.11.0
8  replicas: 3
9  ports: [9898]

Render the composition

Before running the project, use crossplane composition render to preview what your composition produces. The render command runs your composition pipeline locally and prints the resources the composition would create.

In a project directory, render discovers and builds the composition’s functions automatically, so you only pass the example XR and the composition:

1crossplane composition render examples/webapp/podinfo.yaml apis/webapps/composition.yaml

The command prints the rendered Deployment and Service as well as the updates Crossplane would make to the WebApp XR:

 1---
 2apiVersion: platform.example.com/v1alpha1
 3kind: WebApp
 4metadata:
 5  name: podinfo
 6  namespace: default
 7spec:
 8  crossplane:
 9    resourceRefs:
10    - apiVersion: apps/v1
11      kind: Deployment
12      name: podinfo
13    - apiVersion: v1
14      kind: Service
15      name: podinfo
16status:
17  conditions:
18  - lastTransitionTime: "2024-01-01T00:00:00Z"
19    reason: WatchCircuitClosed
20    status: "True"
21    type: Responsive
22  - lastTransitionTime: "2024-01-01T00:00:00Z"
23    reason: ReconcileSuccess
24    status: "True"
25    type: Synced
26  - lastTransitionTime: "2024-01-01T00:00:00Z"
27    message: 'Unready resources: deployment, service'
28    reason: Creating
29    status: "False"
30    type: Ready
31---
32apiVersion: apps/v1
33kind: Deployment
34metadata:
35  annotations:
36    crossplane.io/composition-resource-name: deployment
37  labels:
38    app.kubernetes.io/name: podinfo
39    crossplane.io/composite: podinfo
40  name: podinfo
41  namespace: default
42  ownerReferences:
43  - apiVersion: platform.example.com/v1alpha1
44    blockOwnerDeletion: true
45    controller: true
46    kind: WebApp
47    name: podinfo
48    uid: 88356664-76da-5ea5-a715-b1bde80fe0a5
49spec:
50  replicas: 3
51  selector:
52    matchLabels:
53      app.kubernetes.io/name: podinfo
54  template:
55    metadata:
56      labels:
57        app.kubernetes.io/name: podinfo
58    spec:
59      containers:
60      - image: docker.io/stefanprodan/podinfo:6.11.0
61        name: podinfo
62        ports:
63        - containerPort: 9898
64---
65apiVersion: v1
66kind: Service
67metadata:
68  annotations:
69    crossplane.io/composition-resource-name: service
70  labels:
71    crossplane.io/composite: podinfo
72  name: podinfo
73  namespace: default
74  ownerReferences:
75  - apiVersion: platform.example.com/v1alpha1
76    blockOwnerDeletion: true
77    controller: true
78    kind: WebApp
79    name: podinfo
80    uid: 88356664-76da-5ea5-a715-b1bde80fe0a5
81spec:
82  ports:
83  - port: 9898
84    protocol: TCP
85    targetPort: 9898
86  selector:
87    app.kubernetes.io/name: podinfo

render runs the same composition logic as a real Crossplane control plane, so you can iterate on your function without deploying anything.

Run the project

When you’re happy with the rendered output, run the project on a local development control plane:

1crossplane project run

The run command:

  • creates a local development control plane in a KIND cluster and a local OCI registry
  • builds your embedded function into a Function xpkg
  • builds a Configuration xpkg containing your project’s XRD and Composition, with a dependency on your embedded function and function-auto-ready
  • pushes your project’s xpkgs to the local OCI registry
  • installs the Configuration package on the control plane
  • points your kubectl context at the development control plane

The first run takes some time while the CLI creates the cluster and installs Crossplane. Later runs reuse the cluster and are faster.

After run completes, your kubectl context points at the development control plane. Create the example WebApp:

1kubectl apply -f examples/webapp/podinfo.yaml

Check that the WebApp is ready:

1kubectl get webapp podinfo
2NAME      SYNCED   READY   COMPOSITION                    AGE
3podinfo   True     True    webapps.platform.example.com   45s

Check that Crossplane created the Deployment and Service:

1kubectl get deployment,service -l app.kubernetes.io/name=podinfo
2NAME                      READY   UP-TO-DATE   AVAILABLE   AGE
3deployment.apps/podinfo   3/3     3            3           45s
4
5NAME              TYPE        CLUSTER-IP      EXTERNAL-IP   PORT(S)    AGE
6service/podinfo   ClusterIP   10.96.142.110   <none>        9898/TCP   45s

Crossplane created a Deployment with three replicas and a Service, just as your function defined.

Tip
Edit the WebApp’s replicas or image and apply it again. Crossplane updates the Deployment to match.

When you’re done, delete the WebApp:

1kubectl delete -f examples/webapp/podinfo.yaml

Crossplane deletes the Deployment and Service along with the WebApp.

Tear down the development control plane:

1crossplane project stop

Next steps

You built a complete platform API with the Crossplane CLI: an API, a composition, and a function, all running on a local control plane.

To install your API on an existing Crossplane cluster, use crossplane project build and crossplane project push to package it and push it to an OCI registry. Remember to update the OCI repository in crossplane-project before running build and push.

After pushing your project to an OCI registry, you can install it using crossplane xpkg install configuration.

Tip
When you install the Configuration package built by crossplane project build, the Crossplane package manager will automatically install your project’s dependencies and embedded functions, which are declared as dependencies of the Configuration. To ensure updates work properly when you make changes to your functions in the future, configure your Crossplane cluster to automatically update dependency versions. This alpha feature is disabled by default.

To extend the WebApp, add more fields to the SimpleSchema document or example and regenerate the XRD, then update your function to use them. You can also add more dependencies with crossplane dependency add to compose managed resources from cloud providers alongside the Deployment and Service.

See the CLI command reference for the full set of commands.