Helm's Hidden Quirks: Merging boolean values
Helm is a powerful tool for manageing Kubernetes applications. Despite its ubiquity in the kubernetes-world, there are some nuances to consider when working with boolean values—especially when merging complex objects.
The easy case: Overwrite boolean values
A common use case in Helm charts is defining multiple components and allowing users to enable or disable them based on their needs. For example, many charts provide an Ingress resource by default but allow users to disable it. This might look like the following:
[...]
application:
ingress:
enabled: false
[...]
As expected, the enabled: true
value from the application's chart can be overridden with enabled: false
by the user. This works seamlessly and is straightforward to understand.
The complex case: Merging
Now, let’s imagine a more complex scenario. Suppose you’re deploying an application consisting of multiple smaller components, each with its own configuration. Many of these components share common settings, such as Ingress configurations. To avoid redundancy, you might define a default configuration and merge it with component-specific overrides. Here’s an example of what such a values file might look like:
default:
ingresses:
default-ingress:
enabled: true
service: default-service
port: 80
config:
component1:
component2:
ingresses:
additional-ingress:
enabled: true
service: additional-service
port: 123
component3:
ingresses:
default-ingress:
enabled: false
In this setup, the default
section contains the shared part of the configuration, while the config
section defines overrides for specific components. To use this, you could loop over the components, merge the component-specific config with the default, and create the resources. For merging dictionaries, there is an dedicated merge
function, which could be used like this:
{{ $merged := merge $.Values.default.ingresses (default dict ($deployment).ingresses) }}
The resulting YAML output for each component would look like this:
component1:
default-ingress:
enabled: true
port: 80
service: default-service
---
component2:
additional-ingress:
enabled: true
port: 123
service: additional-service
default-ingress:
enabled: true
port: 80
service: default-service
---
component3:
additional-ingress:
enabled: true
port: 123
service: additional-service
default-ingress:
enabled: true
port: 80
service: default-service
At first glance, Components 1 and 2 look correct. However, Component 3 has two issues:
- It includes an extra Ingress (additional-ingress) that shouldn’t be there.
- The default-ingress is enabled, even though it was explicitly set to false in the configuration.
The Problem: merge
The root of the issue lies in how Helm's merge
function is defined. merge
does not overwrite boolean fields that are set to True
. Because this behavior is not included in the method documentation, it can be quite surprising.
To address this issue, you need to use mergeOverwrite
. This method does overwrite True
values, which is what we need here. Additionally, the precedence changes: while merge
gives precedence from left to right, mergeOverwrite
gives precedence from right to left.
The second thing to keep in mind: The first argument for merge
and mergeOverwrite
is used as the return variable. Therefore, in a loop, you need to ensure that the values stored in the first argument are not passed to the following loop iteration. This is what causes the two ingresses in the third component shown in the example.
Merging, but this time correctly
To implement the merge correctly, there are two options.
- Copy the first argument:
{{ $merged := mergeOverwrite (deepCopy $.Values.default.ingresses) (default dict ($deployment).ingresses) }}
- pass an empty dictionary as first argument:
{{ $merged := mergeOverwrite (dict) ($.Values.default.ingresses) (default dict ($deployment).ingresses) }}
In both cases, the result is what we want to achieve. Component 1 has only the default ingress defined, Component 2 an additional ingress and Component 3 disables the default - without adding an custom one:
name: component1
default-ingress:
enabled: true
port: 80
service: default-service
---
name: component2
additional-ingress:
enabled: true
port: 123
service: additional-service
default-ingress:
enabled: true
port: 80
service: default-service
---
name: component3
default-ingress:
enabled: false
port: 80
service: default-service