Skip to content

Validating Configuration and Enforcing Policies

ConfigHub Triggers enable you to automatically validate configuration data and enforce policies across your Spaces. This guide covers the practicalities of setting up, managing, and troubleshooting Triggers, Validation Errors, and related workflows.

For background on the concepts, see:

Creating Triggers

A Trigger associates a Function with an Event type (e.g., Mutation, PostClone) and a configuration format (ToolchainType). When the specified event occurs on a Unit, the function is automatically invoked.

cub trigger create --space my-space check-replicas Mutation Kubernetes/YAML vet-celexpr 'r.kind != "Deployment" || r.spec.replicas > 1'

Description

Use --description to explain what the Trigger checks and how to fix failures. The description is shown in the UI when hovering over a Validation Error.

cub trigger create --space my-space check-replicas Mutation Kubernetes/YAML \
  --description "Ensures Deployments have more than one replica for high availability" \
  vet-celexpr 'r.kind != "Deployment" || r.spec.replicas > 1'

When no description is provided, the UI shows the function name and arguments as a fallback.

Warn mode

Use --warn to create a non-blocking Trigger that produces Validation Warnings instead of Validation Errors. This is useful for advisory policies or gradual rollouts:

cub trigger create --space my-space soft-check Mutation Kubernetes/YAML --warn vet-celexpr '...'

Toggle warn mode on an existing Trigger with --warn or --unwarn on update.

Protecting what a mutating Trigger writes

--protect on a mutating Trigger records the paths its function writes as protected, so a later merge from upstream does not overwrite them. Use it for a Trigger that decides a value the variant then owns, such as a PostClone Trigger that sets a variant's namespace or replica count. Leave it off a Trigger that applies an organization-wide default, whose value should follow upstream changes like any other. It is refused on a Trigger whose function modifies nothing, since there are no written paths to protect.

Filtering which Units a Trigger applies to

By default, a Trigger applies to all Units in the Space (and any Spaces or Targets that select it). You can restrict which Units a Trigger applies to using:

  • --where-unit-field — a filter expression evaluated against Unit metadata, which sets the Trigger's WhereUnit:

    cub trigger create --space my-space check-prod Mutation Kubernetes/YAML \
      --where-unit-field "Labels.Environment = 'production'" \
      vet-celexpr '...'
    
  • --unit-filter — a reference to a saved Filter entity with From=Unit:

    cub filter create --space my-space prod-units Unit --where-field "Labels.Environment = 'production'"
    cub trigger create --space my-space check-prod Mutation Kubernetes/YAML \
      --unit-filter my-space/prod-units \
      vet-celexpr '...'
    
  • --where-resource — restricts which resources within a Unit's configuration data the function operates on, using metadata path expressions.

Worker-executed Triggers

Triggers can execute on a Worker instead of the built-in function executor:

cub trigger create --space my-space worker-check Mutation Kubernetes/YAML \
  --worker my-space/my-worker \
  vet-celexpr '...'

See Worker disconnection behavior for how ConfigHub handles unavailable workers.

Preventing mistakes with built-in validation

These built-in functions catch common errors and are recommended as Triggers for all Spaces:

vet-schemas — runs kubeconform to validate Kubernetes resource schemas. This catches typos in field names, incorrect types, missing required fields, and invalid API versions before configuration is released:

cub trigger create --space my-space valid-k8s Mutation Kubernetes/YAML vet-schemas

vet-placeholders — detects placeholder values that have not yet been replaced by the user or resolved through a linked configuration. Placeholder values (such as confighubplaceholder or 999999999) indicate that a value still needs to be provided. This prevents deploying incomplete configuration:

cub trigger create --space my-space complete-k8s Mutation Kubernetes/YAML vet-placeholders

Beyond those two, these are worth running everywhere:

  • vet-format rejects YAML that parses differently from how it reads, or not at all in some tools: anchors and aliases, duplicate keys, values such as yes and on that YAML 1.1 reads as booleans, and old-style octals.
  • vet-merge-keys fails when a list holds two entries with the same strategic-merge key, such as two containers or two environment variables with the same name, which Kubernetes refuses to apply.
  • vet-immutable fails when a change modifies a field Kubernetes does not allow to change after an object is created, such as a Deployment's selector, compared with the revision last released. A Trigger passes that revision to it without being told.
  • vet-no-merge-conflicts fails while a Unit has conflicts a merge could not apply, so a Space that promotions merge into does not release a Unit that silently missed part of a change.

