Skip to content
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

feat: provide std.native('helmTemplate') #336

Merged
merged 13 commits into from
Aug 18, 2020
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -1,2 +1,3 @@
dist
tk

161 changes: 161 additions & 0 deletions pkg/helmraiser/helm.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,161 @@
package helmraiser

import (
"bytes"
"encoding/json"
"fmt"
"io"
"io/ioutil"
"os"
"os/exec"
"strings"

jsonnet "github.com/google/go-jsonnet"
"github.com/google/go-jsonnet/ast"
"github.com/pkg/errors"
yaml "gopkg.in/yaml.v3"
)

type HelmConf struct {
Values map[string]interface{}
Flags []string
}

func confToArgs(conf HelmConf) ([]string, []string, error) {
var args []string
var tempFiles []string
Copy link
Member

Choose a reason for hiding this comment

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

You only ever add one file this this, right? No need for a slice then

Copy link
Member Author

Choose a reason for hiding this comment

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

I'm optimizing for future cases where we might want to pass multiple files (it is possible to have multiple --values with helm template) and can easily extend this without having to change this elsewhere. If you don't see value in this, I'll remove it.


// create file and append to args
if len(conf.Values) != 0 {
valuesYaml, err := yaml.Marshal(conf.Values)
if err != nil {
return nil, nil, err
}
tmpFile, err := ioutil.TempFile(os.TempDir(), "tanka-")
if err != nil {
return nil, nil, errors.Wrap(err, "cannot create temporary values.yaml")
}
tempFiles = append(tempFiles, tmpFile.Name())
if _, err = tmpFile.Write(valuesYaml); err != nil {
return nil, tempFiles, errors.Wrap(err, "failed to write to temporary values.yaml")
}
if err := tmpFile.Close(); err != nil {
return nil, tempFiles, err
}
args = append(args, fmt.Sprintf("--values=%s", tmpFile.Name()))
}

// append custom flags to args
args = append(args, conf.Flags...)

if len(args) == 0 {
args = nil
}

return args, tempFiles, nil
}

func parseYamlToMap(yamlFile []byte) (map[string]interface{}, error) {
files := make(map[string]interface{})
d := yaml.NewDecoder(bytes.NewReader(yamlFile))
for {
var doc, jsonDoc interface{}
if err := d.Decode(&doc); err != nil {
if err == io.EOF {
break
}
return nil, errors.Wrap(err, "parsing manifests")
}

jsonRaw, err := json.Marshal(doc)
if err != nil {
return nil, errors.Wrap(err, "marshaling mainfests")
}

if err := json.Unmarshal(jsonRaw, &jsonDoc); err != nil {
return nil, errors.Wrap(err, "unmarshaling manifests")
}
sh0rez marked this conversation as resolved.
Show resolved Hide resolved

// Unmarshal name and kind
kindName := struct {
Kind string `json:"kind"`
Metadata struct {
Name string `json:"name"`
} `json:"metadata"`
}{}
if err := json.Unmarshal(jsonRaw, &kindName); err != nil {
return nil, errors.Wrap(err, "subtracting kind/name through unmarshaling")
}

// snake_case string
normalizeName := func(s string) string {
s = strings.ReplaceAll(s, "-", "_")
s = strings.ReplaceAll(s, ":", "_")
s = strings.ToLower(s)
return s
}

// create a map of resources for ease of use in jsonnet
name := normalizeName(fmt.Sprintf("%s_%s", kindName.Metadata.Name, kindName.Kind))
if jsonDoc != nil {
files[name] = jsonDoc
}
}
return files, nil
}

// helmTemplate wraps and runs `helm template`
// returns the generated manifests in a map
func HelmTemplate() *jsonnet.NativeFunction {
return &jsonnet.NativeFunction{
Name: "helmTemplate",
// Lines up with `helm template [NAME] [CHART] [flags]` except 'conf' is a bit more elaborate
Params: ast.Identifiers{"name", "chart", "conf"},
Func: func(data []interface{}) (interface{}, error) {
name, chart := data[0].(string), data[1].(string)

c, err := json.Marshal(data[2])
if err != nil {
return "", err
}
var conf HelmConf
if err := json.Unmarshal(c, &conf); err != nil {
return "", err
}

// the basic arguments to make this work
args := []string{
"template",
name,
chart,
}

confArgs, tempFiles, err := confToArgs(conf)
if err != nil {
return "", nil
}
for _, file := range tempFiles {
defer os.Remove(file)
}
if confArgs != nil {
args = append(args, confArgs...)
}

helmBinary := "helm"
if hc := os.Getenv("TANKA_HELM_BIN"); hc != "" {
Duologic marked this conversation as resolved.
Show resolved Hide resolved
helmBinary = hc
}

// convert the values map into a yaml file
cmd := exec.Command(helmBinary, args...)
buf := bytes.Buffer{}
cmd.Stdout = &buf
cmd.Stderr = os.Stderr
if err := cmd.Run(); err != nil {
return nil, errors.Wrap(err, fmt.Sprintf("while running helm %s", strings.Join(args, " ")))
Duologic marked this conversation as resolved.
Show resolved Hide resolved
}

return parseYamlToMap(buf.Bytes())
},
}
}
191 changes: 191 additions & 0 deletions pkg/helmraiser/helm_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,191 @@
package helmraiser

