Skip to content

Commit f73b937

Browse files
authored
Cache helm templates for the same inputs (#1016)
1 parent 46c5ced commit f73b937

File tree

2 files changed

+150
-4
lines changed

2 files changed

+150
-4
lines changed

pkg/helm/jsonnet.go

+31
Original file line numberDiff line numberDiff line change
@@ -1,17 +1,24 @@
11
package helm
22

33
import (
4+
"crypto/sha256"
5+
"encoding/base64"
46
"encoding/json"
57
"fmt"
8+
"sync"
69

710
"github.com/google/go-jsonnet"
811
"github.com/google/go-jsonnet/ast"
912
"github.com/grafana/tanka/pkg/kubernetes/manifest"
13+
"github.com/rs/zerolog/log"
1014
)
1115

1216
// DefaultNameFormat to use when no nameFormat is supplied
1317
const DefaultNameFormat = `{{ print .kind "_" .metadata.name | snakecase }}`
1418

19+
// helmTemplateCache caches the inline environments' rendered helm templates.
20+
var helmTemplateCache sync.Map
21+
1522
// JsonnetOpts are additional properties the consumer of the native func might
1623
// pass.
1724
type JsonnetOpts struct {
@@ -57,6 +64,16 @@ func NativeFunc(h Helm) *jsonnet.NativeFunction {
5764
return nil, fmt.Errorf("helmTemplate: Failed to find a chart at '%s': %s. See https://tanka.dev/helm#failed-to-find-chart", chart, err)
5865
}
5966

67+
// check if resources exist in cache
68+
helmKey, err := templateKey(name, chartpath, opts.TemplateOpts)
69+
if err != nil {
70+
return nil, err
71+
}
72+
if entry, ok := helmTemplateCache.Load(helmKey); ok {
73+
log.Debug().Msgf("Using cached template for %s", name)
74+
return entry, nil
75+
}
76+
6077
// render resources
6178
list, err := h.Template(name, chart, opts.TemplateOpts)
6279
if err != nil {
@@ -69,11 +86,25 @@ func NativeFunc(h Helm) *jsonnet.NativeFunction {
6986
return nil, err
7087
}
7188

89+
helmTemplateCache.Store(helmKey, out)
7290
return out, nil
7391
},
7492
}
7593
}
7694