Custom policy expressions with CEL

vet-celexpr — evaluates a simple boolean CEL expression against each resource. The resource is available as r. Returns true (pass) or false (fail). Use this for straightforward constraints:

cub trigger create --space my-space check-replicas Mutation Kubernetes/YAML \
  vet-celexpr 'r.kind != "Deployment" || r.spec.replicas > 1'

A more realistic example enforcing that Deployments run as non-root:

cub trigger create --space my-space ensure-nonroot Mutation Kubernetes/YAML \
  --description "Ensures Deployment containers run as non-root" \
  vet-celexpr 'r.kind != "Deployment" || (r.spec.template.spec.securityContext.runAsNonRoot == true && r.spec.template.spec.containers.all(container, !has(container.securityContext.runAsNonRoot) || container.securityContext.runAsNonRoot == true)) || r.spec.template.spec.containers.all(container, has(container.securityContext.runAsNonRoot) && container.securityContext.runAsNonRoot == true)'

vet-cel — a more capable CEL function that can return a ValidationResult with detailed failure information. The expression can return a simple bool, or a map with passed (bool) and details (list of strings):

cub trigger create --space my-space check-labels Mutation Kubernetes/YAML \
  vet-cel 'has(r.metadata.labels) && has(r.metadata.labels.team) ? {"passed": true} : {"passed": false, "details": ["Missing required label: team"]}'

Parameters follow the expression as key=value arguments, and the expression reads them as strings from params, so the same expression can be stored once with different values:

cub trigger create --space my-space min-replicas Mutation Kubernetes/YAML \
  -- vet-cel 'r.kind != "Deployment" || r.spec.replicas >= int(params.min)' min=2

In the named form, each one is a --param=key=value after --expression='...'. A --param key=value with a space is read as two arguments, and refused.

The map can also name the fields that failed, in failed_attributes: a list of maps with ResourceType, ResourceName, and Path, and optionally Value. cub unit blame then marks those fields, and cub unit path shows the check and its details on each:

cub trigger create --space my-space check-replicas Mutation Kubernetes/YAML \
  vet-cel 'r.kind != "Deployment" || r.spec.replicas > 1 ? {"passed": true} : {"passed": false, "details": ["replicas must be more than 1"], "failed_attributes": [{"ResourceType": r.apiVersion + "/" + r.kind, "ResourceName": r.metadata.namespace + "/" + r.metadata.name, "Path": "spec.replicas"}]}'

Custom policy logic with Starlark

vet-starlark — validates resources using a Starlark program (a Python-like language created by Google). The program must define a validate(r) function that returns a dict with passed (bool) and optionally details (list of strings). Starlark is a good choice for policies that involve more complex logic than a single CEL expression can express:

cub trigger create --space my-space check-resources Mutation Kubernetes/YAML \
  vet-starlark 'def validate(r):
    if r["kind"] != "Deployment":
        return {"passed": True}
    containers = r["spec"]["template"]["spec"]["containers"]
    missing = [c["name"] for c in containers if "resources" not in c or "requests" not in c.get("resources", {})]
    if missing:
        return {"passed": False, "details": ["Containers missing resource requests: " + ", ".join(missing)]}
    return {"passed": True}'

Policy engines: Kyverno and OPA Gatekeeper

For organizations that already use Kyverno or OPA Gatekeeper to enforce policies at Kubernetes admission time, ConfigHub can validate configuration against the same policies before deployment. This gives you a single set of policies for both admission control and pre-deployment validation.

The validation runs as an external function in a process you deploy next to the policy engine. The examples repo has ready-to-build workers for:

  • kyverno-server — validates against Kyverno policies via its webhook server, with the SDK's vet-kyverno-server function
  • opa-gatekeeper — validates against OPA Gatekeeper constraints, with the SDK's vet-opa-gatekeeper function
  • kyverno — validates using Kyverno as a library (no running server needed)
  • kube-score — scores Kubernetes resources against best-practice checks