import (
"fmt"
"os"
"testing"

"github.com/stretchr/testify/assert"
)

func TestConfToArgs_noconf(t *testing.T) {
conf := HelmConf{}
args, tempFiles, err := confToArgs(conf)
for _, file := range tempFiles {
defer os.Remove(file)
}

assert.Equal(t, []string(nil), args)
assert.Nil(t, err)
}

func TestConfToArgs_emptyconf(t *testing.T) {
conf := HelmConf{
Values: map[string]interface{}{},
Flags: []string{},
}

args, tempFiles, err := confToArgs(conf)
for _, file := range tempFiles {
defer os.Remove(file)
}

assert.Equal(t, []string(nil), args)
assert.Nil(t, err)
}

func TestConfToArgs_flags(t *testing.T) {
conf := HelmConf{
Flags: []string{
"--version=v0.1",
"--random=arg",
},
}

args, tempFiles, err := confToArgs(conf)
for _, file := range tempFiles {
defer os.Remove(file)
}

assert.Equal(t, []string{
"--version=v0.1",
"--random=arg",
}, args)
assert.Nil(t, err)
}

func TestConfToArgs_values(t *testing.T) {
conf := HelmConf{
Values: map[string]interface{}{
"hasValues": "yes",
},
}

args, tempFiles, err := confToArgs(conf)
for _, file := range tempFiles {
defer os.Remove(file)
}

assert.FileExists(t, tempFiles[0])
assert.Equal(t, []string{fmt.Sprintf("--values=%s", tempFiles[0])}, args)
assert.Nil(t, err)
}

func TestConfToArgs_flagsvalues(t *testing.T) {
conf := HelmConf{
Values: map[string]interface{}{
"hasValues": "yes",
},
Flags: []string{
"--version=v0.1",
"--random=arg",
},
}

args, tempFiles, err := confToArgs(conf)
for _, file := range tempFiles {
defer os.Remove(file)
}

assert.Equal(t, []string{
fmt.Sprintf("--values=%s", tempFiles[0]),
"--version=v0.1",
"--random=arg",
}, args)
assert.Nil(t, err)
}

func TestParseYamlToMap_basic(t *testing.T) {
yamlFile := []byte(`---
kind: testKind
metadata:
name: testName`)
actual, err := parseYamlToMap(yamlFile)

expected := map[string]interface{}{
"testname_testkind": map[string]interface{}{
"kind": "testKind",
"metadata": map[string]interface{}{
"name": "testName",
},
},
}
assert.Equal(t, expected, actual)
assert.Nil(t, err)
}

func TestParseYamlToMap_dash(t *testing.T) {
yamlFile := []byte(`---
kind: testKind
metadata:
name: test-Name`)
actual, err := parseYamlToMap(yamlFile)

expected := map[string]interface{}{
"test_name_testkind": map[string]interface{}{
"kind": "testKind",
"metadata": map[string]interface{}{
"name": "test-Name",
},
},
}
assert.Equal(t, expected, actual)
assert.Nil(t, err)
}

func TestParseYamlToMap_colon(t *testing.T) {
yamlFile := []byte(`---
kind: testKind
metadata:
name: test:Name`)
actual, err := parseYamlToMap(yamlFile)

expected := map[string]interface{}{
"test_name_testkind": map[string]interface{}{
"kind": "testKind",
"metadata": map[string]interface{}{
"name": "test:Name",
},
},
}
assert.Equal(t, expected, actual)
assert.Nil(t, err)
}

func TestParseYamlToMap_empty(t *testing.T) {
yamlFile := []byte(`---`)
actual, err := parseYamlToMap(yamlFile)

expected := map[string]interface{}{}
assert.Equal(t, expected, actual)
assert.Nil(t, err)
}

func TestParseYamlToMap_multiple_files(t *testing.T) {
yamlFile := []byte(`---
kind: testKind
metadata:
name: testName
---
kind: testKind
metadata:
name: testName2`)
actual, err := parseYamlToMap(yamlFile)

expected := map[string]interface{}{
"testname_testkind": map[string]interface{}{
"kind": "testKind",
"metadata": map[string]interface{}{
"name": "testName",
},
},
"testname2_testkind": map[string]interface{}{
"kind": "testKind",
"metadata": map[string]interface{}{
"name": "testName2",
},
},
}
assert.Equal(t, expected, actual)
assert.Nil(t, err)
}
3 changes: 3 additions & 0 deletions pkg/jsonnet/native/funcs.go
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import (

jsonnet "github.com/google/go-jsonnet"
"github.com/google/go-jsonnet/ast"
"github.com/grafana/tanka/pkg/helmraiser"
"github.com/pkg/errors"
yaml "gopkg.in/yaml.v3"
)
Expand All @@ -29,6 +30,8 @@ func Funcs() []*jsonnet.NativeFunction {
escapeStringRegex(),
regexMatch(),
regexSubst(),

helmraiser.HelmTemplate(),
}
}

Expand Down