This guide helps platform engineers migrate Crossplane compositions from function-kcl to function-starlark. It covers concept mapping, common patterns, side-by-side examples, and a step-by-step migration process.
function-starlark offers several advantages for composition authoring:
- Familiar Python-like syntax -- Starlark uses Python syntax that most engineers already know. No need to learn KCL's type system or schema language.
- Lightweight runtime -- Starlark compiles to bytecode with sub-second execution on cached programs. No external toolchain required. In benchmarks, function-starlark is 4.8x faster than function-kcl at 10 resources and 7.4x faster at 50 resources (see benchmarks).
- Simple mental model -- Imperative scripting with explicit
Resource()calls instead of assembling anitemslist. What you write is what you get. - Deterministic execution -- Starlark is hermetic by design. No file I/O, no network access, no non-determinism.
| KCL | Starlark | Notes |
|---|---|---|
option("params").oxr |
oxr |
Predeclared global, frozen (read-only) |
option("params").dxr |
dxr |
Predeclared global, mutable |
option("params").ocds |
observed |
Predeclared global, frozen dict of frozen dicts |
oxr.spec?.region or "default" |
get(oxr, "spec.region", "default") |
Safe nested access with default |
items = [...] |
Resource(name, body) |
Each call registers one desired resource |
| KCL schema / type annotations | Plain dicts | Starlark uses untyped dicts for resource bodies |
_resources = [...] if cond else [] |
if cond: Resource(...) |
Conditional resource creation |
[expr for x in range(n)] |
for x in range(n): Resource(...) |
Loop-based resource creation |
import module |
Not available | Starlark scripts are self-contained |
lambda x: expr |
lambda x: expr |
Both support lambdas |
krm.kcl.dev/composition-resource-name annotation |
First arg to Resource() |
Name is explicit, not an annotation |
oxr.metadata?.labels?["app.kubernetes.io/name"] or "" |
get_label(oxr, "app.kubernetes.io/name", "") |
Safe dotted-key label access |
oxr.metadata?.annotations?["key"] or "" |
get_annotation(oxr, "key", "") |
Safe annotation access |
dxr = {**oxr, status.field = val} |
set_xr_status("field", val) |
Dot-path status writes with auto-created intermediates |
ocds["name"]?.spec?.field or "" |
get_observed("name", "spec.field", "") |
One-call observed access |
function-starlark provides these predeclared globals:
| Global | Type | Mutable | Description |
|---|---|---|---|
oxr |
StarlarkDict | No (frozen) | Observed composite resource |
dxr |
StarlarkDict | Yes | Desired composite resource |
observed |
StarlarkDict | No (frozen) | Observed composed resources by name |
context |
dict | Yes | Pipeline context (read/write) |
environment |
StarlarkDict | No (frozen) | EnvironmentConfig data |
extra_resources |
dict | No (frozen) | Extra/required resources |
| Function | Signature | Description |
|---|---|---|
Resource |
Resource(name, body, ready=None, labels=None_or_dict, connection_details=None, depends_on=None, external_name=None) |
Register a desired composed resource; returns ResourceRef. depends_on accepts ResourceRef, string, or (ref, "field.path") tuple |
skip_resource |
skip_resource(name, reason) |
Remove a resource from desired state with a reason |
get |
get(obj, path, default=None) |
Safe nested dict access with dot-path or list-of-keys |
set_condition |
set_condition(type, status, reason, message, target="Composite") |
Set an XR condition |
emit_event |
emit_event(severity, message, target="Composite") |
Emit a Normal or Warning event |
fatal |
fatal(message) |
Halt execution with a fatal error |
set_connection_details |
set_connection_details(dict) |
Set XR-level connection details |
require_extra_resource |
require_extra_resource(name, apiVersion, kind, match_name=None, match_labels=None) |
Request a single extra resource |
require_extra_resources |
require_extra_resources(name, apiVersion, kind, match_labels) |
Request multiple extra resources by label selector |
get_label |
get_label(res, key, default=None) |
Safe label lookup handling dotted keys |
get_annotation |
get_annotation(res, key, default=None) |
Safe annotation lookup handling dotted keys |
set_xr_status |
set_xr_status(path, value) |
Dot-path XR status writes with auto-created intermediates |
get_observed |
get_observed(name, path, default=None) |
One-call observed resource field lookup |
KCL:
region = oxr.spec?.region or "us-east-1"
name = oxr.metadata?.name or "unknown"
# Deep access
zone = oxr.spec?.parameters?.networking?.zone or "default"Starlark:
region = get(oxr, "spec.region", "us-east-1")
name = get(oxr, "metadata.name", "unknown")
# Deep access
zone = get(oxr, "spec.parameters.networking.zone", "default")
# Keys with dots (e.g., annotation keys) use list-of-keys form
ann = get(oxr, ["metadata", "annotations", "app.kubernetes.io/name"], "")KCL:
items = [
{
apiVersion = "s3.aws.upbound.io/v1beta1"
kind = "Bucket"
metadata.name = "my-bucket"
metadata.annotations = {
"krm.kcl.dev/composition-resource-name" = "bucket"
}
spec.forProvider.region = region
}
]Starlark:
Resource("bucket", {
"apiVersion": "s3.aws.upbound.io/v1beta1",
"kind": "Bucket",
"metadata": {"name": "my-bucket"},
"spec": {
"forProvider": {
"region": region,
},
},
})Key differences:
- The resource name is the first argument to
Resource(), not an annotation. - Resource bodies are standard Python dicts with string keys, not KCL structs.
- Nested fields use nested dicts, not dot-notation (
spec.forProvider.region).
KCL:
_monitoring = [
{
apiVersion = "monitoring.example.io/v1"
kind = "Dashboard"
metadata.annotations = {
"krm.kcl.dev/composition-resource-name" = "dashboard"
}
spec.enabled = True
}
] if env == "prod" else []
items = _monitoring + _other_resourcesStarlark:
if env == "prod":
Resource("dashboard", {
"apiVersion": "monitoring.example.io/v1",
"kind": "Dashboard",
"spec": {"enabled": True},
})Starlark uses standard if statements. No need to build conditional lists
and concatenate them.
KCL:
_buckets = [
{
apiVersion = "s3.aws.upbound.io/v1beta1"
kind = "Bucket"
metadata.name = "bucket-${i}"
metadata.annotations = {
"krm.kcl.dev/composition-resource-name" = "bucket-${i}"
}
spec.forProvider.region = region
}
for i in range(3)
]
items = _bucketsStarlark:
for i in range(3):
Resource("bucket-%d" % i, {
"apiVersion": "s3.aws.upbound.io/v1beta1",
"kind": "Bucket",
"metadata": {"name": "bucket-%d" % i},
"spec": {
"forProvider": {"region": region},
},
})Key differences:
- Standard
forloop instead of list comprehension. - String formatting uses
%operator (Python 2 style) instead of${}interpolation.
KCL (XR-level):
# KCL uses oxr/dxr connection details fields or annotation-based approaches.
# The exact pattern depends on the function-kcl version.Starlark (XR-level):
set_connection_details({
"endpoint": "https://my-service.example.com",
"password": get(oxr, "spec.credentials.password", ""),
})Starlark (per-resource):
Resource("database", {
"apiVersion": "rds.aws.upbound.io/v1beta1",
"kind": "Instance",
"metadata": {"name": "my-db"},
"spec": {"forProvider": {"region": region}},
}, connection_details={
"host": "my-db.cluster.us-east-1.rds.amazonaws.com",
"port": "5432",
})KCL:
# KCL does not have built-in condition/event support.
# Platform engineers typically use annotation-based approaches or
# rely on Crossplane's automatic condition management.Starlark:
# Set a condition on the XR
set_condition(
type="Ready",
status="True",
reason="Available",
message="All resources provisioned",
)
# Emit an event
emit_event(severity="Normal", message="Provisioning complete")
# Halt execution on fatal error
if not valid:
fatal(message="Validation failed: missing required field")KCL:
_dxr = {
**dxr
status.ready = "True"
status.endpoint = endpoint
}
items = [_dxr] + _resourcesStarlark:
# Direct assignment (replaces entire status):
dxr["status"] = {
"ready": "True",
"endpoint": endpoint,
}
# Preferred: dot-path writes that preserve sibling fields:
set_xr_status("ready", "True")
set_xr_status("endpoint", endpoint)The dxr global is mutable. Direct assignment replaces the entire status dict.
Use set_xr_status() for incremental writes that auto-create intermediate
dicts and preserve existing sibling keys.
KCL:
# Context access depends on function-kcl version and configuration.Starlark:
# Read from context
existing = context["some-key"]
# Write to context (propagates to downstream pipeline steps)
context["my-function/status"] = "complete"KCL has a schema/type system. Starlark uses plain dicts. You lose compile-time type checking but gain simplicity. Validate inputs explicitly:
region = get(oxr, "spec.region", "")
if not region:
fatal(message="spec.region is required")KCL uses ${} interpolation. Starlark uses Python % formatting:
# KCL: name = "bucket-${i}"
# Starlark:
name = "bucket-%d" % i
name = "%s-%s" % (prefix, suffix)KCL uses dot-access (oxr.spec.region). Starlark StarlarkDicts support
dot-access for simple keys, but get() is safer for deeply nested access:
# Works but may raise KeyError if path is missing:
region = oxr.spec.region
# Safe -- returns default if any part of the path is missing:
region = get(oxr, "spec.region", "us-east-1")KCL uses True/False. Starlark also uses True/False (same as Python).
No difference here.
Starlark scripts are self-contained. You cannot import modules. Extract common logic into helper functions within the same script:
def make_bucket(name, region, env):
Resource(name, {
"apiVersion": "s3.aws.upbound.io/v1beta1",
"kind": "Bucket",
"metadata": {"name": name},
"spec": {
"forProvider": {
"region": region,
"tags": {"Environment": env},
},
},
})
for i in range(5):
make_bucket("bucket-%d" % i, region, env)KCL has ?. and or for optional chaining. Starlark uses get():
# KCL: value = oxr.spec?.optional?.field or "default"
# Starlark:
value = get(oxr, "spec.optional.field", "default")KCL uses the krm.kcl.dev/composition-resource-name annotation.
Starlark uses the first argument to Resource():
# KCL: metadata.annotations = {"krm.kcl.dev/composition-resource-name" = "my-resource"}
# Starlark:
Resource("my-resource", {...})List all compositions using function-kcl:
kubectl get compositions -o json | \
jq -r '.items[] | select(.spec.pipeline[]?.functionRef.name == "function-kcl") | .metadata.name'Deploy function-starlark alongside function-kcl (they can coexist):
cat <<EOF | kubectl apply -f -
apiVersion: pkg.crossplane.io/v1beta1
kind: Function
metadata:
name: function-starlark
spec:
package: ghcr.io/wompipomp/function-starlark:latest
EOFFor each KCL composition:
- Read the KCL source and identify resources, conditionals, and loops.
- Create the Starlark equivalent using the patterns above.
- Update the composition pipeline step:
- Change
functionRef.namefromfunction-kcltofunction-starlark. - Change
input.apiVersiontostarlark.fn.crossplane.io/v1alpha1. - Change
input.kindtoStarlarkInput. - Replace
input.spec.sourcewith the Starlark script.
- Change
Use crossplane render to compare outputs:
# Render the KCL version
crossplane render xr.yaml kcl-composition.yaml functions.yaml > kcl-output.yaml
# Render the Starlark version
crossplane render xr.yaml starlark-composition.yaml functions.yaml > starlark-output.yaml
# Compare (ignore field ordering)
diff <(yq -P 'sort_keys(..)' kcl-output.yaml) \
<(yq -P 'sort_keys(..)' starlark-output.yaml)Apply the updated composition to a staging cluster:
kubectl apply -f starlark-composition.yaml
# Create a test claim and verify resources
kubectl apply -f test-claim.yaml
kubectl get managed -l crossplane.io/composite=$(kubectl get xr -o name | head -1 | cut -d/ -f2)Once all compositions are migrated and verified:
kubectl delete function function-kclSee the example/ directory for a complete side-by-side comparison:
example/composition.yaml-- Starlark version (10 resources)example/kcl-composition.yaml-- KCL equivalentexample/expected-output.yaml-- Expected render output
The Starlark composition exercises all builtins: get(), Resource(),
set_condition(), emit_event(), set_connection_details(), dxr status
updates, conditionals, and loops.