Once a worker is running, create a Trigger to validate configuration automatically:

cub trigger create --space my-space validate-kyverno Mutation Kubernetes/YAML \
  --worker my-space/my-kyverno-worker \
  vet-kyverno-server

For details on deploying webhook validation workers, see the admission webhook functions guide. For background on Kubernetes policy enforcement, see What are state-based policy constraints good for? and Vetting Kubernetes configuration with Kyverno prior to deployment.

Organizing Triggers across Spaces and Targets

Dedicated Trigger Space

Rather than scattering Triggers across application Spaces, create a dedicated Space to hold Triggers, along with related entities such as Filters, Invocations, Views, Attributes, Tags, and ChangeSets. This Space should not contain Units, Workers, or Targets — it serves purely as an organizational home for operational tooling:

cub space create app-common --display-name "Shared Triggers and Filters"

Use Labels on Triggers to categorize them by purpose, making it easier to select subsets with Filters:

cub trigger create --space app-common valid-k8s Mutation Kubernetes/YAML \
  --label Purpose=validation \
  vet-schemas

cub trigger create --space app-common complete-k8s Mutation Kubernetes/YAML \
  --label Purpose=validation \
  vet-placeholders

cub trigger create --space app-common check-replicas Mutation Kubernetes/YAML \
  --label Purpose=policy --label Scope=production \
  vet-celexpr 'r.kind != "Deployment" || r.spec.replicas > 1'

cub trigger create --space app-common validate-kyverno Mutation Kubernetes/YAML \
  --worker my-space/my-kyverno-worker \
  --label Purpose=policy --label Engine=kyverno \
  vet-kyverno-server

Validation on Spaces, policies on Targets

In scenarios where application teams own Units in Spaces and a platform team owns the Kubernetes clusters (along with their Workers and Targets), a useful pattern is:

  • Attach validation Triggers to Spaces — functions like vet-schemas and vet-placeholders catch mistakes in configuration authoring. These apply regardless of where the configuration is deployed.
  • Attach policy Triggers to Targets — functions like vet-kyverno-server, vet-opa-gatekeeper, and environment-specific vet-celexpr constraints enforce what is allowed on a particular cluster. The platform team controls these independently of the application teams.

This separation lets application teams manage their own validation while the platform team enforces deployment policies centrally.

Using Filters to select Triggers

Since Triggers are ToolchainType-specific, and you may want different subsets of Triggers for different Spaces or Targets, use Filters with From=Trigger to select the right Triggers. Then attach the Filter to Spaces and Targets using --trigger-filter:

# Create Filters for different purposes
cub filter create --space app-common validation-triggers Trigger \
  --where-field "Labels.Purpose = 'validation'"

cub filter create --space app-common policy-triggers Trigger \
  --where-field "Labels.Purpose = 'policy'"

cub filter create --space app-common prod-policy-triggers Trigger \
  --where-field "Labels.Purpose = 'policy' AND Labels.Scope = 'production'"

Attach Filters to Spaces and Targets:

# Application Spaces get validation Triggers
cub space create my-app --trigger-filter app-common/validation-triggers

# Production Targets get policy Triggers
cub target create --space my-app prod-cluster \
  --trigger-filter app-common/prod-policy-triggers

To update an existing Space or Target to add a Filter and remove WhereTrigger filtering to prevent conflicting filters:

cub space update --patch --trigger-filter app-common/validation-triggers --where-trigger=- my-app
cub target update --patch --space my-app --trigger-filter app-common/policy-triggers --where-trigger=- prod-cluster

When Triggers are added or modified in the Trigger Space, refresh the consuming Spaces and Targets to pick up the changes:

cub space update --patch --refresh-triggers my-app
cub target update --patch --space my-app --refresh-triggers prod-cluster

How Triggers are applied to Spaces and Targets

Triggers are associated with Spaces and Targets through WhereTrigger and TriggerFilterID fields. These fields control which Triggers from across the organization are selected for a given Space or Target.

Default behavior

