While Helm has become the de-facto standard mechanism for distributing applications on Kubernetes, in Istio we built our own install tool for various legacy reasons. As time has gone on and Helm has continued to evolve in functionality and popularity, this standalone tool makes less sense. While it mostly poorly re-invents a subset of Helm, it did have a few nice additions as well. However, it turns out almost all of these are possible with Helm as well, if you are willing to go beyond the basics.

If YAML templating is so bad, how about rewriting a bunch code as a template instead?

Chart validations

Helm 3 added the ability to define JSON schema validations for values. This is a great start on adding validations, and adding this adds validation beyond the majority of charts out in the wild.

However, there is a way to do completely custom validations as well, provided they fit within the predefined set of functions Helm provides. This can be done by conditionally running fail function:

{{- if gt .Values.replicas 10 }}
{{ fail "replicas must be below 10 "}}
{{- end }}

With usage:

$ helm template test . --set replicas=11
Error: execution error at (test/templates/NOTES.txt:1:3): replicas must be below 10

Similar methods can be used for warnings for things like deprecated features by using NOTES.txt:

{{- if hasKey .Values "replicas" }}
Warning: .Values.replicas is deprecated!
{{- end }}

Resulting in:

$ helm install --dry-run test . --set replicas=11
...
Warning: .Values.replicas is deprecated!

However, this is really easy to miss. helm template doesn't show NOTES.txt at all, and many tools build on top of Helm that likely result in humans never seeing the message.

Profiles

Helm chart authors often face an eternal struggle between defining low and high level values.

If I want to expose a "High Availability" mode, do I make that --set ha=true? Or --set app1.autoscaling.enabled=true --set app1.autoscaling.replicas=2 --set app1.pdb.enabled=true --set app2.autoscaling.enabled=true ...?

Maybe I want to allow both of these, which makes the templates a mess of conditionals.

In Istio we solved this with a concept of "profiles", which were just a group of preconfigured values. For instance, I could do --set profile=ha or --set profile=experimental-features.

In Helm you can almost do this. helm install -f <file> allows passing in a file of values, but its awkward to actually use a file from a remote chart, because they cannot be bundled with the chart itself, which is likely coming from a remote repository.

Through some tricks, a Helm chart can natively expose this 'profiles' concept. We will need a few pieces:

Ultimately, we want to do a 3 way merge, with order: values.yaml < profile < -f or --set values. This allows a user to override a value set by a profile (for example, --set profile=ha --set replicas=5).

In order to do this, we need to split values.yaml and overrides. However, Helm just passes us one merged .Values. There are a few options here:

  • Move all values under a key (say defaults), then do some processing later. This is the approach shown below
  • Read the original values.yaml via .Files. The problem with this approach is values.yaml is filtered from .Files! So you will need to symlink (which adds an annoying warning on each use) or manually copy the file).

Next, we need to place some profiles under files/ This is just a set of values, like you would pass to helm install -f <values>.

Finally, we make a special template zzz_profile.yaml. This is purposefully named to ensure Helm runs it before any other templates -- template are executed in reverse alphabetical order.

The result:

.
├── Chart.yaml
├── values.yaml # Changes needed
├── files # Add profiles here!
│   ├── profile-ha.yaml
│   ├── profile-experimental-features.yaml
├── templates
│   └── zzz_profile.yaml # The magic

The real work happens in zzz_profile.yaml:

{{- $defaults := $.Values.defaults }}
{{- $unused1 := unset $.Values "defaults" }}

{{- $profile := dict }}
{{- with .Values.profile }}
{{- with $.Files.Get (printf "files/profile-%s.yaml" .)}}
{{- $profile = (. | fromYaml) }}
{{- else }}
{{ fail (cat "unknown profile" $.Values.profile) }}

{{- if $profile }}
{{- $unused2 := mustMergeOverwrite $defaults $profile }}
{{- end }}
{{- $unused3 := set $ "Values" (mustMergeOverwrite $defaults $.Values) }}

This has a few complex pieces.

First, we extract the default values we split out in values.yaml above. Now .Values is just the users overrides.

Next, we load the profile values.

Then we apply the profile values on top of the default values. This gives us the "values.yaml < profile" behavior.

Finally, we apply the override values on top of the result, giving us the full "values.yaml < profile < -f or --set values" behavior, and write this to .Values.

After that, charts can simply use .Values.<whatever> as usual without changing to support profiles.