Create a composition with Python
Upbound Crossplane allows you to choose how you want to write your composition logic based on your preferred language.
You can choose:
Go - High performance. IDE support with full type safety.
Go Templates - Good for YAML-like configurations. IDE support with YAML language server.
KCL - Concise. Good for transitioning from another configuration language
like HCL. IDE support with language server.
Python (this guide) - Highly accessible, supports complex logic. Provides type hints and autocompletion in your IDE.
Overview
This guide explains how to create compositions that turn your XRs into actual cloud resources. Compositions allow you to implement the business logic that powers your control plane.
This guide assumes you're familiar with Python. If you'd like to become more familiar with Python, the official Python tutorial is a good place to start.
Use this guide after you define your API schema and need to write the logic that creates and manages the underlying resources.
Prerequisites
Before you begin, make sure:
- You designed your XRD
- You've added provider dependencies
- You have a Docker-compatible container runtime running.
upbuilds Python functions inside a container - You have Python 3.11, 3.12, or 3.13 installed for IDE support
- You have the Python VS Code extension installed
- You understand your XRD schema and what resources you need to create
Create your composition scaffold
Use the XRD you created in the previous step to generate a new composition:
up composition generate apis/<your_resource_name>/definition.yaml
This command creates apis/<your_resource_name>/composition.yaml which
references the XRD.
Generate your function
Use your chosen programming language to generate a new function:
up function generate --language=python compose-resources apis/<your_resource_name>/composition.yaml
This command creates a functions/compose-resources directory with your function
code and updates your composition file to reference it.
The function is a Python project built on the Crossplane Python function SDK:
functions/compose-resources/
├── pyproject.toml # Project metadata and dependencies
├── README.md
└── function/
├── __init__.py
├── __version__.py
├── fn.py # Your composition logic
└── main.py # Entrypoint that serves the function
Write your composition logic in function/fn.py. You don't need to edit
main.py. The generated function/fn.py should be similar to:
"""A Crossplane composition function."""
import grpc
from crossplane.function import logging, response
from crossplane.function.proto.v1 import run_function_pb2 as fnv1
from crossplane.function.proto.v1 import run_function_pb2_grpc as grpcv1
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)
# Add your composition logic here. For example, to compose desired
# resources, populate rsp.desired.resources using
# crossplane.function.resource.
return rsp
Crossplane calls RunFunction each time it reconciles your composite resource.
response.to(req) creates a response that already contains the desired state
and context from earlier steps in the pipeline. Update rsp and return it.
The shorter examples on this page omit the generated __init__ method and
logging. Each example is still a complete function/fn.py.
Add dependencies
Your function's dependencies are in the dependencies list in its
pyproject.toml:
dependencies = [
"crossplane-function-sdk-python==0.11.0",
"click==8.3.2",
"grpcio>=1.73.1",
"crossplane-models @ file:./../../.up/python",
]
To use another package from PyPI, add it to this list. up project build
installs it when it builds your function.
up installs dependencies from wheels only, so it can build your function for
more than one architecture. Each package must publish a pure-Python wheel or a
manylinux2014 wheel for every architecture you build. Packages that only
publish a source distribution fail to install.
The crossplane-models entry installs the models up generates for
your project. up function generate only adds this entry when your project
already has generated Python models. If it's missing, run up project build,
then add the entry with the relative path from your function directory to
.up/python.
Models
Upbound Official Providers and some other packages include Pydantic models for their resources. These models enable in-line documentation, linting, autocompletion, and other features when working with Crossplane resources in embedded Python functions.
up generates models for your project's dependencies and XRDs in the
.up/python directory. It packages them as a Python distribution named
crossplane-models, which provides a top-level models package. Your function
depends on crossplane-models in its pyproject.toml, so import models from
models:
from models.com.example.platform.storagebucket import v1alpha1
from models.io.upbound.m.aws.s3.bucket import v1beta1 as bucketv1beta1
Each import path is the resource's API group in reverse, followed by its
lowercase kind and API version. For example, the Bucket kind in the
s3.aws.m.upbound.io/v1beta1 API version is in
models.io.upbound.m.aws.s3.bucket.v1beta1.
To add new models, add the package dependency with the up CLI:
up dependency add xpkg.upbound.io/upbound/provider-aws-s3
When you change an XRD, rebuild your project to regenerate its models:
up project build # Generate models in .up/python directory
The generated model module and class name match your XRD's kind exactly. The
examples on this page assume a v2 XRD (apiextensions.crossplane.io/v2) with
kind: StorageBucket and optional spec.parameters.region,
spec.parameters.acl, and spec.parameters.versioning fields. If your XRD uses the legacy v1 API
(apiextensions.crossplane.io/v1), your XRD's kind uses an X prefix, for
example kind: XStorageBucket, so import from models...xstoragebucket and
use v1alpha1.XStorageBucket instead.
Version 2 Upbound Official Providers include namespaced resources, like
s3.aws.m.upbound.io, and cluster-scoped resources, like s3.aws.upbound.io.
A v2 XRD creates namespaced composite resources by default, and namespaced
composite resources can only compose namespaced resources. The examples on this
page use the namespaced models in models.io.upbound.m.
The imports in this example are specifically for AWS S3 buckets. They follow a similar structure for all resources:
fnv1- Provides protocol buffer types for function communicationgrpcv1- Provides theFunctionRunnerServicebase class for your functionv1alpha1- References your XRD's generated Pydantic modelbucketv1beta1- The AWS S3 provider's Pydantic model
Optional and required fields
Upbound's Python models know which resource fields Crossplane requires and which are optional.
Required fields have a specific type, like str - a string.
Python raises an exception if you create a model without supplying a required field. This can be a problem when updating the desired composite resource (XR).
You should only include the fields your function has an opinion about when you update the desired XR. This can be a problem if for example Crossplane requires an XR spec field, but your function only wants to update a status field.
When updating the desired XR, you can avoid issues due to required fields by
using the resource's status model directly. This example assumes your XRD
defines a status.replicas field:
import grpc
from crossplane.function import resource, response
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.storagebucket import v1alpha1
class FunctionRunner(grpcv1.FunctionRunnerService):
async def RunFunction(
self, req: fnv1.RunFunctionRequest, _: grpc.aio.ServicerContext
) -> fnv1.RunFunctionResponse:
rsp = response.to(req)
# Include any desired status from previous functions in the pipeline.
desired_xr = resource.struct_to_dict(req.desired.composite.resource)
desired_xr_status = v1alpha1.Status(**desired_xr.get("status", {}))
# Update only the status field your function is concerned with.
desired_xr_status.replicas = 3
# Dump the model as a Python dictionary.
resource.update(
rsp.desired.composite,
{"status": desired_xr_status.model_dump(exclude_none=True)},
)
return rsp
Optional fields have a union type with None, like str | None. This means the field can be a string, or None - Python's null value.
Your editor's type checker warns you when you copy an optional field to a required field.
For example, the type checker warns you if you try to copy an optional
spec.parameters.region field from an XR to a required
spec.forProvider.region field of an MR:
import grpc
from crossplane.function import resource, response
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.storagebucket import v1alpha1
from models.io.upbound.m.aws.s3.bucket import v1beta1 as bucketv1beta1
class FunctionRunner(grpcv1.FunctionRunnerService):
async def RunFunction(
self, req: fnv1.RunFunctionRequest, _: grpc.aio.ServicerContext
) -> fnv1.RunFunctionResponse:
rsp = response.to(req)
observed_xr = v1alpha1.StorageBucket(**resource.struct_to_dict(req.observed.composite.resource))
params = observed_xr.spec.parameters or v1alpha1.Parameters()
desired_bucket = bucketv1beta1.Bucket(
spec=bucketv1beta1.Spec(
forProvider=bucketv1beta1.ForProvider(
region=params.region, # Warning: Argument of type "str | None" cannot be assigned to parameter "region" of type "str"
),
),
)
resource.update(rsp.desired.resources["bucket"], desired_bucket)
return rsp
spec.parameters is also optional, so this example falls back to an empty
v1alpha1.Parameters() model when the XR doesn't set it.
You can address this warning two ways.
If the optional field could be None in practice, handle that case by specifying a default value.
import grpc
from crossplane.function import resource, response
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.storagebucket import v1alpha1
from models.io.upbound.m.aws.s3.bucket import v1beta1 as bucketv1beta1
class FunctionRunner(grpcv1.FunctionRunnerService):
async def RunFunction(
self, req: fnv1.RunFunctionRequest, _: grpc.aio.ServicerContext
) -> fnv1.RunFunctionResponse:
rsp = response.to(req)
observed_xr = v1alpha1.StorageBucket(**resource.struct_to_dict(req.observed.composite.resource))
params = observed_xr.spec.parameters or v1alpha1.Parameters()
desired_bucket = bucketv1beta1.Bucket(
spec=bucketv1beta1.Spec(
forProvider=bucketv1beta1.ForProvider(
region=params.region or "us-west-2", # Default to "us-west-2" if region is None.
),
),
)
resource.update(rsp.desired.resources["bucket"], desired_bucket)
return rsp
If the optional field can't be None in practice, use a type: ignore comment to silence the warning.
import grpc
from crossplane.function import resource, response
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.storagebucket import v1alpha1
from models.io.k8s.apimachinery.pkg.apis.meta import v1 as metav1
from models.io.upbound.m.aws.s3.bucket import v1beta1 as bucketv1beta1
class FunctionRunner(grpcv1.FunctionRunnerService):
async def RunFunction(
self, req: fnv1.RunFunctionRequest, _: grpc.aio.ServicerContext
) -> fnv1.RunFunctionResponse:
rsp = response.to(req)
observed_xr = v1alpha1.StorageBucket(**resource.struct_to_dict(req.observed.composite.resource))
desired_bucket = bucketv1beta1.Bucket(
metadata=metav1.ObjectMeta(
name=observed_xr.metadata.name + "-bucket", # type: ignore # The observed XR always has a name.
),
spec=bucketv1beta1.Spec(
forProvider=bucketv1beta1.ForProvider(
region="us-west-2",
),
),
)
resource.update(rsp.desired.resources["bucket"], desired_bucket)
return rsp
Create your function logic
Next, add the function logic. The example below creates an S3 bucket:
import grpc
from crossplane.function import logging, resource, response
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.storagebucket import v1alpha1
from models.io.upbound.m.aws.s3.bucket import v1beta1 as bucketv1beta1
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")
# Start from the desired state and context of earlier pipeline steps.
rsp = response.to(req)
# Load the observed XR into a Pydantic model.
observed_xr = v1alpha1.StorageBucket(**resource.struct_to_dict(req.observed.composite.resource))
params = observed_xr.spec.parameters or v1alpha1.Parameters()
# Create the cloud resource specification.
desired_bucket = bucketv1beta1.Bucket(
spec=bucketv1beta1.Spec(
forProvider=bucketv1beta1.ForProvider(
region=params.region or "us-west-2",
),
),
)
resource.update(rsp.desired.resources["bucket"], desired_bucket)
return rsp
Inputs
Function logic determines how Crossplane handles your resource creation.
In the RunFunctionRequest, there are four inputs that Crossplane can parse:
-
Observed state: What real resources currently exist?
# The API requestobserved_xr = v1alpha1.StorageBucket(**resource.struct_to_dict(req.observed.composite.resource)) -
Desired state: What resources should exist?
# Create the cloud resource specificationdesired_bucket = bucketv1beta1.Bucket(spec=bucketv1beta1.Spec(forProvider=bucketv1beta1.ForProvider(region=params.region or "us-west-2",),),) -
Function logic - What does Crossplane do?
# Reconcile the desired and observed statesresource.update(rsp.desired.resources["bucket"], desired_bucket)
- Pipeline context - Information to pass to subsequent functions in the pipeline.
For a more complex version of a Python function, expand the example below:
A more advanced Python function
The function/fn.py file below takes a composite resource (XR) as input and
produces managed resources (MRs) from the
S3 provider
based on its parameters.
The function always composes an S3 bucket. When the S3 bucket exists, it also composes a bucket access control list (ACL), ownership controls, a public access block, and a server-side encryption configuration. Each of these resources references the bucket by name.
If the composite resource's spec.parameters.versioning field is True, the
function enables versioning by composing a bucket versioning configuration. Like
the ACL, the versioning configuration references the bucket by name.
import grpc
from crossplane.function import logging, resource, response
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.storagebucket import v1alpha1
from models.io.upbound.m.aws.s3.bucket import v1beta1 as bucketv1beta1
from models.io.upbound.m.aws.s3.bucketacl import v1beta1 as aclv1beta1
from models.io.upbound.m.aws.s3.bucketownershipcontrols import v1beta1 as bocv1beta1
from models.io.upbound.m.aws.s3.bucketpublicaccessblock import v1beta1 as pabv1beta1
from models.io.upbound.m.aws.s3.bucketserversideencryptionconfiguration import (
v1beta1 as ssev1beta1,
)
from models.io.upbound.m.aws.s3.bucketversioning import v1beta1 as verv1beta1
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)
rsp = response.to(req)
observed_xr = v1alpha1.StorageBucket(**resource.struct_to_dict(req.observed.composite.resource))
params = observed_xr.spec.parameters or v1alpha1.Parameters()
if params.region is None:
response.fatal(rsp, "spec.parameters.region is required")
return rsp
desired_bucket = bucketv1beta1.Bucket(
spec=bucketv1beta1.Spec(
forProvider=bucketv1beta1.ForProvider(
region=params.region,
),
),
)
resource.update(rsp.desired.resources["bucket"], desired_bucket)
# The desired ACL, encryption, and versioning resources all need to refer to
# the bucket by its external name, which is stored in its external name
# annotation. Return early if the Bucket's external-name annotation isn't
# set yet.
if "bucket" not in req.observed.resources:
log.info("Waiting for the bucket to be created")
return rsp
observed_bucket = bucketv1beta1.Bucket(
**resource.struct_to_dict(req.observed.resources["bucket"].resource)
)
if observed_bucket.metadata is None or observed_bucket.metadata.annotations is None:
return rsp
if "crossplane.io/external-name" not in observed_bucket.metadata.annotations:
return rsp
bucket_external_name = observed_bucket.metadata.annotations[
"crossplane.io/external-name"
]
desired_acl = aclv1beta1.BucketACL(
spec=aclv1beta1.Spec(
forProvider=aclv1beta1.ForProvider(
region=params.region,
bucket=bucket_external_name,
acl=params.acl,
),
),
)
resource.update(rsp.desired.resources["acl"], desired_acl)
desired_boc = bocv1beta1.BucketOwnershipControls(
spec=bocv1beta1.Spec(
forProvider=bocv1beta1.ForProvider(
region=params.region,
bucket=bucket_external_name,
rule=bocv1beta1.Rule(
objectOwnership="BucketOwnerPreferred",
),
),
),
)
resource.update(rsp.desired.resources["boc"], desired_boc)
desired_pab = pabv1beta1.BucketPublicAccessBlock(
spec=pabv1beta1.Spec(
forProvider=pabv1beta1.ForProvider(
region=params.region,
bucket=bucket_external_name,
blockPublicAcls=False,
ignorePublicAcls=False,
restrictPublicBuckets=False,
blockPublicPolicy=False,
),
),
)
resource.update(rsp.desired.resources["pab"], desired_pab)
desired_sse = ssev1beta1.BucketServerSideEncryptionConfiguration(
spec=ssev1beta1.Spec(
forProvider=ssev1beta1.ForProvider(
region=params.region,
bucket=bucket_external_name,
rule=[
ssev1beta1.RuleItem(
applyServerSideEncryptionByDefault=ssev1beta1.ApplyServerSideEncryptionByDefault(
sseAlgorithm="AES256",
),
bucketKeyEnabled=True,
),
],
),
),
)
resource.update(rsp.desired.resources["sse"], desired_sse)
# Return early without composing a BucketVersioning MR if the XR doesn't
# have versioning enabled.
if not params.versioning:
return rsp
desired_versioning = verv1beta1.BucketVersioning(
spec=verv1beta1.Spec(
forProvider=verv1beta1.ForProvider(
region=params.region,
bucket=bucket_external_name,
versioningConfiguration=verv1beta1.VersioningConfiguration(
status="Enabled",
),
),
),
)
resource.update(rsp.desired.resources["versioning"], desired_versioning)
return rsp
Outputs
RunFunctionResponse returns three outputs which update the state of the
control plane:
- Desired state of your composed resources
- Status conditions to apply to the composite resource or claim
- Context to pass to other functions in the pipeline
response.to(req) creates a RunFunctionResponse that's pre-populated with the
request's desired state and context. Python functions only need to update the
fields in the objects that need to change, then return the response.
You can select the RunFunctionResponse object in Visual Studio Code to see what fields it has.
The Python function SDK generates the RunFunctionResponse object from a protobuf
definition. Read the Python Generated Code Guide to learn about protobuf
generated code.
You can add or update composed resources using the resource.update helper function in the Crossplane Python SDK:
composed = ... # Construct a composed resource
resource.update(rsp.desired.resources["my-resource"], composed)
Similarly, you can update the status of the composite resource by updating it in the response:
status_update = {
"status": {
"someInformation": "cool-status"
}
}
resource.update(rsp.desired.composite, status_update)
If you don't want to use a model, you can also pass resource.update a Python dictionary.
import grpc
from crossplane.function import resource, response
from crossplane.function.proto.v1 import run_function_pb2 as fnv1
from crossplane.function.proto.v1 import run_function_pb2_grpc as grpcv1
class FunctionRunner(grpcv1.FunctionRunnerService):
async def RunFunction(
self, req: fnv1.RunFunctionRequest, _: grpc.aio.ServicerContext
) -> fnv1.RunFunctionResponse:
rsp = response.to(req)
resource.update(rsp.desired.composite, {
"status": {
"replicas": 3,
},
})
return rsp
Migrate from the single-file format
Before up v0.50.0, up function generate --language=python created a single
main.py file with a compose(req, rsp) function, a requirements.txt file,
and a model link to the generated models.
up project build still builds functions in the single-file format. up
chooses how to build each function from the files in its directory:
- A
pyproject.tomlfile and afunction/directory use the format this guide describes. - A
main.pyfile uses the single-file format.
Migrate a function to add dependencies from PyPI. To migrate a function, for
example compose-resources:
-
Move the existing function out of the
functionsdirectory:mv functions/compose-resources compose-resources-old -
Generate a new function with the same name. Leave out the composition path, because your composition already references the function:
up function generate --language=python compose-resources -
Move your logic from
compose()incompose-resources-old/main.pyintoRunFunctioninfunctions/compose-resources/function/fn.py:- Keep
rsp = response.to(req)at the start ofRunFunctionandreturn rspat the end. - Change any other
returnstatement toreturn rsp. - Change model imports from
from .model.tofrom models..
- Keep
-
Add any packages from
compose-resources-old/requirements.txtto thedependencieslist infunctions/compose-resources/pyproject.toml. The function SDK already depends on Pydantic. -
Build your project, then delete the old function:
up project buildrm -r compose-resources-old