A Space with neither WhereTrigger nor TriggerFilterID invokes the Triggers in it. Setting either one replaces that with the Triggers it selects from across the organization. A Space may also have a WhereTrigger of SpaceID = '<its own ID>', which earlier versions of ConfigHub set automatically; it selects the same Triggers.

A Space created by cloning another, such as with cub variant create, selects the Triggers its upstream Space selects: it inherits the upstream's WhereTrigger and TriggerFilterID, or, when the upstream has neither, gets a WhereTrigger of SpaceID = '<upstream Space ID>'. Every variant of a Space therefore runs the same Triggers, without the Triggers being copied.

Cross-Space Trigger selection

Triggers can be selected by Spaces and Targets other than the Space they were created in:

cub space update --patch --where-trigger "Space.Slug = 'shared-policies' AND Event = 'Mutation'" my-app-space

Refreshing Trigger lists

When a Trigger is created, updated, or deleted within its own Space, the Space's Trigger list (TriggerIDs) and hash (TriggerHash) are automatically refreshed, and affected Units are enqueued for re-evaluation. When a Trigger is moved to another Space with cub trigger move, the Space it leaves and the one it arrives in are both refreshed. The move lists the other Spaces and Targets whose selection of the Trigger changes; they keep invoking it until they are refreshed.

However, explicit refresh is needed in two main scenarios:

  1. Cross-Space Trigger changes: When Triggers are modified in one Space but consumed by another Space or Target through WhereTrigger or TriggerFilterID, the consuming Space or Target does not automatically detect the change. The manager of the consuming Space or Target must explicitly refresh:
cub space update --patch --refresh-triggers my-app-space
cub target update --patch --space my-space --refresh-triggers my-target

This is by design: since Triggers can affect many Units across many Spaces, delaying re-evaluation gives managers of consuming Spaces an opportunity to verify which Triggers match and potentially update their filters before applying the changes.

  1. Permission verification: Refreshing checks that the caller has Edit permission on the Space or Target. This ensures that only authorized users can change which Triggers are actively applied.

Trigger hashing

ConfigHub computes a TriggerHash for each Space and Target based on the combined hashes of all selected Triggers. When the hash changes (due to Trigger creation, update, deletion, or refresh), all affected Units are enqueued for asynchronous trigger re-evaluation.

Validation Errors and Validation Warnings

Validation is about the configuration itself. Who has reviewed or approved a change is recorded separately, as Attestations, and required by a ChangeWorkflow rather than by a Trigger. See review and approval.

When a validating Trigger function fails, ConfigHub records a Validation Error on the Unit. Validation Errors block publishing a Release until the issue is resolved.

Gate naming

Validation Errors use a 3-part name format: <space-slug>/<trigger-slug>/<function-name>. For example:

my-space/check-replicas/vet-celexpr

This format identifies which Trigger and function produced the gate, and from which Space the Trigger originates. A Trigger that references an Invocation with several functions produces one gate per function, numbered by the function's position in the Invocation:

my-space/checks/vet-celexpr:1
my-space/checks/vet-celexpr:2

The name records the Trigger's slugs as they were when the gate was set. Renaming or moving a Trigger does not rename its gates; they get the new name the next time the Unit's Triggers run. ValidationTriggerIDs maps each gate name to the ID of the Trigger that produced it, and can be used in filters:

cub unit list --space my-space --where "ValidationTriggerIDs.my-space/check-replicas/vet-celexpr = '<trigger-id>'"

The awaiting/triggers gate

When configuration data changes, ConfigHub sets a temporary awaiting/triggers Validation Error on the Unit. It indicates that Triggers have been enqueued for asynchronous evaluation but have not yet completed, and is automatically removed when all Triggers finish executing.

Waiting for Triggers

Many CLI commands support --wait (enabled by default) to wait for the awaiting/triggers gate to clear:

cub unit update --space my-space --wait my-unit ...

When --wait is active, the CLI polls the Unit until awaiting/triggers is removed, then reports any remaining Validation Errors or Validation Warnings.

When Triggers are re-evaluated

