Proposed Crossplane KCL Composition Structure
April 24, 2026 ยท View on GitHub
This guide describes a practical project layout for Crossplane + KCL compositions and a delivery flow that keeps runtime updates small and predictable.
Why this structure
- Use typed KCL schemas and provider objects, not plain YAML string generation.
- Keep orchestration logic in
main.kand reusable calculations inparams.k. - Keep validation rules in
asserts.kso failures happen early with explicit messages. - Validate behavior with focused
tests/*.ksuites before publishing a new module tag. - Publish a versioned OCI artifact and update only the tag in
Composition.
Recommended layout
machine-deployment/
main.k
dxr.k
params.k
asserts.k
instances.k
disks.k
loadbalancer.k
loadbalancer_rule.k
usages.k
nics.k
vm_static_scrape.k
kcl.mod
params.yaml
tests/
main_render_test.k
params_test.k
instance_test.k
disk_test.k
loadbalancer_test.k
loadbalancer_rule_test.k
usages_test.k
nics_test.k
vm_static_scrape_test.k
Core files and responsibilities
params.yaml / kcl.yaml (local input emulation)
Use an input file to emulate Crossplane runtime payload during development. This lets you test rendering and assertions before deploying.
Core idea:
- emulate
oxr(input XR from Crossplane) - emulate
ctx(data from previous functions, for example environment config) - emulate
ocds(observed composed resources already created)
Minimal structure:
kcl_options:
- key: params
value:
ocds: {}
ctx:
"apiextensions.crossplane.io/environment": {}
oxr:
metadata:
name: machine-service
spec: {}
From your real params.yaml examples:
oxrincludes XR spec, e.g.additionalNICNetworks,os,size,replicas,ports.ctx."apiextensions.crossplane.io/environment"includes AMI/network/project/zone config gathered by other functions.ocdscan contain data from already created resources, for example:
ocds:
"playti-service-vip":
Resource:
status:
atProvider:
ipAddress: "1.2.3.4"
That enables local validation of DXR patch logic like:
status.externalIP.ip = params.ocds[params._vip_name]?.Resource?.status?.atProvider?.ipAddress or "Provisioning..."
Run commands:
- If file name is
params.yaml:kcl run -Y params.yaml - If file name is
kcl.yaml:kcl run
composition.yaml (pipeline entrypoint)
Keep the composition pipeline short and explicit:
apiVersion: apiextensions.crossplane.io/v1
kind: Composition
metadata:
name: machine-deployment
spec:
mode: Pipeline
pipeline:
- step: gather-env-config
functionRef:
name: function-environment-configs
- step: render-machine-deployment
functionRef:
name: function-kcl
input:
apiVersion: krm.kcl.dev/v1alpha1
kind: KCLInput
spec:
# target: Default (or omit target)
source: oci://registry.example.com/machine-deployment?tag=0.2.0
main.k
Entry-point that imports each resource module and returns one items list for Crossplane:
import instances as instances
import disks as disks
import loadbalancer as loadbalancer
import loadbalancer_rule as loadbalancer_rule
import vm_static_scrape as vm_static_scrape
import usages as usages
import nics as nics
import dxr as dxr_patch
items = [
*instances._listOfInstances
*disks._listOfDisks
*loadbalancer._listOfLoadbalancers
*loadbalancer_rule._listOfLoadbalancerRules
*vm_static_scrape._listOfVMStaticScrapes
*usages._listOfUsages
*nics._listOfNICs
dxr_patch._dxr
]
dxr.k (patch XR status)
Use a dedicated module to patch the desired XR status:
import params as params
_dxr = {
**params.dxr
status.externalIP.ip = params.ocds[params._vip_name]?.Resource?.status?.atProvider?.ipAddress or "Provisioning..."
}
Important behavior:
- This works when
spec.targetisDefault(or not set, becauseDefaultis implicit). - This does not work with
spec.target: Resources, becauseResourcesmode only returns composed resources and does not patch XR fields.
params.k (minimal context include example)
_params = option("params")
oxr = _params.oxr
env: any = _params.ctx?["apiextensions.crossplane.io/environment"] or {}
ocds = _params.ocds
dxr = _params.dxr or {}
_vip_name = "{}-vip".format(oxr.metadata.name)
kcl.mod
Module metadata and dependency lock-point:
- module name and semantic version
- KCL edition
- OCI/path dependencies for Crossplane/provider schemas
Schema modules are connected here (provider/Crossplane APIs used by typed objects in files like instances.k):
[dependencies]
crossplane = { oci = "oci://registry.example.com/kcl-lang/crossplane", tag = "1.17.3", version = "1.17.3" }
crossplane-provider-cloudstack = { oci = "oci://registry.example.com/crossplane-provider-cloudstack", tag = "0.0.1", version = "0.0.1" }
After changing kcl.mod dependencies, run:
kcl mod update
This matches the helper workflow from kcl_command_helper.md and ensures schema modules are downloaded/updated before kcl run, kcl test, or packaging.
instances.k (typed schema/object example)
Use provider object types directly (schema-backed resources), not ad-hoc YAML maps:
import crossplane_provider_cloudstack.v1alpha1 as cloudstack
import params as params
build_instance = lambda hostname: str, size: str, os: str, revision: str -> cloudstack.Instance {
cloudstack.Instance {
metadata: {
name: hostname
annotations: {
"krm.kcl.dev/composition-resource-name" = hostname
}
}
spec: {
forProvider: {
serviceOffering: size
template: params._resolve_template(size, os)
}
}
}
}
This approach gives stronger contracts than raw YAML generation:
- API fields are aligned to provider schemas.
- Refactoring is safer across modules.
- Tests can assert behavior at object level.
params.k
Central place for:
- runtime inputs from
option("params")(oxr,ocds,dxr,ctx) - naming and hash helpers
- upgrade/stateful switches
- template resolution
- derived runtime flags (readiness, pause conditions)
asserts.k
Validation and guardrails:
- duplicate names and required field checks
- unsupported scenarios (for example, blocked combinations)
- naming length budget constraints
- environment/template resolvability checks
Small example:
import params as params
assert params._vip_name != "", "VIP resource name must not be empty"
tests/*.k
Use targeted tests for each module plus integration-style render checks.
At minimum:
- one render-count test for final
items - parameter derivation tests (
params.k) - resource-generation tests per module
- regression tests for naming and upgrade behavior
Small example (tests/main_render_test.k style):
import ..main as main
test_main_items_render = lambda {
assert len(main.items) > 0, "main.items must not be empty"
True
}
Pipeline composition example (tag-based)
apiVersion: apiextensions.crossplane.io/v1
kind: Composition
metadata:
name: machine-deployment
spec:
mode: Pipeline
compositeTypeRef:
apiVersion: platform.example.io/v1alpha1
kind: MachineDeployment
pipeline:
- step: gather-env-config
functionRef:
name: function-environment-configs
input:
apiVersion: environmentconfigs.fn.crossplane.io/v1beta1
kind: Input
spec:
environmentConfigs:
- type: Reference
ref:
name: core-details
- type: Reference
ref:
name: images
- step: render-machine-deployment
functionRef:
name: function-kcl
input:
apiVersion: krm.kcl.dev/v1alpha1
kind: KCLInput
spec:
# Keep target as Default (or omit target) if you patch XR via dxr.k
# target: Default
source: oci://registry.example.com/machine-deployment?tag=0.2.0
- step: auto-ready
functionRef:
name: function-auto-ready
Delivery flow: package once, replace only tag
- Build module artifact:
kcl mod update
kcl mod pkg --vendor --target build
- Push as OCI tag:
kcl mod push --vendor "oci://registry.example.com/machine-deployment?tag=0.2.0"
- Update only the
sourcetag inComposition:
spec:
pipeline:
- step: render-machine-deployment
input:
spec:
source: oci://registry.example.com/machine-deployment?tag=0.2.1
With this model, no large in-cluster template replacement is needed. The pipeline always pulls a versioned module archive, and rollout is controlled by tag changes.