Skip to content

Cache helm templates for the same inputs #1016

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 1 commit into from
Apr 18, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
31 changes: 31 additions & 0 deletions pkg/helm/jsonnet.go
Original file line number Diff line number Diff line change
@@ -1,17 +1,24 @@
package helm

import (
"crypto/sha256"
"encoding/base64"
"encoding/json"
"fmt"
"sync"

"github.com/google/go-jsonnet"
"github.com/google/go-jsonnet/ast"
"github.com/grafana/tanka/pkg/kubernetes/manifest"
"github.com/rs/zerolog/log"
)

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

// helmTemplateCache caches the inline environments' rendered helm templates.
var helmTemplateCache sync.Map

// JsonnetOpts are additional properties the consumer of the native func might
// pass.
type JsonnetOpts struct {
Expand Down Expand Up @@ -57,6 +64,16 @@ func NativeFunc(h Helm) *jsonnet.NativeFunction {
return nil, fmt.Errorf("helmTemplate: Failed to find a chart at '%s': %s. See https://tanka.dev/helm#failed-to-find-chart", chart, err)
}

// check if resources exist in cache
helmKey, err := templateKey(name, chartpath, opts.TemplateOpts)
if err != nil {
return nil, err
}
if entry, ok := helmTemplateCache.Load(helmKey); ok {
log.Debug().Msgf("Using cached template for %s", name)
return entry, nil
}

// render resources
list, err := h.Template(name, chart, opts.TemplateOpts)
if err != nil {
Expand All @@ -69,11 +86,25 @@ func NativeFunc(h Helm) *jsonnet.NativeFunction {
return nil, err
}

helmTemplateCache.Store(helmKey, out)
return out, nil
},
}
}

// templateKey returns the key identifier used in the template cache for the given helm chart.
func templateKey(chartName string, chartPath string, opts TemplateOpts) (string, error) {
hasher := sha256.New()
hasher.Write([]byte(chartName))
hasher.Write([]byte(chartPath))
valuesBytes, err := json.Marshal(opts)
if err != nil {
return "", err
}
hasher.Write(valuesBytes)
return base64.URLEncoding.EncodeToString(hasher.Sum(nil)), nil
}

func parseOpts(data interface{}) (*JsonnetOpts, error) {
c, err := json.Marshal(data)
if err != nil {
Expand Down
123 changes: 119 additions & 4 deletions pkg/helm/jsonnet_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import (
)

const calledFrom = "/my/path/here"
const kubeVersion = "1.18.0"

type MockHelm struct {
mock.Mock
Expand Down Expand Up @@ -88,8 +89,6 @@ func callNativeFunction(t *testing.T, expectedHelmTemplateOptions TemplateOpts,
// TestDefaultCommandineFlagsIncludeCrds tests that the includeCrds flag is set
// to true by default
func TestDefaultCommandLineFlagsIncludeCrds(t *testing.T) {
kubeVersion := "1.18.0"

// we will check that the template function is called with these options,
// i.e. that includeCrds got set to true. This is not us passing an input,
// we are asserting here that the template function is called with these
Expand All @@ -116,8 +115,6 @@ func TestDefaultCommandLineFlagsIncludeCrds(t *testing.T) {
// TestIncludeCrdsFalse tests that the includeCrds flag is can be set to false,
// and this makes it to the helm.Template() method call
func TestIncludeCrdsFalse(t *testing.T) {
kubeVersion := "1.18.0"

// we will check that the template function is called with these options,
// i.e. that includeCrds got set to false. This is not us passing an input,
// we are asserting here that the template function is called with these
Expand All @@ -140,3 +137,121 @@ func TestIncludeCrdsFalse(t *testing.T) {
// `helm template` don't contain the --include-crds flag
require.NotContains(t, args, "--include-crds")
}

// TestTemplateCachingWorks tests that calling template with the same chart and values twice will
// use the cached version the second time.
func TestTemplateCachingWorks(t *testing.T) {
values := map[string]interface{}{"testkey": "testvalue"}
expectedHelmTemplateOptions := TemplateOpts{
Values: values,
KubeVersion: kubeVersion,
IncludeCRDs: true,
}
inputOptionsFromJsonnet := make(map[string]interface{})
inputOptionsFromJsonnet["values"] = values
inputOptionsFromJsonnet["calledFrom"] = calledFrom
inputOptionsFromJsonnet["kubeVersion"] = kubeVersion

helmMock := &MockHelm{}
// ChartExists called on both function calls, only template is cached
helmMock.On(
"ChartExists",
"exampleChartPath",
mock.AnythingOfType("*helm.JsonnetOpts")).
Return("/full/chart/path", nil).
Twice()
// this verifies that the helmMock.Template() method is called with the
// correct arguments and only a single time.
helmMock.On("Template", "exampleChartName", "/full/chart/path", expectedHelmTemplateOptions).
Return(manifest.List{}, nil).
Once()

nf := NativeFunc(helmMock)
require.NotNil(t, nf)

// the mandatory parameters to helm.template() in Jsonnet
params := []string{
"exampleChartName",
"exampleChartPath",
}

// mandatory parameters + the k-v pairs from the Jsonnet input
paramsInterface := make([]interface{}, 3)
paramsInterface[0] = params[0]
paramsInterface[1] = params[1]
paramsInterface[2] = inputOptionsFromJsonnet

_, err := nf.Func(paramsInterface)
firstCommandArgs := helmMock.TestData().Get("templateCommandArgs").StringSlice()
require.NoError(t, err)
_, err = nf.Func(paramsInterface)
secondCommandArgs := helmMock.TestData().Get("templateCommandArgs").StringSlice()
require.NoError(t, err)

helmMock.AssertExpectations(t)

// Verify command line args are same between the two calls
require.Equal(t, firstCommandArgs, secondCommandArgs)
}

type templateData struct {
chartName string
chartPath string
opts TemplateOpts
}

var templateTestCases = []struct {
name string
data templateData
errMessage string
}{
{
name: "emptyData",
data: templateData{
chartName: "testChart",
chartPath: "./chart/path",
opts: TemplateOpts{},
},
},
{
name: "fullData",
data: templateData{
chartName: "bigChart",
chartPath: "./chart/bigPath",
opts: TemplateOpts{
map[string]interface{}{
"installCRDs": true,
"multitenancy": map[string]interface{}{
"enabled": false,
"defaultServiceAccount": "default",
"privileged": false,
},
"clusterDomain": "cluster.local",
"cli": map[string]interface{}{
"image": "test-image.io",
"nodeSelector": map[string]interface{}{},
"tolerations": []interface{}{},
},
"baz": []int32{12, 13},
},
[]string{"asdf", "qwer", "zxcv"},
true,
false,
"version",
"namespace",
false,
},
},
},
}

func BenchmarkTemplateKey(b *testing.B) {
for _, c := range templateTestCases {
b.Run(c.name, func(b *testing.B) {
for i := 0; i < b.N; i++ {
// nolint:errcheck
templateKey(c.data.chartName, c.data.chartPath, c.data.opts)
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Interesting, turns out that json.Marshal really is the fastest option (of the few I tested)

}
})
}
}