Repository: grafana/tanka (go)

Issue:
Helmraiser creates naming conflicts.
I am playing with helmraiser with the [flux chart](https://github.com/fluxcd/flux/blob/master/chart/flux/templates/rbac-role.yaml#L2) which creates a `role` named `flux` in each namespace I ask it to run flux against. When I run `tk eval` I see that there is only a single object named `flux_role` in the evaluated json. 

This is because resources get added to the json with only `$NAME_$KIND` as the key. [Code here](https://github.com/grafana/tanka/blob/6d99591bf404393da3e0904cc8e04e7fda42a354/pkg/helmraiser/helm.go#L99).

This leads to a few competing concerns:

- The keys need to be unique so as to not cause conflicts / overwriting keys.
- The keys need to be usable and consistent to make later jsonnet overrides easier.

Possible solutions:

1. Always name things `$NAME_$KIND_$NAMESPACE`. 
    - fully explicit
    - we might not know the namespace until later (although I think we should set the namespace in the helm command. separate issue).
    - still a pain to access by a dynamic json key.
2. Keep naming as is **unless** the namespace differs from the spec namespace, then append it.
    - will usually act as it does now.
    - usually easier to consume / override.
3. Somehow make things a map by namespace, either at the top level, or inside child levels
    - still kinda hard to pull things out with jsonnet by dynamic namespace.

In any case, I'd vote that an error would be preferable to silently overwriting objects.

Pull request description:
feat(helm): Make name format configurable
Makes the key used for the map returned by `helmTemplate` configurable.

It is now permitted to pass a `nameFormat` field as part of the `opts` argument that changes the default name format `{{kind}}_{{name}}`.

To make helpful features such as `lower` or `snakecase` available in the template, https://github.com/Masterminds/sprig was added. This change has also been propagated to the template used in `tk export`.

Fixes #361 



Patch:
diff --git a/cmd/tk/export.go b/cmd/tk/export.go
index ba6d3ff4..8ce4a6ac 100644
--- a/cmd/tk/export.go
+++ b/cmd/tk/export.go
@@ -12,6 +12,7 @@ import (
 
 	"text/template"
 
+	"github.com/Masterminds/sprig/v3"
 	"github.com/go-clix/cli"
 
 	"github.com/grafana/tanka/pkg/tanka"
@@ -39,12 +40,6 @@ func exportCmd() *cli.Command {
 	vars := workflowFlags(cmd.Flags())
 	getJsonnetOpts := jsonnetFlags(cmd.Flags())
 
-	templateFuncMap := template.FuncMap{
-		"lower": func(s string) string {
-			return strings.ToLower(s)
-		},
-	}
-
 	cmd.Run = func(cmd *cli.Command, args []string) error {
 		// dir must be empty
 		to := args[1]
@@ -61,7 +56,9 @@ func exportCmd() *cli.Command {
 		// Replace all os.path separators in string with BelRune for creating subfolders
 		replacedFormat := strings.Replace(*format, string(os.PathSeparator), BelRune, -1)
 
-		tmpl, err := template.New("").Funcs(templateFuncMap).Parse(replacedFormat)
+		tmpl, err := template.New("").
+			Funcs(sprig.TxtFuncMap()). // register Masterminds/sprig
+			Parse(replacedFormat)      // parse template
 		if err != nil {
 			return fmt.Errorf("Parsing name format: %s", err)
 		}
diff --git a/go.mod b/go.mod
index 020d88ae..30b9626a 100644
--- a/go.mod
+++ b/go.mod
@@ -4,21 +4,24 @@ go 1.12
 
 require (
 	github.com/Masterminds/semver v1.4.2
+	github.com/Masterminds/sprig/v3 v3.1.0
 	github.com/fatih/color v1.9.0
 	github.com/fatih/structs v1.1.0
 	github.com/go-clix/cli v0.1.1
 	github.com/gobwas/glob v0.2.3
 	github.com/google/go-cmp v0.5.2-0.20200818193711-d2fcc899bdc2
 	github.com/google/go-jsonnet v0.16.1-0.20200908152747-b70cbd441a39
+	github.com/huandu/xstrings v1.3.2 // indirect
+	github.com/imdario/mergo v0.3.11 // indirect
 	github.com/karrick/godirwalk v1.15.5
 	github.com/pkg/errors v0.8.1
 	github.com/posener/complete v1.2.3
 	github.com/spf13/pflag v1.0.5
 	github.com/stretchr/objx v0.2.0
-	github.com/stretchr/testify v1.4.0
+	github.com/stretchr/testify v1.5.1
 	github.com/thoas/go-funk v0.4.0
-	golang.org/x/crypto v0.0.0-20190820162420-60c769a6c586
-	gopkg.in/yaml.v2 v2.2.8
+	golang.org/x/crypto v0.0.0-20200414173820-0848c9571904
+	gopkg.in/yaml.v2 v2.3.0
 	gopkg.in/yaml.v3 v3.0.0-20191010095647-fc94e3f71652
 	k8s.io/apimachinery v0.18.3
 	sigs.k8s.io/yaml v1.2.0
diff --git a/go.sum b/go.sum
index 17b5936c..3c768386 100644
--- a/go.sum
+++ b/go.sum
@@ -1,5 +1,11 @@
+github.com/Masterminds/goutils v1.1.0 h1:zukEsf/1JZwCMgHiK3GZftabmxiCw4apj3a28RPBiVg=
+github.com/Masterminds/goutils v1.1.0/go.mod h1:8cTjp+g8YejhMuvIA5y2vz3BpJxksy863GQaJW2MFNU=
 github.com/Masterminds/semver v1.4.2 h1:WBLTQ37jOCzSLtXNdoo8bNM8876KhNqOKvrlGITgsTc=
 github.com/Masterminds/semver v1.4.2/go.mod h1:MB6lktGJrhw8PrUyiEoblNEGEQ+RzHPF078ddwwvV3Y=
+github.com/Masterminds/semver/v3 v3.1.0 h1:Y2lUDsFKVRSYGojLJ1yLxSXdMmMYTYls0rCvoqmMUQk=
+github.com/Masterminds/semver/v3 v3.1.0/go.mod h1:VPu/7SZ7ePZ3QOrcuXROw5FAcLl4a0cBrbBpGY/8hQs=
+github.com/Masterminds/sprig/v3 v3.1.0 h1:j7GpgZ7PdFqNsmncycTHsLmVPf5/3wJtlgW9TNDYD9Y=
+github.com/Masterminds/sprig/v3 v3.1.0/go.mod h1:ONGMf7UfYGAbMXCZmQLy8x3lCDIPrEZE/rU8pmrbihA=
 github.com/NYTimes/gziphandler v0.0.0-20170623195520-56545f4a5d46/go.mod h1:3wb06e3pkSAbeQ52E9H9iFoQsEEwGN64994WTCIhntQ=
 github.com/PuerkitoBio/purell v1.0.0/go.mod h1:c11w/QuzBsJSee3cPx9rAFu61PvFxuPbtSwDGJws/X0=
 github.com/PuerkitoBio/urlesc v0.0.0-20160726150825-5bd2802263f2/go.mod h1:uGdkoq3SwY9Y+13GIhn11/XLaGBb4BfwItxLd5jeuXE=
@@ -35,14 +41,12 @@ github.com/google/go-cmp v0.3.0 h1:crn/baboCvb5fXaQ0IJ1SGTsTVrWpDsCWC8EGETZijY=
 github.com/google/go-cmp v0.3.0/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU=
 github.com/google/go-cmp v0.5.2-0.20200818193711-d2fcc899bdc2 h1:CZtx9gNen+kr3PuC/JQff3n1pJbgpy7Wr3hzjnupqdw=
 github.com/google/go-cmp v0.5.2-0.20200818193711-d2fcc899bdc2/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
-github.com/google/go-cmp v0.5.2 h1:X2ev0eStA3AbceY54o37/0PQ/UWqKEiiO2dKL5OPaFM=
-github.com/google/go-jsonnet v0.15.1-0.20200331184325-4f4aa80dd785 h1:+dlQ7fPoeAqO0U9V+94golo/rW1/V2Pn+v8aPp3ljRM=
-github.com/google/go-jsonnet v0.15.1-0.20200331184325-4f4aa80dd785/go.mod h1:sOcuej3UW1vpPTZOr8L7RQimqai1a57bt5j22LzGZCw=
 github.com/google/go-jsonnet v0.16.1-0.20200908152747-b70cbd441a39 h1:noLRnY1ESguFGDPxXvIcESe2rG63f+ZSbSGYfVa6iHo=
 github.com/google/go-jsonnet v0.16.1-0.20200908152747-b70cbd441a39/go.mod h1:sOcuej3UW1vpPTZOr8L7RQimqai1a57bt5j22LzGZCw=
 github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
 github.com/google/gofuzz v1.1.0 h1:Hsa8mG0dQ46ij8Sl2AYJDUv1oA9/d6Vk+3LG99Oe02g=
 github.com/google/gofuzz v1.1.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
+github.com/google/uuid v1.1.1 h1:Gkbcsh/GbpXz7lPftLA3P6TYMwjCLYm83jiFQZF/3gY=
 github.com/google/uuid v1.1.1/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
 github.com/googleapis/gnostic v0.0.0-20170729233727-0c5108395e2d/go.mod h1:sJBsCZ4ayReDTBIg8b9dl28c5xFWyhBTVRp3pOg5EKY=
 github.com/googleapis/gnostic v0.1.0/go.mod h1:sJBsCZ4ayReDTBIg8b9dl28c5xFWyhBTVRp3pOg5EKY=
@@ -52,6 +56,12 @@ github.com/hashicorp/go-multierror v1.0.0 h1:iVjPR7a6H0tWELX5NxNe7bYopibicUzc7uP
 github.com/hashicorp/go-multierror v1.0.0/go.mod h1:dHtQlpGsu+cZNNAkkCN/P3hoUDHhCYQXV3UM06sGGrk=
 github.com/hashicorp/golang-lru v0.5.1/go.mod h1:/m3WP610KZHVQ1SGc6re/UDhFvYD7pJ4Ao+sR/qLZy8=
 github.com/hpcloud/tail v1.0.0/go.mod h1:ab1qPbhIpdTxEkNHXyeSf5vhxWSCs/tWer42PpOxQnU=
+github.com/huandu/xstrings v1.3.1/go.mod h1:y5/lhBue+AyNmUVz9RLU9xbLR0o4KIIExikq4ovT0aE=
+github.com/huandu/xstrings v1.3.2 h1:L18LIDzqlW6xN2rEkpdV8+oL/IXWJ1APd+vsdYy4Wdw=
+github.com/huandu/xstrings v1.3.2/go.mod h1:y5/lhBue+AyNmUVz9RLU9xbLR0o4KIIExikq4ovT0aE=
+github.com/imdario/mergo v0.3.8/go.mod h1:2EnlNZ0deacrJVfApfmtdGgDfMuh/nq6Ok1EcJh5FfA=
+github.com/imdario/mergo v0.3.11 h1:3tnifQM4i+fbajXKBHXWEH+KvNHqojZ778UH75j3bGA=
+github.com/imdario/mergo v0.3.11/go.mod h1:jmQim1M+e3UYxmgPu/WyfjB3N3VflVyUjjjwH0dnCYA=
 github.com/json-iterator/go v1.1.6/go.mod h1:+SdeFBvtyEkXs7REEP0seUULqWtbJapLOCVDaaPEHmU=
 github.com/json-iterator/go v1.1.8 h1:QiWkFLKq0T7mpzwOTu6BzNDbfTE8OLrYhVKYMLF46Ok=
 github.com/json-iterator/go v1.1.8/go.mod h1:KdQUCv79m/52Kvf8AW2vK1V8akMuk1QjK/uOdHXbAo4=
@@ -70,6 +80,10 @@ github.com/mattn/go-colorable v0.1.4/go.mod h1:U0ppj6V5qS13XJ6of8GYAs25YV2eR4EVc
 github.com/mattn/go-isatty v0.0.8/go.mod h1:Iq45c/XA43vh69/j3iqttzPXn0bhXyGjM0Hdxcsrc5s=
 github.com/mattn/go-isatty v0.0.11 h1:FxPOTFNqGkuDUGi3H/qkUbQO4ZiBa2brKq5r0l8TGeM=
 github.com/mattn/go-isatty v0.0.11/go.mod h1:PhnuNfih5lzO57/f3n+odYbM4JtupLOxQOAqxQCu2WE=
+github.com/mitchellh/copystructure v1.0.0 h1:Laisrj+bAB6b/yJwB5Bt3ITZhGJdqmxquMKeZ+mmkFQ=
+github.com/mitchellh/copystructure v1.0.0/go.mod h1:SNtv71yrdKgLRyLFxmLdkAbkKEFWgYaq1OVrnRcwhnw=
+github.com/mitchellh/reflectwalk v1.0.0 h1:9D+8oIskB4VJBN5SFlmc27fSlIBZaov1Wpk/IfikLNY=
+github.com/mitchellh/reflectwalk v1.0.0/go.mod h1:mSTlrgnPZtwu0c4WaC2kGObEpuNDbx0jmZXqmk4esnw=
 github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
 github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg=
 github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
@@ -91,20 +105,25 @@ github.com/posener/complete v1.2.3 h1:NP0eAhjcjImqslEwo/1hq7gpajME0fTLTezBKDqfXq
 github.com/posener/complete v1.2.3/go.mod h1:WZIdtGGp+qx0sLrYKtIRAruyNpv6hFCicSgv7Sy7s/s=
 github.com/sergi/go-diff v1.1.0 h1:we8PVUC3FE2uYfodKH/nBHMSetSfHDR6scGdBi+erh0=
 github.com/sergi/go-diff v1.1.0/go.mod h1:STckp+ISIX8hZLjrqAeVduY0gWCT9IjLuqbuNXdaHfM=
+github.com/spf13/cast v1.3.1 h1:nFm6S0SMdyzrzcmThSipiEubIDy8WEXKNZ0UOgiRpng=
+github.com/spf13/cast v1.3.1/go.mod h1:Qx5cxh0v+4UWYiBimWS+eyWzqEqokIECu5etghLkUJE=
 github.com/spf13/pflag v0.0.0-20170130214245-9ff6c6923cff/go.mod h1:DYY7MBk1bdzusC3SYhjObp+wFpr4gzcvqqNjLnInEg4=
 github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA=
 github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
 github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
 github.com/stretchr/objx v0.2.0 h1:Hbg2NidpLE8veEBkEZTL3CvlkUIVzuU9jDplZO54c48=
 github.com/stretchr/objx v0.2.0/go.mod h1:qt09Ya8vawLte6SNmTgCsAVtYtaKzEcn8ATUoHMkEqE=
+github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs=
 github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
 github.com/stretchr/testify v1.4.0 h1:2E4SXV/wtOkTonXsotYi4li6zVWxYlZuYNCXe9XRJyk=
 github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4=
+github.com/stretchr/testify v1.5.1 h1:nOGnQDM7FYENwehXlg/kFVnos3rEvtKTjRvOWSzb6H4=
+github.com/stretchr/testify v1.5.1/go.mod h1:5W2xD1RspED5o8YsWQXVCued0rvSQ+mT+I5cxcmMvtA=
 github.com/thoas/go-funk v0.4.0 h1:KBaa5NL7NMtsFlQaD8nQMbDt1wuM+OOaNQyYNYQFhVo=
 github.com/thoas/go-funk v0.4.0/go.mod h1:mlR+dHGb+4YgXkf13rkQTuzrneeHANxOm6+ZnEV9HsA=
 golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
-golang.org/x/crypto v0.0.0-20190820162420-60c769a6c586 h1:7KByu05hhLed2MO29w7p1XfZvZ13m8mub3shuVftRs0=
-golang.org/x/crypto v0.0.0-20190820162420-60c769a6c586/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
+golang.org/x/crypto v0.0.0-20200414173820-0848c9571904 h1:bXoxMPcSLOq08zI3/c5dEBT6lE4eh+jOh886GHrn6V8=
+golang.org/x/crypto v0.0.0-20200414173820-0848c9571904/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
 golang.org/x/net v0.0.0-20170114055629-f2499483f923/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
 golang.org/x/net v0.0.0-20180906233101-161cd47e91fd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
 golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
@@ -141,6 +160,8 @@ gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
 gopkg.in/yaml.v2 v2.2.4/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
 gopkg.in/yaml.v2 v2.2.8 h1:obN1ZagJSUGI0Ek/LBmuj4SNLPfIny3KsKFopxRdj10=
 gopkg.in/yaml.v2 v2.2.8/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
+gopkg.in/yaml.v2 v2.3.0 h1:clyUAQHOM3G0M3f5vQj7LuJrETvjVot3Z5el9nffUtU=
+gopkg.in/yaml.v2 v2.3.0/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
 gopkg.in/yaml.v3 v3.0.0-20191010095647-fc94e3f71652 h1:VKvJ/mQ4BgCjZUDggYFxTe0qv9jPMHsZPD4Xt91Y5H4=
 gopkg.in/yaml.v3 v3.0.0-20191010095647-fc94e3f71652/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
 k8s.io/apimachinery v0.18.3 h1:pOGcbVAhxADgUYnjS08EFXs9QMl8qaH5U4fr5LGUrSk=
diff --git a/pkg/helm/helm.go b/pkg/helm/helm.go
index b89f25fb..300b2a44 100644
--- a/pkg/helm/helm.go
+++ b/pkg/helm/helm.go
@@ -7,7 +7,6 @@ import (
 	"io/ioutil"
 	"os"
 	"os/exec"
-	"strings"
 
 	"github.com/grafana/tanka/pkg/kubernetes/manifest"
 )
@@ -24,40 +23,6 @@ type Helm interface {
 	Template(name, chart string, opts TemplateOpts) (manifest.List, error)
 }
 
-type TemplateOpts struct {
-	// Values to pass to Helm using --values
-	Values map[string]interface{}
-
-	// Kubernetes api versions used for Capabilities.APIVersions
-	APIVersions []string
-	// IncludeCRDs specifies whether CustomResourceDefinitions are included in
-	// the template output
-	IncludeCRDs bool
-	// Namespace scope for this request
-	Namespace string
-}
-
-// Flags returns all options apart from Values as their respective `helm
-// template` flag equivalent
-func (t TemplateOpts) Flags() []string {
-	var flags []string
-
-	if t.APIVersions != nil {
-		value := strings.Join(t.APIVersions, ",")
-		flags = append(flags, "--api-versions="+value)
-	}
-
-	if t.IncludeCRDs {
-		flags = append(flags, "--include-crds")
-	}
-
-	if t.Namespace != "" {
-		flags = append(flags, "--namespace="+t.Namespace)
-	}
-
-	return flags
-}
-
 // PullOpts are additional, non-required options for Helm.Pull
 type PullOpts struct {
 	Opts
diff --git a/pkg/helm/jsonnet.go b/pkg/helm/jsonnet.go
new file mode 100644
index 00000000..faa51d3b
--- /dev/null
+++ b/pkg/helm/jsonnet.go
@@ -0,0 +1,143 @@
+package helm
+
+import (
+	"bytes"
+	"encoding/json"
+	"fmt"
+	"os"
+	"path/filepath"
+	"text/template"
+
+	"github.com/Masterminds/sprig/v3"
+	"github.com/google/go-jsonnet"
+	"github.com/google/go-jsonnet/ast"
+	"github.com/grafana/tanka/pkg/kubernetes/manifest"
+)
+
+// DefaultNameFormat to use when no nameFormat is supplied
+const DefaultNameFormat = `{{ print .kind "_" .metadata.name | snakecase }}`
+
+// JsonnetOpts are additional properties the consumer of the native func might
+// pass.
+type JsonnetOpts struct {
+	TemplateOpts
+
+	// CalledFrom is the file that calls helmTemplate. This is used to find the
+	// vendored chart relative to this file
+	CalledFrom string `json:"calledFrom"`
+	// NameTemplate is used to create the keys in the resulting map
+	NameFormat string `json:"nameFormat"`
+}
+
+// NativeFunc returns a jsonnet native function that provides the same
+// functionality as `Helm.Template` of this package. Charts are required to be
+// present on the local filesystem, at a relative location to the file that
+// calls `helm.template()` / `std.native('helmTemplate')`. This guarantees
+// hermeticity
+func NativeFunc(h Helm) *jsonnet.NativeFunction {
+	return &jsonnet.NativeFunction{
+		Name: "helmTemplate",
+		// Similar to `helm template [NAME] [CHART] [flags]` except 'conf' is a
+		// bit more elaborate and chart is a local path
+		Params: ast.Identifiers{"name", "chart", "opts"},
+		Func: func(data []interface{}) (interface{}, error) {
+			name, ok := data[0].(string)
+			if !ok {
+				return nil, fmt.Errorf("First argument 'name' must be of 'string' type, got '%T' instead", data[0])
+			}
+
+			chartpath, ok := data[1].(string)
+			if !ok {
+				return nil, fmt.Errorf("Second argument 'chart' must be of 'string' type, got '%T' instead", data[1])
+			}
+
+			// TODO: validate data[2] actually follows the struct scheme
+			opts, err := parseOpts(data[2])
+			if err != nil {
+				return "", err
+			}
+
+			// resolve the Chart relative to the caller
+			callerDir := filepath.Dir(opts.CalledFrom)
+			chart := filepath.Join(callerDir, chartpath)
+			if _, err := os.Stat(chart); err != nil {
+				// TODO: add website link for explanation
+				return nil, fmt.Errorf("helmTemplate: Failed to find a Chart at '%s': %s", chart, err)
+			}
+
+			// render resources
+			list, err := h.Template(name, chart, opts.TemplateOpts)
+			if err != nil {
+				return nil, err
+			}
+
+			// convert list to map
+			out, err := listAsMap(list, opts.NameFormat)
+			if err != nil {
+				return nil, err
+			}
+
+			return out, nil
+		},
+	}
+}
+
+func parseOpts(data interface{}) (*JsonnetOpts, error) {
+	c, err := json.Marshal(data)
+	if err != nil {
+		return nil, err
+	}
+	var opts JsonnetOpts
+	if err := json.Unmarshal(c, &opts); err != nil {
+		return nil, err
+	}
+
+	// Charts are only allowed at relative paths. Use conf.CalledFrom to find the callers directory
+	if opts.CalledFrom == "" {
+		// TODO: rephrase and move lengthy explanation to website
+		return nil, fmt.Errorf("helmTemplate: 'opts.calledFrom' is unset or empty.\nTanka must know where helmTemplate was called from to resolve the Helm Chart relative to that.\n")
+	}
+
+	return &opts, nil
+}
+
+func listAsMap(list manifest.List, nameFormat string) (map[string]interface{}, error) {
+	if nameFormat == "" {
+		nameFormat = DefaultNameFormat
+	}
+
+	tmpl, err := template.New("").
+		Funcs(sprig.TxtFuncMap()).
+		Parse(nameFormat)
+	if err != nil {
+		return nil, fmt.Errorf("Parsing name format: %w", err)
+	}
+
+	out := make(map[string]interface{})
+	for _, m := range list {
+		var buf bytes.Buffer
+		if err := tmpl.Execute(&buf, m); err != nil {
+			return nil, err
+		}
+		name := buf.String()
+
+		if _, ok := out[name]; ok {
+			return nil, ErrorDuplicateName{name: name, format: nameFormat}
+		}
+		out[name] = map[string]interface{}(m)
+	}
+
+	return out, nil
+}
+
+// ErrorDuplicateName means two resources share the same name using the given
+// nameFormat.
+type ErrorDuplicateName struct {
+	name   string
+	format string
+}
+
+func (e ErrorDuplicateName) Error() string {
+	// TODO: explain on website
+	return fmt.Sprintf("Two resources share the same name '%s'. Please adapt the name template '%s'", e.name, e.format)
+}
diff --git a/pkg/helm/template.go b/pkg/helm/template.go
index 3663f42a..336a672d 100644
--- a/pkg/helm/template.go
+++ b/pkg/helm/template.go
@@ -2,30 +2,15 @@ package helm
 
 import (
 	"bytes"
-	"encoding/json"
-	"fmt"
 	"io"
 	"os"
-	"path/filepath"
 	"strings"
 
-	jsonnet "github.com/google/go-jsonnet"
-	"github.com/google/go-jsonnet/ast"
 	"github.com/grafana/tanka/pkg/kubernetes/manifest"
 	"github.com/pkg/errors"
 	yaml "gopkg.in/yaml.v3"
 )
 
-// JsonnetOpts are additional properties the consumer of the native func might
-// pass.
-type JsonnetOpts struct {
-	TemplateOpts
-
-	// CalledFrom is the file that calls helmTemplate. This is used to find the
-	// vendored chart relative to this file
-	CalledFrom string `json:"calledFrom"`
-}
-
 // Template expands a Helm Chart into a regular manifest.List using the `helm
 // template` command
 func (h ExecHelm) Template(name, chart string, opts TemplateOpts) (manifest.List, error) {
@@ -72,77 +57,37 @@ func (h ExecHelm) Template(name, chart string, opts TemplateOpts) (manifest.List
 	return list, nil
 }
 
-// NativeFunc returns a jsonnet native function that provides the same
-// functionality as `Helm.Template` of this package. Charts are required to be
-// present on the local filesystem, at a relative location to the file that
-// calls `helm.template()` / `std.native('helmTemplate')`. This guarantees
-// hermeticity
-func NativeFunc(h Helm) *jsonnet.NativeFunction {
-	return &jsonnet.NativeFunction{
-		Name: "helmTemplate",
-		// Similar to `helm template [NAME] [CHART] [flags]` except 'conf' is a
-		// bit more elaborate and chart is a local path
-		Params: ast.Identifiers{"name", "chart", "opts"},
-		Func: func(data []interface{}) (interface{}, error) {
-			name, ok := data[0].(string)
-			if !ok {
-				return nil, fmt.Errorf("First argument 'name' must be of 'string' type, got '%T' instead", data[0])
-			}
-
-			chartpath, ok := data[1].(string)
-			if !ok {
-				return nil, fmt.Errorf("Second argument 'chart' must be of 'string' type, got '%T' instead", data[1])
-			}
-
-			// TODO: validate data[2] actually follows the struct scheme
-			c, err := json.Marshal(data[2])
-			if err != nil {
-				return "", err
-			}
-			var opts JsonnetOpts
-			if err := json.Unmarshal(c, &opts); err != nil {
-				return "", err
-			}
-
-			// Charts are only allowed at relative paths. Use conf.CalledFrom to find the callers directory
-			if opts.CalledFrom == "" {
-				// TODO: rephrase and move lengthy explanation to website
-				return nil, fmt.Errorf("helmTemplate: 'conf.calledFrom' is unset or empty.\nTanka must know where helmTemplate was called from to resolve the Helm Chart relative to that.\n")
-			}
-			callerDir := filepath.Dir(opts.CalledFrom)
-
-			// resolve the Chart relative to the caller
-			chart := filepath.Join(callerDir, chartpath)
-			if _, err := os.Stat(chart); err != nil {
-				// TODO: add website link for explanation
-				return nil, fmt.Errorf("helmTemplate: Failed to find a Chart at '%s': %s", chart, err)
-			}
+// TemplateOpts are additional, non-required options for Helm.Template
+type TemplateOpts struct {
+	// Values to pass to Helm using --values
+	Values map[string]interface{}
+
+	// Kubernetes api versions used for Capabilities.APIVersions
+	APIVersions []string
+	// IncludeCRDs specifies whether CustomResourceDefinitions are included in
+	// the template output
+	IncludeCRDs bool
+	// Namespace scope for this request
+	Namespace string
+}
 
-			// render resources
-			list, err := h.Template(name, chart, opts.TemplateOpts)
-			if err != nil {
-				return nil, err
-			}
+// Flags returns all options apart from Values as their respective `helm
+// template` flag equivalent
+func (t TemplateOpts) Flags() []string {
+	var flags []string
 
-			// convert list to map
-			out := make(map[string]interface{})
-			for _, m := range list {
-				// TODO: make this configurable
-				name := fmt.Sprintf("%s_%s", m.Metadata().Name(), m.Kind())
-				name = normalizeName(name)
+	if t.APIVersions != nil {
+		value := strings.Join(t.APIVersions, ",")
+		flags = append(flags, "--api-versions="+value)
+	}
 
-				// TODO: fail in case of ovewriting
-				out[name] = map[string]interface{}(m)
-			}
+	if t.IncludeCRDs {
+		flags = append(flags, "--include-crds")
+	}
 
-			return out, nil
-		},
+	if t.Namespace != "" {
+		flags = append(flags, "--namespace="+t.Namespace)
 	}
-}
 
-func normalizeName(s string) string {
-	s = strings.ReplaceAll(s, "-", "_")
-	s = strings.ReplaceAll(s, ":", "_")
-	s = strings.ToLower(s)
-	return s
+	return flags
 }


Test patch:
diff --git a/pkg/helm/jsonnet_test.go b/pkg/helm/jsonnet_test.go
new file mode 100644
index 00000000..4f74c25d
--- /dev/null
+++ b/pkg/helm/jsonnet_test.go
@@ -0,0 +1,97 @@
+package helm
+
+import (
+	"testing"
+
+	"github.com/google/go-cmp/cmp"
+	"github.com/grafana/tanka/pkg/kubernetes/manifest"
+)
+
+func TestListAsMap(t *testing.T) {
+	cases := []struct {
+		name       string
+		list       manifest.List
+		result     map[string]interface{}
+		nameFormat string
+		err        error
+	}{
+		{
+			name:       "simple",
+			nameFormat: "", // test it properly defaults to DefaultNameFormat
+			list: manifest.List{
+				configMap("foo"),
+				deployment("bar"),
+			},
+			result: map[string]interface{}{
+				"config_map_foo": configMap("foo"),
+				"deployment_bar": deployment("bar"),
+			},
+		},
+		{
+			name: "duplicate-default",
+			list: manifest.List{
+				secret("foo", map[string]interface{}{"id": 1}),
+				secret("foo", map[string]interface{}{"id": 2}),
+			},
+			err:    ErrorDuplicateName{name: "secret_foo", format: DefaultNameFormat},
+			result: nil, // expect no result
+		},
+		{
+			name:       "duplicate-custom",
+			nameFormat: `{{ print .metadata.name "_" .data.id }}`,
+			list: manifest.List{
+				secret("foo", map[string]interface{}{"id": 1}),
+				secret("foo", map[string]interface{}{"id": 2}),
+			},
+			result: map[string]interface{}{
+				"foo_1": secret("foo", map[string]interface{}{"id": 1}),
+				"foo_2": secret("foo", map[string]interface{}{"id": 2}),
+			},
+			err: nil, // expect no error
+		},
+	}
+
+	for _, c := range cases {
+		t.Run(c.name, func(t *testing.T) {
+			result, err := listAsMap(c.list, c.nameFormat)
+			if err != c.err {
+				t.Fatalf("err mismatch: want '%s' but got '%s'", c.err, err)
+			}
+
+			if diff := cmp.Diff(c.result, result); diff != "" {
+				t.Fatal(diff)
+			}
+		})
+	}
+}
+
+func configMap(name string) map[string]interface{} {
+	return map[string]interface{}{
+		"apiVersion": "v1",
+		"kind":       "ConfigMap",
+		"metadata":   map[string]interface{}{"name": name},
+		"data":       map[string]interface{}{},
+	}
+}
+
+func deployment(name string) map[string]interface{} {
+	return map[string]interface{}{
+		"apiVersion": "apps/v1",
+		"kind":       "Deployment",
+		"metadata":   map[string]interface{}{"name": name},
+		"spec":       map[string]interface{}{},
+	}
+}
+
+func secret(name string, data map[string]interface{}) map[string]interface{} {
+	if data == nil {
+		data = map[string]interface{}{}
+	}
+
+	return map[string]interface{}{
+		"apiVersion": "v1",
+		"kind":       "Secret",
+		"metadata":   map[string]interface{}{"name": name},
+		"data":       data,
+	}
+}