Skip to content

Creating and managing variants

A common task is to manage variants of the same configuration — for example, development, staging, and production variants, or per-region and per-tenant variants.

A variant is a space that was cloned from an upstream space — typically a base — and customized for a different environment, region, tenant, or experiment. Cloning links each unit in the variant space to the upstream unit it was copied from, so the variant can later be brought up to date as the upstream changes.

The cub variant commands manage variants at the space level and are the recommended way to work with them. Under the hood, the upstream relationships they establish are maintained per unit, and the lower-level unit commands remain fully supported — see working with variants at the unit level.

Managing variant spaces

The cub variant subcommands operate on a whole space and its units at once: seed a base space from rendered manifests, clone it into a variant, and later reconcile the variant with its upstream.

Seeding a base space with cub variant upload

When you already have rendered Kubernetes manifests — from the Helm plugin, kustomize build, helm template, or an external pipeline — cub variant upload ingests them into a space as units. It does not render anything; it stores what you give it.

kustomize build overlays/base | cub variant upload \
    --component web --variant base \
    --granularity per-resource \
    --target web-base/cluster -

--component and --variant are required and become the well-known Component and Variant space labels; --environment, --region, --layer, and --owner set the rest. The space slug comes from --space-pattern (default template:{{.Labels.Component}}-{{.Labels.Variant}}) or from an explicit --space, and the space is created if it does not exist. --granularity per-resource creates one unit per resource; the default minimal packs everything into a single unit, splitting out CRDs and each AppConfig file. --target binds the created units to a target. Rendered Secrets are never uploaded — apply them out of band.

This is the recommended way to seed a promotable base space.

Re-uploading the same space later reconciles it with the new input rather than replacing it: units whose resources are unchanged are left alone, changed ones are merged, and new ones are created. A change you made in ConfigHub after the upload — a replica count, an annotation — survives that merge only if the path is protected, because the rendered source is otherwise authoritative and the next upload puts its own value back:

# Hold this value against the next re-upload
cub function set --space web-base --unit web --protect set-replicas 5

That is usually the behavior you want for a space whose content is generated elsewhere. If you find yourself protecting the same path after every render, the value belongs in the source instead.

Creating a variant with cub variant create

cub variant create clones an upstream space and all of its units into a new downstream space, linking each clone to its upstream unit so the variant can be promoted later.

cub variant create prod web-base \
    --environment Prod --region us-east2 \
    --target web-prod/cluster \
    --namespace web-prod \
    --unit-delete-gate critical --unit-destroy-gate critical

The first argument is the variant name, which becomes the new space's Variant label; the second is the upstream space to clone from. The new space's name defaults to <component>-<variant> from the cloned space's labels — web-prod here — and can be overridden with --space-pattern. The new space inherits the upstream's labels (with Variant overridden), along with its WhereTrigger, TriggerFilterID, permissions, and delete gates. --target retargets the cloned units and stamps the space's TargetID annotation, and --namespace replaces the placeholder namespace from the base so each variant lands in its own namespace. The UpstreamSpaceID annotation it records is what makes the space promotable.

To automatically customize the clones, define PostClone triggers and select them via the upstream space's WhereTrigger or TriggerFilterID so they are copied down and run during the clone. See managing dependencies and validation and policies.

Promoting a variant with cub variant promote

cub variant promote reconciles a variant space with the upstream space recorded in its UpstreamSpaceID annotation. In one command it upgrades every unit whose upstream has advanced (merging the upstream changes), clones any units added to the upstream since the variant was created or last promoted, and copies the new units' links.

# Preview what would change first.
cub variant promote web-prod --dry-run -o mutations

# Promote, optionally within a changeset and with a change description.
cub variant promote web-prod \
    --changeset release-2024-06 \
    --change-desc "Promote web to prod"

Promotion only updates the configuration data; it does not make the change live. Publish a new Release afterward with cub release publish <space> so the GitOps operator picks it up.

To promote a named change rather than everything the upstream has reached — one image bump, through dev then staging then prod, gated on each stage actually running it — pass --change-order. See tracking and promoting changes.

Only spaces created by cub variant create can be promoted this way. For finer-grained or cross-space promotions, use the unit-level --upgrade mechanism described under keeping variants in sync, typically wrapped in a ChangeSet.

Protecting a variant's own values

A variant exists to differ from its base. The question every promotion asks is which of those differences are the variant's to keep and which are values it is only carrying until the upstream changes them — and ConfigHub answers it per path, from what you have said, rather than by guessing from the data.

Protection is opt-in. An ordinary change — a hand edit, a function invocation, a trigger, a needs/provides binding — leaves each path it writes as protected as it found it, and content that arrives from a clone, an upgrade, or a merge is recorded unprotected. So by default an upstream change to a path reaches the variant even if someone here had set that path to something else. That is the right default for a value the variant is carrying: a replica count copied down from the base should follow the base when the base changes its mind.

A value the variant has chosen, on the other hand, should say so. Two ways to say it:

As you make the change, with --protect on cub unit update, cub function set, or cub run. Every path the change writes becomes a protected local override:

cub function set --space prod --unit backend-clone --protect set-replicas 5