Triggers run asynchronously in the background. They are re-evaluated when:

  • Unit data changes — any mutation to a Unit's configuration data (create, update, function invocations, link resolution, clone)
  • Links are added or removed — resolved data from linked Units may affect Trigger results
  • A Release is published with outstanding Validation Errors — the Unit is re-enqueued for Trigger re-evaluation so that the errors can be re-checked with the current Trigger configuration
  • Trigger configuration changes — creating, updating, or deleting a Trigger in the Space automatically re-evaluates all affected Units
  • Space or Target Trigger list is refreshed — after --refresh-triggers, all Units in the Space (or with the Target) are re-evaluated

Worker disconnection behavior

When a Worker responsible for executing a Trigger's function is disconnected or unresponsive:

  • Other Triggers continue to execute — only Triggers for the unavailable worker are affected
  • Validating and mutating Triggers for the unavailable worker produce Validation Errors — reflecting that the validation or mutation could not be performed
  • After the fail-open duration (default 6 hours), the Trigger is automatically disabled

Configuring fail-open duration

Use --fail-open-after to set a per-Trigger duration (requires --worker):

cub trigger create --space my-space worker-check Mutation Kubernetes/YAML \
  --worker my-space/my-worker \
  --fail-open-after 30m \
  vet-celexpr '...'

When not set, the default duration of 6 hours is used.

Invoking Triggers imperatively

Triggers can be invoked on demand with the --trigger flag, using the verb that matches the kind of function the Trigger runs — cub function vet for a validating Trigger, cub function set for one that mutates:

cub function vet --space my-space --where "Slug = 'my-unit'" --trigger my-space/check-replicas

The verb is checked against the Trigger's function, so invoking a mutating Trigger with vet is an error rather than an unintended write. Use cub function do only to invoke several Triggers of differing kinds in one request.

Updating validation results imperatively

Add --update-validation-results to selectively update a Unit's Validation Errors and Warnings from the Trigger results:

cub function vet --space my-space --where "Slug = 'my-unit'" \
  --trigger my-space/check-replicas \
  --update-validation-results

This is useful when a worker comes back online and you want to re-evaluate Triggers and update the results without waiting for the next automatic re-evaluation. Unlike automatic trigger evaluation, imperative --update-validation-results only modifies the entries for the invoked Triggers — it does not clear those from other Triggers.

(--update-apply-gates is the former name of this flag. It still works, and warns that it is deprecated.)

Permissions

  • Creating a Trigger requires CreateChildren permission on the Space it is created in, and updating or deleting one requires Edit permission on the Trigger, which EditChildren on its Space grants. No permission on the Spaces and Targets that select the Trigger is required: the Space the Trigger is in is refreshed with the change, and any other Space or Target that selects it takes the change when it is refreshed.
  • Refreshing Triggers on a Space or Target requires Edit permission on that Space or Target.
  • Invoking Triggers with --update-validation-results requires Edit permission on the affected Units.

Troubleshooting

Units have unexpected Validation Errors

Use cub trigger list to see which Triggers are active in a Space:

cub trigger list --space my-space

Check the validation results on a specific Unit:

cub unit get --space my-space -o jq=".Unit.ValidationResults" my-unit

Triggers are not running

  • Check that the Trigger is not disabled: cub trigger get --space my-space my-trigger
  • Check whether any Triggers are selected: cub space get my-space -o "jq=.Space | {Space: .Slug, TriggerIDs: .TriggerIDs}"
  • Check that the Space's WhereTrigger or TriggerFilterID selects the Trigger: cub space get -o jq=".Space.TriggerIDs" my-space
  • If both WhereTrigger and TriggerFilterID are set, they are ANDed, and may conflict. Remove WhereTrigger with cub space update my-space --patch --where-trigger=-. Remove TriggerFilterID with cub space update my-space --patch --trigger-filter=-.
  • If using cross-Space Triggers, ensure the consuming Space has been refreshed: cub space update --patch --refresh-triggers my-space
  • Perform similar steps for Target Triggers if the expected Triggers are attached to the Target.

Worker Triggers produce gates after worker restart

When a worker reconnects, existing Units are not automatically re-evaluated. Use imperative invocation with --update-validation-results to re-evaluate:

cub function vet --space my-space --trigger my-space/worker-check --update-validation-results

Or refresh the Space's Triggers to enqueue all Units:

cub space update --patch --refresh-triggers my-space