Skip to content

Writing changes that generalize across variants

Many changes in ConfigHub are not applied to just a single unit. A function invocation runs across whatever selection you give it, the resulting change propagates to the variants downstream, and stored invocations are re-run later against data that has changed since. The same invocation therefore has to work against units with different contents, different collection ordering (e.g., sometimes patches append to arrays rather than insert), and sometimes different resource names.

Everything in this guide already applies to bulk execution today. It matters more as the number of variants grows, because an invocation that only works against the data in front of you fails silently in the variants where it does not match.

Refer to things by identity, not position

The most common reason an invocation stops working elsewhere is a hardcoded index.

# Fragile: whichever container happens to be first in this unit
cub function set --space prod --unit backend \
    set-string-path apps/v1/Deployment "spec.template.spec.containers.0.image" ghcr.io/acme/api:v2

# Portable: the container named "api", wherever it appears
cub function set --space prod --unit backend \
    set-container-image api ghcr.io/acme/api:v2

containers.0 selects the element in position 0 of that unit's container list. A variant that adds a sidecar, or lists its containers in a different order, has a different container in that position, and writing to it succeeds because position 0 exists. Nothing fails; the wrong container is modified. Selecting by name is stable under both reordering and insertion, because ConfigHub matches the element rather than counting to it.

Three forms of the same rule:

  • Prefer a purpose-built function - set-container-image, set-env-var, set-container-resources — over a generic path setter like set-string-path. These already select their target by merge key.
  • When a path is unavoidable, use one that selects. ConfigHub paths can match by merge key: spec.template.spec.containers.?name=api.image selects the container named api.
  • Keep arguments parameters rather than data. A function whose argument is a list computed from one unit's current contents only applies to that unit. Arguments that name what to change are portable; arguments carrying a specific unit's data are not.

Similar rules apply when writing functions using CEL, YQ, and Starlark.

Merge keys are identity, so avoid renaming them

Like Kubernetes strategic merge patch and kustomize, ConfigHub matches array elements by merge key — the container name, the volume name. A name in a declarative model identifies the element and usually describes its role, so changing it makes the element a different one as far as the merge engine and every stored invocation are concerned.

Renaming a container from web to frontend in a downstream variant means every stored invocation that refers to web no longer matches anything in that variant. Those invocations do not fail; they find nothing to change. Merge key changes across variants also break patches. Avoid renaming merge keys. If a variant genuinely needs a differently-purposed container, adding one and removing the old one states that directly. When you do have to rename, treat it as a breaking change for that variant: invocations naming the old value stop reaching it, and they need to be updated.

Plan for execution on units with different numbers of matching paths

Variants could contain more or fewer resources, containers, ports, volumes, etc.

  • Search for all relevant occurrences. Use the * wildcard or a function that searches by value, such as set-image-reference-by-uri.
  • Select only the relevant occurrences. Don't assume that the specified path only occurs once. Use WhereResource or expressions in CEL, YQ, or Starlark to ensure the function invocation doesn't touch paths it shouldn't.

In a template, such as a Helm chart, correct variation is sometimes implemented by explicit conditional logic, but often is pushed into careful curation of input values for each variant across values overlay files in variant-specific directories -- implicit rather than explicit reasons. In ConfigHub, it is recommended that high-level variant characteristics, such as "highly-available" or "pci-compliant", that determine detailed configuration settings should be made explicit via labels or annotations in ConfigHub and/or Kubernetes resources, and then unit and/or resource filters can be used to target function invocations appropriately.

Dry-run across the variants before committing a change

--dry-run accepts the same selection that bulk execution does, so you can check an invocation against every variant before it becomes part of the history:

cub function set --space "*" --where "Slug = 'deployment'" --dry-run -o mutations \
    set-container-image web ghcr.io/acme/app:v2

Two things in that output are worth reading:

  • Variants that report no mutation. The invocation matched nothing there, possibly because the container is absent or renamed. This is the case that is hardest to notice later, because it produces no error and no change.
  • Variants that changed something unexpected, or more paths than intended. That generally means the invocation is not selective enough.

Either can be fixed while the change is still a proposal, by correcting the invocation or by correcting the variant that has drifted from the shape the invocation expects.

Why an invocation that changes nothing is not an error

Most functions do not return an error when they find nothing to operate on. That is deliberate: it is what allows one invocation to run across a bulk selection, or across variants shaped differently from the one it was written against, without the caller enumerating which units and resources it applies to.

We may add a mode that returns errors in such cases in the future.

Protecting the values a variant owns

The other half of a change reaching the right variants is a change not overwriting the values a variant sets deliberately. ConfigHub records, per path, whether a value came from a clone, upgrade, or merge, or was set locally, and a merge does not overwrite a local override. See Mutation Sources for the mechanism and for adjusting protection per path.

Two points that matter when the change is made by automation rather than by hand:

  • A script applying a value it did not choose should pass --preserve-protection. Without it, the write is recorded as a local override, and subsequent upgrades stop updating that path. The effect is not visible at the time and is difficult to trace back to the script afterwards.
  • Protecting a path is a standing decision, not a one-time one. Once a path is protected, upstream changes to it are reported rather than applied. cub unit conflicts lists what was withheld, and lets you apply the changes you want.

Checklist

Before an invocation becomes part of the history:

  1. Does it select what it changes by name, rather than by position?
  2. Is there a purpose-built function for this change?
  3. If it takes a path, does the path select by merge key?
  4. Does it select precisely the resources and paths that should be affected?
  5. Has it been dry-run across --space "*", and were the variants reporting no mutation accounted for?
  6. Does it rename a merge key that other invocations refer to?
  7. If it is automation applying a value decided elsewhere, does it pass --preserve-protection?