Package Values
A cargoship package ships with a set of values: Helm-style configuration, addressed by dotted paths, that a cluster can override without rebuilding the package. Values reach the engine configuration in two ways – structural mappings, and Go templates that read .Values – and they are also available to the files a package uploads and to the actions that build it.
This guide covers authoring values in a package, overriding them from a cluster inventory or the command line, and choosing between mappings and templates.
Declaring values in a package
Values live under spec.values of a distro.yaml:
spec:
values:
files:
- values.yaml
- overrides/prod.yaml
schema: values.schema.json
files are merged in order, key by key, with a later file winning over an earlier one. Each entry is a path relative to the package definition, an absolute path, or a URL. Cargoship copies them into the package as values/<index>-<basename>, so two files that share a base name do not collide.
schema is a JSON Schema document the merged values must satisfy. Cargoship checks the values against it while building the package and refuses to build when they do not match, so a typo is caught by whoever builds the package rather than by whoever installs it. The schema must not use $ref. Set "additionalProperties": false on the root object: without it, a misspelled top-level key in a cluster inventory is silently ignored instead of rejected.
A values file is ordinary YAML:
cilium:
encryption:
enabled: false
hubble:
ui:
enabled: false
ipv6: false
Overriding values
Values are resolved from three sources, each winning over the one before it:
- The values files the package ships with.
spec.config.valuesin the cluster inventory.- One or more
--values <file>flags on the command line.
The inventory carries the values a cluster always wants:
kind: Cluster
spec:
config:
values:
cilium:
encryption:
enabled: true
The flag carries the ones that change between runs:
cargoship apply ./package.tar.zst --config inventory.yaml --values ./encryption-off.yaml
--values may be given more than once, with a later file winning over an earlier one, and it is accepted by cargoship apply, cargoship prepare, and cargoship engine-config-sync. There is no -f shorthand.
Merging is per key, not per document. An override that sets cilium.encryption.enabled leaves every other key under cilium as the package defined it.
The merged values are checked against the package’s schema again at install time, so an inventory that sets a key the package does not accept fails before anything is written to a host.
That check happens when the package is installed, which is late to learn that a key is misspelled. cargoship schema inventory --package <package> composes the package’s values schema into the inventory schema, so an editor completes and checks spec.config.values while the inventory is being written:
cargoship schema inventory --package ./package.tar.zst -o ./inventory.schema.json
--package accepts a package source directory as well as a built one, so a package author can point an editor at a definition that has not been built yet. The composed schema describes the override subtree on its own, while cargoship validates the overrides merged with the values the package ships, so required is dropped from it – a key the package’s own values.yaml supplies is not required of the operator. It catches an unknown key, a wrong type, and a name outside an enum or pattern; it does not claim to reproduce install-time validation. See the inventory guide for wiring it into an editor.
cargoship validate runs that same composed schema without an editor, which is the form that fits CI:
cargoship validate ./inventory.yaml --package ./package.tar.zst
Mappings
A mapping projects one value onto one place in the engine configuration:
spec:
values:
mappings:
- source: .cilium.encryption.enabled
target: .manifest.rke2-cilium.encryption.enabled
source is read out of the resolved values. target is written relative to spec.config.engine. Both paths must begin with a dot. A source the values do not define is left alone, so the package’s own engine configuration stands as the default.
Mappings are structural: the value keeps its YAML type, and nothing in the package has to be written as a template. They are the right tool when a value lands on exactly one key.
A mapping that cannot be applied – a path without a leading dot, or a target underneath a chart entry that is a YAML string rather than a map – is reported as a warning when the package is built, and again when it is installed. It does not fail the build. The feature is new, and a hard failure on a package that installs correctly today is the wrong first move.
Templates
A template reads .Values directly, anywhere in the engine configuration:
spec:
config:
engine:
manifest:
rke2-cilium:
encryption:
enabled: "{{ .Values.cilium.encryption.enabled }}"
A scalar that is entirely one template is re-typed after rendering, the same way Helm’s --set typing works, so the example above lands in the generated HelmChartConfig as a YAML boolean rather than the string "true". A scalar that mixes text and templating stays a string.
Rendering happens before the mappings are applied, and before the engine configuration is validated. Package-authored text is therefore what gets executed: a value supplied by a cluster inventory that happens to contain {{ is written through as literal text, never evaluated.
Conditionals
Templating can do the one thing mappings cannot: decide whether a block of configuration exists at all. Rendering walks the parsed structure one scalar at a time and never re-parses a document, so a conditional only works inside a chart entry written as a YAML block scalar:
spec:
config:
engine:
manifest:
rke2-cilium: |-
kubeProxyReplacement: true
encryption:
enabled: {{ .Values.cilium.encryption.enabled }}
{{- if .Values.cilium.hubble.ui.enabled }}
hubble:
enabled: true
ui:
enabled: true
{{- else }}
hubble:
enabled: false
{{- end }}
Both shapes are in the examples: example/rke2-cilium-vsphere spends its values through mappings, and example/rke2-multi-cni-cilium spends the same values through a templated block scalar.
Which to reach for
Use a mapping when a value has one destination and its type should survive untouched. Use a template when one value drives several keys, when the value needs reshaping on the way, or when the package has to emit or omit a whole block. A package may use both.
A chart entry carries more than its values
Everything above writes a chart entry under spec.config.engine.manifest as the chart’s Helm values and nothing else. An entry may instead be a wrapper, which names the values under a values key and says two more things about the chart:
spec:
config:
engine:
manifest:
rke2-cilium:
enabled: true
images:
- docker.io/rancher/mirrored-cilium-cilium:v1.20.2
- docker.io/rancher/mirrored-cilium-hubble-ui:v0.13.6
values: |-
kubeProxyReplacement: true
| Key | What it does |
|---|---|
values | The chart’s Helm values, exactly as the bare form carries them - a YAML string or a mapping, templated and mapped the same way |
images | The offline images this chart needs, carried in the package beside spec.config.imageConfig.images |
enabled | Whether those images reach a host. Defaults to true. This is not an install switch - the engine’s disable key, described below, is what leaves a chart out of the cluster |
Why images belong to a chart
imageConfig.images is one flat list, and nothing about it records which chart an image is for. So a cluster that turns Hubble’s web interface off is still sent mirrored-cilium-hubble-ui and -ui-backend, and a cluster running no vSphere CSI is still sent its ten driver images - exported to a tarball, pushed over SSH to every node, and left on disk. On an air-gapped link that is the expensive kind of waste.
Naming an image under the chart that needs it is what lets cargoship skip it. A chart with enabled: false has none of its images exported or uploaded, and because the upload phase removes image tarballs it did not write this run, disabling a chart on a later apply takes its images back off the hosts.
The package still carries every image. cargoship create pulls all of them and a published package holds all of their blobs, whatever any chart’s enabled says - a package whose contents depended on the cluster it was going to be installed on would be a different package per cluster. The saving is in what crosses the wire to the hosts, not in the size of the tarball an operator carries across the air gap.
A chart that is not enabled also gets no HelmChartConfig, for the same reason a disabled one does not: there is nothing to configure when the images are not on the node.
Deciding it from an inventory
enabled is an ordinary place for a value to land, so a cluster decides it:
spec:
values:
mappings:
- source: .addons.vsphere.csi.enabled
target: .manifest.rancher-vsphere-csi.enabled
Mappings are applied before any phase runs, so by the time the upload phase picks images the answer is in hand.
A chart is only switchable if the package says so. enabled defaults to true, and so does an entry that omits it – including every entry written in the bare form. A cluster reaches spec.config.engine only through a mapping or a template the package itself declares; a cluster’s own engine section carries node labels and taints and nothing else. So a chart whose entry has no enabled key, and no mapping onto one, cannot be turned off by an inventory or by --values at all. Exposing the switch is the package author’s decision, and not exposing it is how a package says a chart is not optional.
Note that enabled is about the chart’s images, not about whether the engine installs it – the disable key below is that, and the two are not wired together. See todo-chart-disable-image-gating for what that currently costs.
Both shapes are accepted, and one is going away
An entry is read as a wrapper when it has a values key, and as the chart’s values themselves when it does not. That is the whole rule, and it is deliberately narrow: a chart’s own values may well have a top-level enabled or images key - cilium has enablePolicy, and rancher-vsphere-csi really does take an images list - so neither of those can tell the two shapes apart. The cost is that a chart whose values have a top-level values key cannot use the wrapper, and has to stay in the bare form.
Two consequences worth knowing:
- A typo in a wrapper key is silent. Because a bare entry is any mapping,
enabledd: falseis indistinguishable from a Helm value by that name. It validates, and the chart’s images ship. Nothing warns. - The bare form is what
v1beta1drops. It is supported for as long asv1alpha1is, and a package written today needs no change; a package written now for a chart that will grow images is better off in the wrapper.
Disabling bundled addons
RKE2 and K3s both install a set of charts of their own – ingress, a load balancer, metrics, DNS – and both are told to leave one out through a single disable key in config.yaml. Every example package exposes that key as a value, so which addons a cluster runs is a cluster’s decision rather than a packaging one:
# values.yaml, in the package
addons:
disabled:
- rke2-ingress-nginx
# distro.yaml, in the package
spec:
values:
mappings:
- source: .addons.disabled
target: .config.disable
An inventory names the charts it wants left out:
spec:
config:
values:
addons:
disabled:
- rke2-ingress-nginx
- rke2-traefik
- rke2-traefik-crd
The list is replaced, not merged. Values merge per key, and a list is one value, so an inventory that sets addons.disabled decides the whole list. Repeat the entries the package disables by default – rke2-ingress-nginx above – or they come back.
This works because a mapping copies the value as it stands, lists included. Templating cannot produce a list: rendering re-types a fully templated scalar into a boolean, a number, or a string, never into a sequence. A value that has to reach the engine as a list has to arrive through a mapping.
The names are the chart names the engine itself uses, and which ones a build packages is a property of that build rather than of the engine: RKE2 1.37 carries rke2-gateway-api-crd and rke2-security-responder, RKE2 1.34 carries neither, and K3s calls its charts by bare names (coredns, servicelb, traefik, local-storage, metrics-server, runtimes). Rather than keep a list here that drifts from what is packaged, every example’s values.schema.json carries its own version’s charts as suggestions under addons.disabled, which an editor offers as completions. That list is generated from the engine’s source – see mage and thirdparty-src – so it is the vocabulary that build advertises. Read the schema next to the distro.yaml you are installing.
The suggestions are not the whole vocabulary. The engine’s --disable help text is where the list comes from, and it is not exhaustive: RKE2 bundles rke2-runtimeclasses and never names it there. A schema that rejected everything outside the list would reject a chart the engine would have honored, so addons.disabled accepts any chart name and the list only drives completion. An unknown name reaches config.yaml unchanged: cargoship warns that the build packages no such component and leaves the entry in place, since dropping it would quietly install the very chart the cluster asked to be without. A typo is visible in that warning rather than at validation time.
Two other things follow from disabling a chart:
- No
HelmChartConfigis written for it. A package may configure a chart underspec.config.engine.manifestand still have it disabled by an inventory. Cargoship skips the manifest for a disabled chart rather than configuring something that will not be installed, and a file left over from an earlier run is removed the next time the node is synced. A chart whose entry setsenabled: falseis skipped the same way, for the same reason - its images are not on the node either. - Agents are unaffected.
disableis a server-only key. It is dropped from an agent’sconfig.yamlalong with every other controller-only key, and only controllers carry chart manifests in the first place.
Pod Security Admission levels
Every example package writes a Pod Security admission configuration, and the level it enforces is a value rather than a packaging decision:
# values.yaml, in the package
podSecurity:
defaults:
enforce: restricted
audit: restricted
warn: restricted
# distro.yaml, in the package
spec:
values:
mappings:
- source: .podSecurity.defaults.enforce
target: .podSecurity.plugins[0].configuration.defaults.enforce
- source: .podSecurity.defaults.audit
target: .podSecurity.plugins[0].configuration.defaults.audit
- source: .podSecurity.defaults.warn
target: .podSecurity.plugins[0].configuration.defaults.warn
Each key takes one of privileged, baseline, or restricted, which the schema enforces, and all three default to restricted. A cluster that has to run a workload the restricted profile rejects can lower one of them without rebuilding:
spec:
config:
values:
podSecurity:
defaults:
enforce: baseline
The three are separate on purpose. enforce is the only one that rejects a pod; audit and warn record it and tell the client about it. Lowering enforce to baseline while leaving audit and warn at restricted runs the workload and still reports every pod that would not have passed, which is the shape to reach for while a workload is being brought up to the profile rather than exempted from it.
Only the levels are values. The -version keys, the exemptions, and the plugin itself stay with the package: an exemption list is a property of what the package installs – kube-system, rook-ceph, and zarf are exempted because the charts in the package need it – and a cluster that edited it would be deciding for components it did not choose. Anything beyond the three levels is a package edit.
What renders
Templating is deliberately not applied everywhere. Three surfaces render:
-
spec.config.engine– always, without an opt-in. This is the subtree that becomes the engine’s configuration and itsHelmChartConfigmanifests. -
spec.config.filesandspec.config.os.files– per file, when the entry setstemplate: true. It defaults to false, because most files are binaries or archives that a template pass would corrupt.spec: config: files: - source: audit-policy.yaml target: /etc/rancher/rke2/audit-policy.yaml template: trueThe file is rendered once, in place in the extracted package, before any phase uploads it.
-
spec.actions.onCreate– per action, when the action setstemplate: true. This renders at build time, on the machine building the package, and covers the action’scmdand itswaitblock.
spec.config.os.sysctl, spec.config.os.environment, spec.config.os.fapolicyd, and spec.config.os.selinux do not render.
A template that names a value the package does not define is an error, not an empty string, on every surface.
Rendered manifests on a running cluster
The HelmChartConfig manifests a template produces are written to the engine’s manifest directory, which the Rancher engines re-read on their own. Cargoship marks them as not requiring a restart: cargoship engine-config-sync compares them on disk, writes the drift in place, and logs the paths it wrote. No node is drained for a values change that lands only in manifests.
Available functions
Templates are rendered with the sprig-compatible function set from sprout, which is what a Helm chart author already expects: toYaml, upper, b64enc, default, quote, the arithmetic and list helpers, and the rest.
Four functions are removed: env, expandEnv, expandenv, and getHostByName. A package author writes the templates and an operator runs them, so leaving these in place would let a package read the operator’s environment into chart values, or resolve names against the operator’s DNS. Helm removes the same four for the same reason. They are removed rather than stubbed, so a package that uses one fails to parse instead of quietly rendering an empty string.
Five functions Helm defines and sprig itself lacks are added back: toToml, fromToml, toYamlPretty, fromYamlArray, and fromJsonArray. As in Helm, these report a parse failure in band – in the returned value – rather than failing the render.
Create-only functions
Four more functions are available to onCreate actions, and only there:
| Function | Purpose |
|---|---|
fileExists <path> | Whether a path exists on the machine building the package. |
cachedFileExists <relative path> | Whether a file exists under cargoship’s cache directory. |
cachedFilePath <relative path> | The absolute path of a file in cargoship’s cache directory. |
downloadToCache <url> <relative path> [sha256] | Downloads a URL into the cache and returns the local path. |
Pulling a missing artifact into the cache is the whole point of a create action, so these belong there. They are not available to values, manifests, files, or anything that runs at install time: a template that renders chart values should not be able to probe the deploy host or issue HTTP requests. A template that names one outside an action fails at parse time.
Each of these returns its error rather than a zero value, so a cache directory that cannot be read stops the build instead of reading as “the file is not there” and sending the package down the wrong branch.
Because the create-time function set is cargoship’s own, cargoship – not the vendored Zarf action runner – renders onCreate actions. Zarf still executes them, with its shells, retries, timeouts, and output capture unchanged, and .Constants and .Variables still resolve. Actions are rendered one at a time, in order, so a variable one action sets is readable by the next.
spec:
actions:
onCreate:
before:
- description: pull the chart
template: true
cmd: |
{{- if not (cachedFileExists "charts/cilium.tgz") }}
curl -sSLo {{ cachedFilePath "charts/cilium.tgz" }} {{ .Values.cilium.chartURL }}
{{- end }}
One consequence of resolving values before the build runs: an onCreate action cannot produce a values file that the same build then reads. Values are resolved, schema-checked, and packaged before the first action runs.
Verifying a package
Build the package and read the rendered output back:
cargoship create ./example/rke2-multi-cni-cilium/v1_36/v1.36.4-rke2r1
cargoship apply <package> --config inventory.yaml --dry-run
Values are resolved, the schema is checked, and every surface is rendered before the first phase runs, so a bad value or a template that names a key the package does not define fails ahead of any change to a host. A dry run stops there.