Afterwards, per path, with cub unit set-protection. Each --protect and --unprotect names a resource type, a resource name, and a path:

# This deployment's replica count is prod's own from here on.
cub unit set-protection --space prod backend-clone \
    --protect "apps/v1/Deployment:prod/backend:spec.replicas"

# Changed our mind: let the base drive it again.
cub unit set-protection --space prod backend-clone \
    --unprotect "apps/v1/Deployment:prod/backend:spec.replicas"

--unprotect re-opens a path, so the next merge overwrites it with whatever the upstream has. Either form creates a new revision only if a flag actually changes, and neither touches the configuration data — protection is a statement about who owns a field, not a value.

Leave --protect off for a script propagating a release. A promotion that protected everything it wrote would turn upstream content into local overrides and block every merge after it.

To see what a unit currently protects, ask for its mutations. The two lists are exactly this distinction — what the variant owns, and what a merge is free to update:

cub unit get --space prod backend-clone -o mutations
Locally overridden (preserved during merges):
Resource: apps/v1/Deployment prod/backend
  ~ [Update] spec.replicas  (#2)

Eligible for upstream merges:
Resource: apps/v1/Deployment prod/backend
  + [Add] (#1)

Protection is stored per path, so it survives a restore and is remembered across merges. Two mechanisms decide the same question by rule instead — a WhereMutation filter over mutation history, which protects further paths on top of the stored flags, and the subtraction step, which infers overrides from the diff at merge time and replaces the stored flags for the merges it applies to.

Conflicts: what a merge could not apply

A merge that cannot apply part of what it brought does not fail, and does not apply it anyway. It applies the rest and records what it withheld on the unit, where it stays until you deal with it:

cub unit conflicts --space prod backend-clone

Each conflict names the resource, the path, the value that was withheld, and a reason:

  • ProtectedPath — the path is a protected local override, so the upstream's change to it was withheld. This is the common one, and it is the protection mechanism reporting itself rather than working silently.
  • ExclusiveWithheld / ExclusiveCleared — the two sides set mutually exclusive fields (a volume with one source, a deployment strategy and its options). The downstream's choice stands and the upstream's is reported.
  • Subtracted, DeleteShadowedthe subtraction step dropped a change, or a deletion could not be applied because the target had changed underneath it.
  • UnresolvedPath — the path the source changed could not be located in this unit.
  • ReplayFailed — a replayed function errored here. The step was patched instead, and the conflict carries the function name and the error.

Conflicts are queryable, so "which variants have something outstanding?" is one command:

cub unit list --space "*" --where "Conflicts.*.Reason = 'ProtectedPath'"

Deal with one by applying it — take the upstream's value after all — or dismissing it:

# What would taking the upstream's value do here? --dry-run writes nothing.
cub unit conflicts --space prod backend-clone --apply --dry-run -o mutations

# Apply everything withheld for a given reason
cub unit conflicts --space prod backend-clone --apply --reason ProtectedPath

# Or drop the report without changing any data
cub unit conflicts --space prod backend-clone --dismiss --reason ProtectedPath

--path and --resource narrow the selection further; with no selector, --apply and --dismiss act on everything outstanding. --dry-run works with either and reports what the request would do — with -o mutations, as the same per-path list a real one records.

Applying writes the withheld value and records the path as content that came from elsewhere, so a later upstream change to it lands normally rather than reporting the same conflict release after release. Dismissing changes no data and leaves the protection in place.

A merge replaces the outstanding set rather than adding to it, so a later merge that lands cleanly clears what an earlier one withheld — the set describes where the unit stands now, not everything that ever happened. Within a single walked range the conflicts accumulate across the hops, so nothing is erased before you have seen it.

To make outstanding conflicts block an apply rather than sit there, attach the vet-no-merge-conflicts validating function as a Trigger; it fails while anything is outstanding, which turns into an ApplyGate.

Base units

You may choose to maintain base units which you copy to create all of the variants you plan to deploy. Base units are similar to abstract classes in that they aren't intended to be deployed. For example, they need not have attached deployment targets. You can use labels to skip these in bulk applies, or just filter out units without targets (where TargetID IS NOT NULL).

This approach enables a clearer separation of common attributes and custom attributes.

Placeholders for undetermined values

Base units, and sometimes other new config units, contain default values for most fields, but some may require specific values that cannot yet be provided. This is indicated by placeholders.

A placeholder is a special string or number in the config data which indicates that you must replace it with a real value before the configuration is valid. ConfigHub uses the string "confighubplaceholder" to indicate where a string field needs to be supplied with a value and 9 9s (999999999) for integer fields.

The vet-placeholders function can be installed as a trigger to prevent applying configuration units with unreplaced placeholders, and get-placeholders can return a list of field paths containing placeholders.

Triggers are good for setting general properties of an environment: scale/cost (e.g., replicated vs not, backups vs not), privacy (e.g., public vs not, data with real PII vs not), security (e.g. must be encrypted vs not, can't run as root), and the like.

Links better address more specific resource properties like hostnames, IP addresses, etc.

See managing dependencies for guidance regarding how to replace placeholders with variant-specific values.

Going further