Validation Rules
This guide explains how to define CEL-based validation rules for ComponentTypes and Traits to enforce semantic constraints and cross-field relationships beyond basic schema validation.
Overview
Validation rules complement schema validation by enabling:
- Cross-field relationships - Validate that multiple fields work together correctly
- Domain-specific invariants - Enforce business logic constraints
- Rendering context validation - Check parameters against the workload, dataplane, and environment configuration resolved for the target environment
- Rendered output invariants - Assert that a guarantee still holds on the final rendered resources after all traits are applied
- Custom error messages - Provide clear, actionable feedback when validation fails
Validation rules use CEL expressions wrapped in ${} that must evaluate to true for validation to pass.
Validations run in two stages. Both stages are available on ComponentTypes and Traits (and their Cluster-scoped variants):
Pre-render (preRenderValidations) | Post-render (postRenderValidations) | |
|---|---|---|
| Runs | Before rendering | After all traits are applied |
| Evaluates against | Static configuration (parameters, environmentConfigs, workload, ...) | The final rendered Kubernetes resources |
| Rule shape | rule + message | target selection + rule + message, with optional when / forEach |
${...} in message | Interpolated | Not interpolated (shown literally) |
| Typical use | "These parameters are consistent" | "No trait undid my guarantee" |
A ComponentType or Trait is a reusable definition applied across every environment, so validation rules must never hard-code environment names ("production", "staging", ...) — the same definition has to work regardless of how a platform names its environments. To vary behavior per environment, expose the varying value or toggle in environmentConfigs and let the platform set it per environment via the ReleaseBinding. Write rules that check relationships and overrides, not environment-name string comparisons.
Context Variables in Validations
Validation rules have access to different context variables depending on scope:
ComponentType Context
metadata- Component metadata (componentName,componentNamespace,environmentName, labels, etc.). The environment name is present, but don't branch on it — see the warning aboveparameters- Component parameters with schema defaults appliedenvironmentConfigs- Environment-specific parameter overrides (from theReleaseBinding)workload- Workload specification (container, endpoints, workloadType)configurations- Configuration and secret referencesdataplane- DataPlane configuration (secretStore, gateway, etc.)gateway- Ingress gateway resolved for the environment (gateway.ingress.external/internal)environment- Environment-specific configuration (gateway overrides,defaultNotificationChannel)dependencies- Resolved endpoint connections and resource dependencies
Trait Context
All ComponentType variables plus:
trait.name- Name of the trait typetrait.instanceName- Unique instance name for this trait within the component
Post-render Context
Post-render rules evaluate with the declaring resource's own context (the same variables listed above), plus:
resource- The rendered Kubernetes resource matched by the validation'starget(bound per match, available intarget.whereandrule)
Pre-render Validations
Pre-render rules (spec.preRenderValidations) run against static configuration after schema defaults are applied, before any resource is rendered.
Rule Format
Each pre-render validation rule consists of two required fields:
preRenderValidations:
- rule: ${parameters.replicas >= 1}
message: "replicas must be at least 1"
- rule: ${parameters.port > 0 && parameters.port <= 65535}
message: "port must be between 1 and 65535"
| Field | Type | Required | Description |
|---|---|---|---|
rule | string | Yes | CEL expression wrapped in ${...} that must evaluate to true |
message | string | Yes | Error message shown when the rule evaluates to false |
validations is deprecatedspec.validations is a deprecated alias for spec.preRenderValidations with identical shape and semantics. Set only one of the two — specifying both on the same resource is rejected at admission time.
ComponentType Examples
Cross-field parameter validation
Use validation rules for cross-field relationships that a schema alone can't express. This web-service type exposes an autoscaling range as parameters and checks that the range is internally consistent:
apiVersion: openchoreo.dev/v1alpha1
kind: ComponentType
metadata:
name: web-service
spec:
workloadType: deployment
parameters:
openAPIV3Schema:
type: object
properties:
port:
type: integer
minimum: 1
maximum: 65535
autoscaling:
type: object
# default: {} makes the nested defaults apply even when the
# developer omits autoscaling entirely, so rules can read the
# fields without has() guards
default: {}
properties:
enabled:
type: boolean
default: false
minReplicas:
type: integer
default: 1
minimum: 1
maxReplicas:
type: integer
default: 5
minimum: 1
environmentConfigs:
openAPIV3Schema:
type: object
properties:
autoscaling:
type: object
# An environment that overrides the range must set both bounds
required: [minReplicas, maxReplicas]
properties:
minReplicas:
type: integer
minimum: 1
maxReplicas:
type: integer
minimum: 1
preRenderValidations:
# Cross-field parameter check: the autoscaling range must be internally consistent
- rule: ${!parameters.autoscaling.enabled || parameters.autoscaling.maxReplicas >= parameters.autoscaling.minReplicas}
message: "autoscaling.maxReplicas (${parameters.autoscaling.maxReplicas}) must be >= autoscaling.minReplicas (${parameters.autoscaling.minReplicas})"
Validating environment overrides
To vary behavior per environment, validate the per-environment overrides in environmentConfigs — the platform sets these through the ReleaseBinding, so the same rule works no matter what an environment is named.
The rules worth writing here read parameters and environmentConfigs together. The schema validates each one in isolation; only a rule can check that an environment's override makes sense for the component that declared it:
preRenderValidations:
# Cross-object check: an environment may only override the range if the component
# turned autoscaling on. Otherwise the override is silently ignored at render time.
- rule: ${!has(environmentConfigs.autoscaling) || parameters.autoscaling.enabled}
message: "this environment overrides the autoscaling range, but the component has autoscaling disabled"
# The override itself must stay internally consistent. has() is required here because
# environmentConfigs.autoscaling has no `default: {}` — it is genuinely absent in
# environments that don't override the range, unlike parameters.autoscaling above.
- rule: ${!has(environmentConfigs.autoscaling) || environmentConfigs.autoscaling.maxReplicas >= environmentConfigs.autoscaling.minReplicas}
message: "this environment's autoscaling override must keep maxReplicas (${environmentConfigs.autoscaling.maxReplicas}) >= minReplicas (${environmentConfigs.autoscaling.minReplicas})"
Because the schema requires both bounds whenever the override is present, a single has() on the object is enough — no per-field guards. The same holds for the other static-config variables listed in Context Variables: workload, dataplane, gateway, and dependencies are all in scope here and can be combined the same way.
Workload-based validation
These rules constrain the workload and relate endpoint visibility to the ingress resolved for the environment via the gateway context — never to an environment name:
preRenderValidations:
# A service must expose at least one endpoint
- rule: ${size(workload.endpoints) > 0}
message: "Service components must have at least one endpoint. Use 'deployment/worker' for components without endpoints."
# Restrict the allowed endpoint types
- rule: ${workload.endpoints.all(name, ep, ep.type == "HTTP")}
message: "This component only supports endpoints of type HTTP."
# Endpoints exposed externally need an external ingress on the Environment or DataPlane.
# gateway is resolved per environment, so this holds for every environment.
- rule: >-
${workload.endpoints.exists(name, ep, "external" in ep.visibility)
? has(gateway.ingress) && has(gateway.ingress.external)
: true}
message: "Endpoints with 'external' visibility require gateway.ingress.external to be configured on the Environment or DataPlane."
Trait Examples
Parameter validation
Prefer the schema for shape constraints — required fields, lengths, patterns, enums all belong there. Use rules for what the schema can't express:
apiVersion: openchoreo.dev/v1alpha1
kind: Trait
metadata:
name: persistent-volume
spec:
parameters:
openAPIV3Schema:
type: object
required: [volumeName, mountPath]
properties:
volumeName:
type: string
maxLength: 63
mountPath:
type: string
pattern: "^/"
accessMode:
type: string
enum: [ReadWriteOnce, ReadOnlyMany, ReadWriteMany]
default: ReadWriteOnce
preRenderValidations:
# A blacklist the schema can't express: block mounts over system temp directories,
# including anything nested beneath them (/tmp/cache, /var/tmp/build/out, ...)
- rule: ${!["/tmp", "/var/tmp"].exists(d, parameters.mountPath == d || parameters.mountPath.startsWith(d + "/"))}
message: "Cannot mount volumes to system temp directories /tmp or /var/tmp"
Workload-aware validation
preRenderValidations:
# Validate access modes for different workload types
- rule: ${parameters.accessMode == "ReadWriteOnce" || workload.workloadType == "statefulset"}
message: "ReadWriteMany and ReadOnlyMany access modes are only supported for StatefulSet workloads"
# Ensure container exists for volume mounts
- rule: ${!has(parameters.containerName) || has(workload.container)}
message: "Cannot mount volume: no container found in workload"
# Validate trait instance naming
- rule: ${trait.instanceName.matches("^[a-z]([a-z0-9-]*[a-z0-9])?$")}
message: "Trait instanceName must be lowercase DNS-compliant: start/end with alphanumeric, contain only lowercase letters, numbers, and hyphens"
Common Patterns
Context-aware checks
preRenderValidations:
# Access component metadata
- rule: ${size(metadata.componentName) <= 63}
message: "Component name must be 63 characters or less for DNS compatibility"
# Check dataplane capabilities
- rule: ${!parameters.externalAccess || has(dataplane.publicVirtualHost)}
message: "External access requires publicVirtualHost configuration in the dataplane"
# Validate against workload container
- rule: ${!has(parameters.containerPort) || parameters.containerPort == workload.container.port}
message: "Container port parameter must match workload container configuration"
List and map validation
preRenderValidations:
# Validate all items in a list
- rule: ${!has(parameters.databases) || parameters.databases.all(db, has(db.host) && has(db.port) && db.port > 0)}
message: "All databases must have valid host and port configuration"
# Check for required keys in maps
- rule: ${!has(parameters.secrets) || parameters.secrets.all(name, secret, has(secret.key))}
message: "All secrets must specify a key field"
# Validate uniqueness
- rule: ${!has(parameters.endpoints) || size(parameters.endpoints) == size(parameters.endpoints.map(ep, ep.name).distinct())}
message: "Endpoint names must be unique"
Conditional validation
preRenderValidations:
# Conditional requirements based on features.
# ssl is an optional object, so guard it with has() before reading enabled —
# an object without `default: {}` in the schema is absent, not empty.
- rule: ${!has(parameters.ssl) || !parameters.ssl.enabled || (has(parameters.ssl.certSecret) && has(parameters.ssl.keySecret))}
message: "SSL enabled requires both certificate and key secrets"
# Conditional requirement driven by a component parameter (author intent), not an environment name
- rule: ${!parameters.highAvailability || (parameters.replicas >= 2 && has(environmentConfigs.resources) && has(environmentConfigs.resources.limits))}
message: "highAvailability requires >=2 replicas and resource limits"
# Mutually exclusive options
- rule: ${[has(parameters.basicAuth), has(parameters.oauth)].filter(x, x).size() <= 1}
message: "Cannot enable both basicAuth and oauth authentication"
Post-render Validations
Pre-render rules see only static configuration — they cannot check what the rendering pipeline actually produced. Traits stack: each one can create, patch, or remove resources, so a guarantee established by the ComponentType or an earlier trait can be silently undone by a later one. postRenderValidations close this gap. They are CEL rules evaluated after all traits are applied, against the final rendered Kubernetes resources.
Post-render rules see the rendered manifests exactly as the traits produced them: no Kubernetes server-side defaulting or admission mutation has been applied, and OpenChoreo's own labels and owner references are not yet injected. A field the cluster would default (like spec.replicas) may simply be absent — guard reads with has().
Each entry selects rendered resources by GVK (plus an optional where filter), binds each match to the resource variable, and requires rule to evaluate to true:
| Field | Required | Description |
|---|---|---|
when | No | CEL guard evaluated against the trait/component context; if it evaluates to false, the validation is skipped |
forEach / var | No | Repeats the validation per item of a CEL-evaluated list; var names the loop variable (in scope for target.where and rule) |
target.group / .version / .kind | Yes | GVK of the rendered resources to select |
target.where | No | CEL filter over the selected resources, with resource bound |
target.mustMatch | No | Defaults to true: when no rendered resource matches the target, the validation fails |
targetPlane | No | dataplane (default) or observabilityplane |
rule | Yes | CEL expression wrapped in ${...}, evaluated with resource bound to each match; must evaluate to true |
message | Yes | Error message shown when the rule fails (a literal string — ${...} interpolation is not applied) |
A storage trait shows why this stage exists. With accessMode: ReadWriteOnce, the volume can only be attached to a single node, so the platform team's policy is to pin such workloads to a single replica. The trait can't enforce that on its own configuration: it neither renders nor patches replicas — the final value is the cumulative result of the ComponentType template and every other trait in the stack. Only the final rendered Deployment can answer whether the policy holds:
apiVersion: openchoreo.dev/v1alpha1
kind: Trait
metadata:
name: single-writer-pvc
spec:
parameters:
openAPIV3Schema:
type: object
properties:
accessMode:
type: string
enum: [ReadWriteOnce, ReadWriteMany]
default: ReadWriteOnce
postRenderValidations:
- when: ${parameters.accessMode == 'ReadWriteOnce'}
target:
group: apps
version: v1
kind: Deployment
# replicas may be absent from the rendered manifest, in which case
# Kubernetes defaults it to 1 — so absence passes
rule: ${!has(resource.spec.replicas) || resource.spec.replicas <= 1}
message: "ReadWriteOnce volume requires a single replica for exclusive write access"
If an HA trait anywhere in the stack patched replicas: 3, this rule sees the final value and fails the release with the message above — nothing from this release is deployed.
mustMatch defaults to true, so a target that matches zero rendered resources fails the validation. This catches the case where a later trait removed the resource entirely; set mustMatch: false only when the target is genuinely optional.
ComponentTypes support postRenderValidations with the same field shape and semantics. The only difference is that when, forEach, and rule bind the component context (parameters, environmentConfigs, ...) instead of a trait's. A ComponentType author can use this to assert that invariants it rendered — a hardened securityContext, resource limits — survived the whole trait stack.
Validation Execution and Error Handling
Execution Order
- Schema validation - Type checking and constraint validation happens first
- Default application - Schema defaults are applied to parameters and environmentConfigs
- Pre-render rule evaluation -
preRenderValidations(or the deprecatedvalidations) are evaluated against the static context: the ComponentType's rules first, then each trait's rules just before that trait is applied - Rendering - Base resources are rendered from the ComponentType templates, then each trait's creates, patches, and removes are applied in order
- Post-render rule evaluation -
postRenderValidationsfrom the ComponentType and every trait are evaluated against the final rendered resources, and failures from all of them are aggregated
A failure at any stage stops the pipeline — later stages don't run — and the release is not deployed. Failures within the same rule list are collected and reported together rather than stopping at the first.
Error Message Format
When validation fails, OpenChoreo provides structured error messages:
rule[0] "${parameters.replicas >= 1}" evaluated to false: replicas must be at least 1
Multiple failures are joined with ; :
rule[0] "${parameters.replicas >= 1}" evaluated to false: replicas must be at least 1; rule[1] "${parameters.port > 0}" evaluated to false: port must be greater than 0
Post-render failures are aggregated the same way, across all matched resources and every declaring ComponentType and trait. Unlike pre-render messages, ${...} inside a post-render message is not interpolated — it is shown literally. When a forEach iteration fails, the aggregated error already identifies the failing item, so you don't need to embed it in the message.
Best Practices for Error Messages
Interpolating context values into message (as below) works for pre-render rules only:
preRenderValidations:
# Bad - unclear and not actionable
- rule: ${parameters.value > 0}
message: "Invalid value"
# Good - specific and actionable
- rule: ${parameters.replicas > 0 && parameters.replicas <= 20}
message: "replicas must be between 1 and 20. Current value: ${parameters.replicas}"
# Good - includes context and guidance
- rule: ${!parameters.highAvailability || parameters.replicas >= 3}
message: "High availability mode requires at least 3 replicas. Set replicas >= 3 or disable highAvailability."
# Good - references documentation
- rule: ${parameters.storageClass in ["standard", "ssd", "premium"]}
message: "storageClass '${parameters.storageClass}' is not supported. Allowed values: standard, ssd, premium. See: https://docs.example.com/storage"
Testing Validation Rules
Testing pre-render rules across environments
Validation rules that read environmentConfigs run at render time, once the per-environment overrides from the ReleaseBinding are applied — not on the bare Component. So the same Component can pass in one environment and fail in another. Define one Component, then bind it to environments whose autoscaling overrides differ:
# The component is static and identical across every environment
apiVersion: openchoreo.dev/v1alpha1
kind: Component
metadata:
name: test-web-service
spec:
componentType:
kind: ComponentType
name: deployment/web-service
parameters:
port: 8080
autoscaling:
enabled: true
minReplicas: 2
maxReplicas: 5
The environment-specific autoscaling range comes from the ReleaseBinding, not the Component:
# Development: no override, so the override-consistency rule passes vacuously
apiVersion: openchoreo.dev/v1alpha1
kind: ReleaseBinding
metadata:
name: test-web-service-development
spec:
owner:
projectName: default
componentName: test-web-service
environment: development
releaseName: test-web-service-v1
# componentTypeEnvironmentConfigs omitted -> has(environmentConfigs.autoscaling) is false, rule passes
---
# Staging: overrides the range inconsistently (maxReplicas < minReplicas), so this environment FAILS
apiVersion: openchoreo.dev/v1alpha1
kind: ReleaseBinding
metadata:
name: test-web-service-staging
spec:
owner:
projectName: default
componentName: test-web-service
environment: staging
releaseName: test-web-service-v1
componentTypeEnvironmentConfigs:
autoscaling:
minReplicas: 8
maxReplicas: 4 # maxReplicas < minReplicas -> validation fails for this environment
Validation is driven entirely by the target environment's overrides — no environment name is baked into the ComponentType or the Component.
Testing post-render rules
Post-render rules are exercised by attaching a trait that breaks the invariant. With the single-writer-pvc trait from Post-render Validations attached, adding an HA trait that patches the replica count makes the rule fail against the final rendered Deployment — regardless of the order the traits appear in:
apiVersion: openchoreo.dev/v1alpha1
kind: Component
metadata:
name: test-single-writer
spec:
componentType:
kind: ComponentType
name: deployment/web-service
parameters:
port: 8080
traits:
- kind: Trait
name: single-writer-pvc
instanceName: data-volume
# accessMode defaults to ReadWriteOnce -> the post-render rule is active
- kind: Trait
name: high-availability
instanceName: ha
parameters:
replicas: 3 # patches the Deployment -> final replicas is 3, rule fails
The failure surfaces only once the component is bound to an environment, because rendering happens per environment. Unlike the pre-render case above, no override is needed to trigger it — the conflict is between the two traits, so every environment fails identically:
apiVersion: openchoreo.dev/v1alpha1
kind: ReleaseBinding
metadata:
name: test-single-writer-development
spec:
owner:
projectName: default
componentName: test-single-writer
environment: development
releaseName: test-single-writer-v1
# No componentTypeEnvironmentConfigs needed: the autoscaling override is optional,
# and the post-render failure comes from the trait stack, not from an override
The ReleaseBinding status reports the post-render failure and nothing from the release is deployed.
Verification Commands
# Apply the pre-render example (component + per-environment bindings)
kubectl apply -f test-web-service.yaml
# Apply the post-render example (component with conflicting traits + its binding)
kubectl apply -f test-single-writer.yaml
# Check binding status for validation failures
kubectl get releasebindings -o wide
# View detailed error messages for the failing pre-render environment
kubectl describe releasebinding test-web-service-staging
# View the post-render failure (the single-replica rule broken by the HA trait)
kubectl describe releasebinding test-single-writer-development
Related Resources
- Templating Syntax - CEL expressions and context variables
- Schema Syntax - Parameter validation and constraints
- Overview - ComponentTypes and Traits fundamentals
- Context Variables - Complete context reference
- ComponentType API - Full API specification
- Trait API - Full API specification