From 57106e4cf590b2a4dfe06f15c5543f71128caacb Mon Sep 17 00:00:00 2001 From: sh0rez Date: Mon, 14 Sep 2020 13:01:44 +0200 Subject: [PATCH] feat(helm): Make name format configurable (#381) * feat(helm): Configurable map keys Makes the keys the `helmTemplate` native func uses in the returned map configurable, by passing `nameFormat` as part of the `opts` argument. * feat(cli): use Masterminds/sprig for export Recently introduces `Masterminds/sprig` is now also available in the name format of `tk export` to be consistent. * refactor(helm): parseOpts * refactor(helm): jsonnet.go Moves Jsonnet native func related code into jsonnet.go * test(helm): TestListAsMap --- cmd/tk/export.go | 11 ++- go.mod | 9 ++- go.sum | 31 +++++++-- pkg/helm/helm.go | 35 ---------- pkg/helm/jsonnet.go | 143 +++++++++++++++++++++++++++++++++++++++ pkg/helm/jsonnet_test.go | 97 ++++++++++++++++++++++++++ pkg/helm/template.go | 109 ++++++++--------------------- 7 files changed, 303 insertions(+), 132 deletions(-) create mode 100644 pkg/helm/jsonnet.go create mode 100644 pkg/helm/jsonnet_test.go diff --git a/cmd/tk/export.go b/cmd/tk/export.go index ba6d3ff43..8ce4a6acb 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 020d88ae7..30b9626ab 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 17b5936c7..3c768386f 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 b89f25fbf..300b2a444 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 000000000..faa51d3b6 --- /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/jsonnet_test.go b/pkg/helm/jsonnet_test.go new file mode 100644 index 000000000..4f74c25db --- /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, + } +} diff --git a/pkg/helm/template.go b/pkg/helm/template.go index 3663f42a5..336a672df 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 }