Substitution Expansion in `workload validate`
ksail workload validate runs kubeconform against your Kubernetes manifests to catch schema errors before they reach the cluster. When your manifests use Flux postBuild substitutions (${VAR}, ${VAR:-default}, ${VAR:=default}), the validator needs concrete values to produce valid YAML for schema checking.
This guide explains how the expansion works, why environment variables are not read, and how to write substitutions that validate cleanly.
Why Local Environment Variables Are Not Used
Section titled “Why Local Environment Variables Are Not Used”workload validate is designed to be deterministic — the same manifests should produce the same validation result regardless of the machine, user, or environment they are run on. Reading from local environment variables would break this:
- A manifest that validates on a developer’s machine (because
$REPLICA_COUNTis set) might fail in CI (where it is not set). - Conversely, an incorrectly typed default (
replicas: "3"instead ofreplicas: 3) might slip through validation on a machine where the env var overrides the bad default.
Instead, KSail uses typed schema-aware placeholders derived from the Kubernetes JSON schema. Validation catches type mismatches at schema-check time, not at deploy time.
[!NOTE] Commands that deploy or push manifests (
workload apply,workload push,workload reconcile) do not expand Flux${VAR}substitutions from your local environment. They pass manifests through (or trigger GitOps reconciliation), and Flux performs substitution in-cluster via KustomizationpostBuild.substitute/substituteFrom. Onlyworkload validatesimulates substitutions client-side, using deterministic placeholders instead of reading environment variables.
Expansion Rules
Section titled “Expansion Rules”${VAR} — Bare Variable Reference (No Default)
Section titled “${VAR} — Bare Variable Reference (No Default)”A bare ${VAR} is replaced with a typed placeholder based on the JSON schema type of the field it occupies.
| Schema type | Placeholder value |
|---|---|
string |
"placeholder" |
integer |
0 |
number |
0.0 |
boolean |
true |
| unknown / no schema | "placeholder" |
Example:
spec: replicas: ${REPLICA_COUNT} # integer field → 0 image: ${IMAGE_REF} # string field → "placeholder" enableFeature: ${FEATURE_ON} # boolean field → trueAfter expansion:
spec: replicas: 0 image: placeholder enableFeature: true${VAR:-default} and ${VAR:=default} — Variable With Default
Section titled “${VAR:-default} and ${VAR:=default} — Variable With Default”When a default is provided (either :- or := syntax), the default value is used as-is and coerced to the schema type.
spec: replicas: ${REPLICA_COUNT:-3} # integer field → 3 (not "3") name: ${APP_NAME:-my-app} # string field → "my-app" debug: ${DEBUG_MODE:-false} # boolean field → false[!NOTE] Coercion only applies to scalar-only substitutions (when the entire field value is a single
${VAR:-...}). In mixed-text contexts (e.g.,prefix-${VAR}-suffix), the result is always a string: bare${VAR}references are replaced with the string placeholder"placeholder", while${VAR:-default}/${VAR:=default}expand to the provided default text.
Mixed-Text Substitution
Section titled “Mixed-Text Substitution”When a variable reference is embedded inside a larger string, the result is always a string:
spec: hostname: ${ENV}-app.example.com # → "placeholder-app.example.com" image: myrepo/${IMAGE_NAME:-nginx}:latest # → "myrepo/nginx:latest"Schema Inference
Section titled “Schema Inference”KSail loads JSON schemas from the kubeconform disk cache (populated on previous workload validate runs). Schema files are looked up from the OS user cache directory (for example ~/.cache/ksail/kubeconform/), or from ${TMPDIR}/ksail/kubeconform when the user cache directory is unavailable (such as in some CI environments).
- Core Kubernetes resources (Deployment, Service, ConfigMap, …): typed via kubernetes-json-schema when the relevant schema is present in the kubeconform disk cache
- CRDs (FluxCD, cert-manager, …): typed via the CRDs-catalog when their schemas are present in the kubeconform disk cache
- Resources without a cached schema (including core kinds or CRDs whose schemas have not yet been cached): all bare
${VAR}expand to"placeholder"(string)
[!NOTE] Placeholder type inference reads schemas only from the local kubeconform cache — it does not itself make any network requests while deciding how to type
${VAR}placeholders. Kubeconform may still download missing schemas from its configured remote schema locations on a cache miss (typically the first time it sees a given kind), and then caches them under the same directory for subsequent validations.
Practical Guidance
Section titled “Practical Guidance”Avoid False Validation Failures
Section titled “Avoid False Validation Failures”If workload validate reports a type error for a field that uses ${VAR} without a default, add a meaningful default of the correct type:
# Before — bare ${VAR} expands to "placeholder" (string), fails integer validationspec: replicas: ${REPLICA_COUNT}
# After — default 2 is correctly typed as an integerspec: replicas: ${REPLICA_COUNT:-2}Verify Your Defaults Are Well-Typed
Section titled “Verify Your Defaults Are Well-Typed”When using ${VAR:-default}, ensure the default value matches the field’s expected type. Incorrect defaults will cause validation failures even if the env var is always set at runtime:
# This will fail validation: "2" is a string, but replicas expects an integerspec: replicas: ${REPLICA_COUNT:-"2"}
# Correct: unquoted numeric defaultspec: replicas: ${REPLICA_COUNT:-2}Skip Validation for Specific Kinds
Section titled “Skip Validation for Specific Kinds”workload validate checks each resource against its JSON schema from the CRDs-catalog. When a CRD’s catalog schema is stale or missing, kubeconform can reject otherwise-valid manifests — for example flagging a newer field as additional properties ... not allowed. (Kubernetes Secrets are already skipped by default to avoid SOPS-related failures.)
Skip such kinds with the --skip-kinds flag:
ksail workload validate --skip-kinds Coroot,SealedSecret…or, so it also applies in CI (the ksail-cluster action with validate: "true"), via ksail.yaml:
spec: workload: validation: skipKinds: - CorootThe flag and the configured kinds are merged. Skip only the kinds whose schema is genuinely unavailable — skipped resources are not validated at all.
Use workload validate Before Pushing
Section titled “Use workload validate Before Pushing”Combine workload validate with workload push in your workflow to catch schema errors before they enter the GitOps pipeline:
# Validate first, then push if cleanksail workload validate && ksail workload pushThis is the same pattern the ksail-cluster composite GitHub Action uses when validate: "true" is set. See PR Preview Clusters for the full CI/CD workflow.
Multi-Environment Validation
Section titled “Multi-Environment Validation”Use --config to validate against the settings of a specific environment:
# Validate against staging schema/constraintsksail --config ksail.staging.yaml workload validate
# Validate against productionksail --config ksail.prod.yaml workload validateSee Multi-Environment Workflows for how to structure per-environment config files.
Supported Substitution Syntax
Section titled “Supported Substitution Syntax”KSail supports the standard Flux postBuild variable syntax:
| Syntax | Behavior during validation |
|---|---|
${VAR} |
Typed placeholder (schema-derived) |
${VAR:-default} |
default value, coerced to schema type |
${VAR:=default} |
default value, coerced to schema type |
${VAR} in mixed text |
Replaced with "placeholder" (string) |
Other Flux substitution forms (e.g., shell-style $VAR without braces, $(VAR) command substitution) are not expanded by KSail and are left as-is.