Helm is a good tool to create common templates for your deployments. But when you start writing complex
templates testing them manually is unpleasure. Could I use TDD (Test-Driven Development) for
writing Helm Charts?
Basic testing includes such manual operations as:
Create test-service-values.yml with test values. Change that file for any case that you want to test.
Run something like this helm template test-service spring-microservice --namespace esbs -f test-service-values.yml > test-service.yml
Manually check the generated file test-service.yml that it’s correct.
After some investigation I found three approaches to write unit tests for Helm Charts:
For example our deployment.yaml from start was such:
1
2
3
4
5
6
7
8
9
10
11
12
apiVersion:apps/v1kind:Deployment# ...spec:# ...template:metadata:{{- with .Values.podAnnotations }}annotations:{{- toYaml . | nindent 8 }}{{- end }}# ...
First iteration
First, create a directory called tests inside helm chart root folder. Then inside that folder create
a file called vault_support_test.yaml. Test files must include suffix _test.yaml. Add tests directory
into .helmignore file.
suite:test Vault Agent integrationtemplates:- deployment.yamltests:- it:should not generate Vault annotations if vault.enabled = falseset:vault.enabled:falseasserts:- isNull:path:spec.template.metadata.annotations- it:should set annotations from podAnnotationsset:vault.enabled:falsepodAnnotations:test/some-annotation:"some-value"asserts:- equal:path:spec.template.metadata.annotationsvalue:test/some-annotation:"some-value"- isNull:path:spec.template.metadata.annotations.[vault.hashicorp.com/agent-inject]
# Patterns to ignore when building packages.
# This supports shell glob matching, relative path matching, and
# negation (prefixed with !). Only one pattern per line.
.DS_Store
# ...
tests
To run tests:
1
helm unittest --helm3 spring-microservice
1
2
3
4
5
6
7
8
9
### Chart [ spring-microservice ] spring-microservice
PASS test Vault Agent integration spring-microservice/tests/vault_support_test.yaml
Charts: 1 passed, 1 total
Test Suites: 1 passed, 1 total
Tests: 2 passed, 2 total
Snapshot: 0 passed, 0 total
Time: 59.915078ms
You can get an error if you’re not using --helm3 flag:
1
2
3
4
5
6
7
8
### Error: apiVersion 'v2' is not valid. The value must be "v1"
Charts: 1 failed, 1 errored, 0 passed, 1 total
Test Suites: 0 passed, 0 total
Tests: 0 passed, 0 total
Snapshot: 0 passed, 0 total
Time: 6.379641ms
Now let’s add functionality to our Helm Chart step by step using TDD.
First, add new test to the suite:
1
2
3
4
5
6
7
8
9
10
11
- it:should set annotations from podAnnotations and generated Vault annotationsset:vault.enabled:truepodAnnotations:test/some-annotation:"some-value"asserts:- equal:path:spec.template.metadata.annotations.[test/some-annotation]value:"some-value"- isNotNull:path:spec.template.metadata.annotations.[vault.hashicorp.com/agent-inject]
After running test we will get an error:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
FAIL test Vault Agent integration spring-microservice/tests/vault_support_test.yaml
- should set annotations from podAnnotations and generated Vault annotations
- asserts[1] `isNotNull` fail
Template: spring-microservice/templates/deployment.yaml
DocumentIndex: 0
Path: spec.template.metadata.annotations.[vault.hashicorp.com/agent-inject]
Expected NOT to be null, got:
null
Charts: 1 failed, 0 passed, 1 total
Test Suites: 1 failed, 0 passed, 1 total
Tests: 1 failed, 2 passed, 3 total
Then we write simple code improvement to implement our new requirement:
1
2
3
4
5
6
7
{{- if or .Values.podAnnotations .Values.vault.enabled }}annotations:{{- if .Values.vault.enabled }}vault.hashicorp.com/agent-inject:true{{- end }}{{- toYaml .Values.podAnnotations | nindent 8 }}{{- end }}
Second iteration
Now write a new test:
1
2
3
4
5
6
7
8
9
10
11
- it:should generate Vault annotations by defaultasserts:- equal:path:spec.template.metadata.annotationsvalue:vault.hashicorp.com/agent-inject:truevault.hashicorp.com/agent-image:"registry-test.alfa-bank.kz/esbs/docker-base-images/vault:1.9.2-curl"vault.hashicorp.com/preserve-secret-case:truevault.hashicorp.com/ca-cert:"/vault/tls/ca.crt"vault.hashicorp.com/tls-secret:"vault-tls-client"vault.hashicorp.com/role:"k8s-test-default-role"
For that test we will add new values to values.yaml. That values used by default:
FAIL test Vault Agent integration spring-microservice/tests/vault_support_test.yaml
- should generate Vault annotations by default
Error: yaml: line 26: did not find expected key
Charts: 1 failed, 0 passed, 1 total
Test Suites: 1 failed, 0 passed, 1 total
Tests: 1 failed, 1 errored, 3 passed, 4 total
You can see that it’s not just failed (means that assertions are failed), but errored. That tells us
to look at code on line 26. In my example, if .Values.podAnnotations is empty, then this error arises.
Let’s fix it:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
{{- if or .Values.podAnnotations .Values.vault.enabled }}annotations:{{- if .Values.vault.enabled }}vault.hashicorp.com/agent-inject:truevault.hashicorp.com/agent-image:"registry-test.alfa-bank.kz/esbs/docker-base-images/vault:1.9.2-curl"vault.hashicorp.com/preserve-secret-case:truevault.hashicorp.com/ca-cert:"/vault/tls/ca.crt"vault.hashicorp.com/tls-secret:"vault-tls-client"vault.hashicorp.com/role:"k8s-test-default-role"{{- end }}{{- with .Values.podAnnotations }}{{- toYaml . | nindent 8 }}{{- end }}{{- end }}
Refactoring
Now we can see 4 passed test, without any failed or errored. We can refactor this code to simplify
maintainability. Create templates/_vault.tpl file with the following content:
{{/*Sets pod annotations with vault injector annotations*/}}{{- define "spring-boot-microservice.podAnnotations" -}}{{- if or .Values.podAnnotations .Values.vault.enabled }}annotations:{{- if .Values.vault.enabled }}{{- include "spring-boot-microservice.vaultAnnotations" . | nindent 8 }}{{- end }}{{- with .Values.podAnnotations }}{{- toYaml . | nindent 8 }}{{- end }}{{- end }}{{- end }}{{/*Sets vault injector annotations*/}}{{- define "spring-boot-microservice.vaultAnnotations" -}}vault.hashicorp.com/agent-inject:truevault.hashicorp.com/agent-image:"registry-test.alfa-bank.kz/esbs/docker-base-images/vault:1.9.2-curl"vault.hashicorp.com/preserve-secret-case:truevault.hashicorp.com/ca-cert:"/vault/tls/ca.crt"vault.hashicorp.com/tls-secret:"vault-tls-client"vault.hashicorp.com/role:{{.Values.vault.role | quote }}{{- end }}
And change templates/deployment.yaml to simple one:
{{- define "spring-boot-microservice.vaultAnnotations" -}}#...vault.hashicorp.com/agent-inject-secret-config.properties:{{include "spring-boot-microservice.vaultSecretPath" . }}vault.hashicorp.com/agent-inject-template-config.properties:| {{`{{- with secret "/k8s-prod/data/test/test-service" -}}
{{- range $k, $v := .Data.data -}}
{{- if not ($k | regexMatch "base64file") -}}
{{- $k }}={{ printf "%s\n" $v -}}
{{- end -}}
{{- end -}}
{{- end -}}`}}{{- end }}{{/* Get Vault secret path */}}{{- define "spring-boot-microservice.vaultSecretPath" -}}{{- printf "%s/data/%s/%s" "k8s-prod".Release.Namespace(include "spring-microservice.fullname" . ) }}{{- end }}
Fifth iteration
Now we want to define Vault KV secret based on environment variable. If it set to prod, k8s-prod KV
should be used.
1
2
3
4
5
6
7
8
9
10
- it:should use Vault KV based on environment settingrelease:namespace:test-nsset:fullnameOverride:"test-service"environment:prodasserts:- equal:path:spec.template.metadata.annotations.[vault.hashicorp.com/agent-inject-secret-config.properties]value:"k8s-prod/data/test-ns/test-service"
Add the following configuration settings into values.yaml:
{{- define "spring-boot-microservice.vaultSecretPath" -}}{{- printf "%s/data/%s/%s"(include "spring-boot-microservice.vaultKV" . ).Release.Namespace(include "spring-microservice.fullname" . ) }}{{- end }}{{/*Define Vault KV (key-value) engine based on selected environment (test|prod)*/}}{{- define "spring-boot-microservice.vaultKV" -}}{{- if .Values.environment }}{{- default "" (printf "%s" (index .Values.vaultKV .Values.environment)) }}{{- end }}{{- end }}
Templates issues
Suppose that we want to use predefined templates for Vault templating. And that templates will be not
Strings, but another template that we evaluate using tpl function. For example, our values.yaml
will be changed:
vault:enabled:truefiles:config.properties:secretPath:'{{ include "spring-boot-microservice.vaultSecretPath" . }}'secretKey:""useTemplate:"kv"templates:kv:| {{`{{- with secret`}} {{ .secretPath | quote }} {{`-}}
{{- range $k, $v := .Data.data -}}
{{- if not ($k | regexMatch "base64file") -}}
{{- $k }}={{ printf "%s\n" $v -}}
{{- end -}}
{{- end -}}
{{- end -}}`}}file:| {{`{{- with secret` }} {{ .secretPath | quote }} {{ `-}}
{{- base64Decode (index .Data.data `}}{{ .secretKey | quote }}{{`) }}
{{- end -}}`}}
As you can see, vault.templates.kv contains .secretPath variable, and vault.templates.file
additionally evaluates .secretKey variable. The values of that variables should be gotten from
vault.files.*.secretPath and vault.files.*.secretKey respectively.
Previously in our test suite we have specified only one deployment.yaml in the list templates. That
means only one template will be generated for tests.
Consider a case when we want to generate ServiceAccount even if serviceAccount.create disabled
but vault.enabled is true. If we specify new test like this:
1
2
3
4
5
6
7
8
9
10
11
- it:should enable serviceAccount when vault is enabledset:fullnameOverride:"test-service"serviceAccount.create:falsevault.enabled:trueasserts:- containsDocument:kind:ServiceAccountapiVersion:v1name:test-servicetemplate:serviceaccount.yaml
It will fail with an error:
1
2
3
- asserts[1] `containsDocument` fail
Error:
template "spring-microservice/templates/serviceaccount.yaml" not exists or not selected in test suite
This error tells us that when you specify template value for specific assert or test, it must be
included in the list of templates in a test suite. So to not overwrite previous tests, that used
only deployment.yaml we will create separate test suite tests/serviceaccount_test.yaml.
It contains all variations of configuration settings that we want:
suite:test ServiceAccounttemplates:- deployment.yaml- serviceaccount.yamltests:- it:should enable serviceAccount when vault is enabledset:fullnameOverride:"test-service"serviceAccount.create:falsevault.enabled:trueasserts:- equal:path:spec.template.spec.serviceAccountNamevalue:"test-service"template:deployment.yaml- containsDocument:kind:ServiceAccountapiVersion:v1name:test-servicetemplate:serviceaccount.yaml- it:should create serviceAccount when serviceAccount.create enabledset:fullnameOverride:"another-service"serviceAccount.create:truevault.enabled:falseasserts:- equal:path:spec.template.spec.serviceAccountNamevalue:"another-service"template:deployment.yaml- containsDocument:kind:ServiceAccountapiVersion:v1name:another-servicetemplate:serviceaccount.yaml- it:should not create serviceAccount when serviceAccount.create disabledset:serviceAccount.create:falsevault.enabled:falseasserts:- equal:path:spec.template.spec.serviceAccountNamevalue:"default"template:deployment.yaml- hasDocuments:count:0template:serviceaccount.yaml
Another trick to implement such behaviour is setting value of serviceAccount.create to true
if vault.enabled is true using set function:
1
2
3
4
5
6
7
8
{{- if .Values.vault.enabled -}}{{- $_ := set .Values.serviceAccount "create" true }}{{- end -}}{{- if .Values.serviceAccount.create -}}apiVersion:v1kind:ServiceAccount#...{{- end }}