95+
// templateKey returns the key identifier used in the template cache for the given helm chart.
96+
func templateKey(chartName string, chartPath string, opts TemplateOpts) (string, error) {
97+
hasher := sha256.New()
98+
hasher.Write([]byte(chartName))
99+
hasher.Write([]byte(chartPath))
100+
valuesBytes, err := json.Marshal(opts)
101+
if err != nil {
102+
return "", err
103+
}
104+
hasher.Write(valuesBytes)
105+
return base64.URLEncoding.EncodeToString(hasher.Sum(nil)), nil
106+
}
107+
77108
func parseOpts(data interface{}) (*JsonnetOpts, error) {
78109
c, err := json.Marshal(data)
79110
if err != nil {

pkg/helm/jsonnet_test.go

+119-4
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import (
1010
)
1111

1212
const calledFrom = "/my/path/here"
13+
const kubeVersion = "1.18.0"
1314

1415
type MockHelm struct {
1516
mock.Mock
@@ -88,8 +89,6 @@ func callNativeFunction(t *testing.T, expectedHelmTemplateOptions TemplateOpts,
8889
// TestDefaultCommandineFlagsIncludeCrds tests that the includeCrds flag is set
8990
// to true by default
9091
func TestDefaultCommandLineFlagsIncludeCrds(t *testing.T) {
91-
kubeVersion := "1.18.0"
92-
9392
// we will check that the template function is called with these options,
9493
// i.e. that includeCrds got set to true. This is not us passing an input,
9594
// we are asserting here that the template function is called with these
@@ -116,8 +115,6 @@ func TestDefaultCommandLineFlagsIncludeCrds(t *testing.T) {
116115
// TestIncludeCrdsFalse tests that the includeCrds flag is can be set to false,
117116
// and this makes it to the helm.Template() method call
118117
func TestIncludeCrdsFalse(t *testing.T) {
119-
kubeVersion := "1.18.0"
120-
121118
// we will check that the template function is called with these options,
122119
// i.e. that includeCrds got set to false. This is not us passing an input,
123120
// we are asserting here that the template function is called with these
@@ -140,3 +137,121 @@ func TestIncludeCrdsFalse(t *testing.T) {
140137
// `helm template` don't contain the --include-crds flag
141138
require.NotContains(t, args, "--include-crds")
142139
}
140+
141+
// TestTemplateCachingWorks tests that calling template with the same chart and values twice will
142+
// use the cached version the second time.
143+
func TestTemplateCachingWorks(t *testing.T) {
144+
values := map[string]interface{}{"testkey": "testvalue"}
145+
expectedHelmTemplateOptions := TemplateOpts{
146+
Values: values,
147+
KubeVersion: kubeVersion,
148+
IncludeCRDs: true,
149+
}
150+
inputOptionsFromJsonnet := make(map[string]interface{})
151+
inputOptionsFromJsonnet["values"] = values
152+
inputOptionsFromJsonnet["calledFrom"] = calledFrom
153+
inputOptionsFromJsonnet["kubeVersion"] = kubeVersion
154+
155+
helmMock := &MockHelm{}
156+
// ChartExists called on both function calls, only template is cached
157+
helmMock.On(
158+
"ChartExists",
159+
"exampleChartPath",
160+
mock.AnythingOfType("*helm.JsonnetOpts")).
161+
Return("/full/chart/path", nil).
162+
Twice()
163+
// this verifies that the helmMock.Template() method is called with the
164+
// correct arguments and only a single time.
165+
helmMock.On("Template", "exampleChartName", "/full/chart/path", expectedHelmTemplateOptions).
166+
Return(manifest.List{}, nil).
167+
Once()
168+
169+
nf := NativeFunc(helmMock)
170+
require.NotNil(t, nf)
171+
172+
// the mandatory parameters to helm.template() in Jsonnet
173+
params := []string{
174+
"exampleChartName",
175+
"exampleChartPath",
176+
}
177+
178+
// mandatory parameters + the k-v pairs from the Jsonnet input
179+
paramsInterface := make([]interface{}, 3)
180+
paramsInterface[0] = params[0]
181+
paramsInterface[1] = params[1]
182+
paramsInterface[2] = inputOptionsFromJsonnet
183+
184+
_, err := nf.Func(paramsInterface)
185+
firstCommandArgs := helmMock.TestData().Get("templateCommandArgs").StringSlice()
186+
require.NoError(t, err)
187+
_, err = nf.Func(paramsInterface)
188+
secondCommandArgs := helmMock.TestData().Get("templateCommandArgs").StringSlice()
189+
require.NoError(t, err)
190+
191+
helmMock.AssertExpectations(t)
192+
193+
// Verify command line args are same between the two calls
194+
require.Equal(t, firstCommandArgs, secondCommandArgs)
195+
}
196+
197+
type templateData struct {
198+
chartName string
199+
chartPath string
200+
opts TemplateOpts
201+
}
202+
203+
var templateTestCases = []struct {
204+
name string
205+
data templateData
206+
errMessage string
207+
}{
208+
{
209+
name: "emptyData",
210+
data: templateData{
211+
chartName: "testChart",
212+
chartPath: "./chart/path",
213+
opts: TemplateOpts{},
214+
},
215+
},
216+
{
217+
name: "fullData",
218+
data: templateData{
219+
chartName: "bigChart",
220+
chartPath: "./chart/bigPath",
221+
opts: TemplateOpts{
222+
map[string]interface{}{
223+
"installCRDs": true,
224+
"multitenancy": map[string]interface{}{
225+
"enabled": false,
226+
"defaultServiceAccount": "default",
227+
"privileged": false,
228+
},
229+
"clusterDomain": "cluster.local",
230+
"cli": map[string]interface{}{
231+
"image": "test-image.io",
232+
"nodeSelector": map[string]interface{}{},
233+
"tolerations": []interface{}{},
234+
},
235+
"baz": []int32{12, 13},
236+
},
237+
[]string{"asdf", "qwer", "zxcv"},
238+
true,
239+
false,
240+
"version",
241+
"namespace",
242+
false,
243+
},
244+
},
245+
},
246+
}
247+
248+
func BenchmarkTemplateKey(b *testing.B) {
249+
for _, c := range templateTestCases {
250+
b.Run(c.name, func(b *testing.B) {
251+
for i := 0; i < b.N; i++ {
252+
// nolint:errcheck
253+
templateKey(c.data.chartName, c.data.chartPath, c.data.opts)
254+
}
255+
})
256+
}
257+
}

0 commit comments

Comments
 (0)