12. Render the ConfigMap without mutating .Values
Date: 2026-06-29
Status
Accepted
Context
The registry's blob descriptor cache and the garbage-collection CronJob cannot
run against the same store: registry garbage-collect walks the filesystem while
the running registry assumes its in-memory (or Redis) blob descriptor cache is
authoritative, and the two views diverge — GC can delete blobs the cache still
believes are present, corrupting the registry. To prevent operators from
configuring a combination that silently corrupts data, the chart forces the cache
off whenever garbageCollect.enabled is true, regardless of what
configData.storage.cache.blobdescriptor is set to.
templates/configmap.yaml implemented that override by mutating the shared values
tree at render time:
{{- if .Values.garbageCollect.enabled -}}
{{- $_ := set .Values.configData.storage.cache "blobdescriptor" "disabled" -}}
{{- end -}}
...
config.yml: |-
{{ toYaml .Values.configData | indent 4 }}
set mutates the map in place. In Helm, .Values is a single tree shared by
every template in the release — it is not re-derived per file. So this line does
not just change what the ConfigMap renders; it permanently rewrites
.Values.configData.storage.cache.blobdescriptor to disabled for the remainder
of the render. Every template processed after configmap.yaml sees the mutated
value.
This is a leaky seam, and it is dangerous for three reasons:
- Render-order dependence. Helm renders templates in a deterministic but
incidental order (lexical by path). The correctness of any other template that
reads
configData.storage.cachewould silently depend on whether it happens to sort before or afterconfigmap.yaml. Today no other template reads that subtree (deployment.yaml,statefulset.yaml, andservice.yamlonly read the unrelatedconfigData.http.debug.addr), so the bug is latent — but it arms a trap for the next person who adds such a read. - Non-locality. A reader of, say,
deployment.yamlhas no way to know thatconfigDatamay have already been rewritten by an unrelated file. Behaviour is no longer explained by the code in front of you; understanding requires holding the whole render order in your head. - Idempotency by luck. The
checksum/configpod annotation re-includes the ConfigMap template (include (print $.Template.BasePath "/configmap.yaml")), so the mutation runs more than once per render. It happens to be idempotent (settingdisabledtwice is harmless), but the pattern does not guarantee that for future edits.
The override behaviour itself is correct and is covered by tests
(tests/unit/configmap_test.yaml asserts inmemory when GC is off and disabled
when GC is on, and the garbage-collect scenario depends on it). The problem is
purely how the override is applied, not what it produces.
Decision
Compute the effective config on a local deep copy and render that, leaving
.Values untouched:
{{- $configData := deepCopy .Values.configData -}}
{{- if .Values.garbageCollect.enabled -}}
{{- $_ := set $configData.storage.cache "blobdescriptor" "disabled" -}}
{{- end -}}
...
config.yml: |-
{{ toYaml $configData | indent 4 }}
deepCopy (sprig) produces a fully independent tree, including nested maps, so the
set on $configData.storage.cache cannot leak back into the shared .Values.
The rendered output is byte-for-byte identical to before for both the GC-on and
GC-off paths; only the side effect on shared state is removed.
The set/deepCopy mechanism is kept (rather than, say, building the disabled
config with dict/merge) because it is the smallest change that preserves the
exact existing output, and because the GC-on path is a single, well-understood
override of one leaf key.
Consequences
- The ConfigMap is now a pure function of its inputs: it reads
.Valuesand never writes to it. Render order no longer affects any other template, and the latent trap for future readers ofconfigData.storage.cacheis removed. - Behaviour is unchanged and the existing unit assertions and
garbage-collectscenario continue to pass; the change needed no test edits. - The decision assumes
configData.storage.cacheexists, the same assumption the original code made (the defaultvalues.yamlalways provides it). A fully customconfigDatathat dropsstorage.cachewould error on theset— if that ever becomes a real use case, guard thesetwith adiglookup. It was left out here to keep the fix behaviour-identical. - This establishes a chart-wide rule worth following in future templates: treat
.Valuesas read-only; derive a local copy when a template needs a modified view. In-placeset/unseton.Valuesshould be avoided.