Compare commits

...
10 Commits
Author SHA1 Message Date
KUOKA Yusuke 79f0e70ce8 fix: avoid "cross-device link" errors while decrypting secrets (#260)
ref https://github.com/roboll/helmfile/issues/251#issuecomment-417166296
2018-08-30 17:20:38 +09:00
KUOKA Yusuke 0c8a89cbaf fix: coordinate secret decryption (#259)
Fixes #258
2018-08-30 17:03:45 +09:00
KUOKA Yusuke b3ebd4cdd0 fix: Keep backward-compatibility broken after introduction of values file template (#257)
Fixes #249
2018-08-30 16:57:37 +09:00
Shane Starcher 9b71c64ef2 fix: use temporary file for secret file to prevent deletion collision (#250)
Fixes #167
2018-08-30 09:28:26 +09:00
KUOKA Yusuke 822cc13e72 feat: Template functions to replace sets (#242)
Resolves #227
2018-08-28 22:00:51 +09:00
KUOKA Yusuke 0ac8401d1e feat: feat: Configurable default values for --verify, --force, --recreate-pods, and --timeout (#241)
Resolves #230
2018-08-28 15:11:05 +09:00
KUOKA Yusuke 815ee1f85b feat: --force, --recreate-pods, --timeout as first-class citizens in helmfile.yaml (#239)
Resolves #229
2018-08-27 23:06:16 +09:00
KUOKA Yusuke 2c36640ad2 To reduce integration test flakiness 2018-08-27 23:04:16 +09:00
KUOKA Yusuke faaf172615 fix: panic on flag parsing error (#238)
This is a follow-up for #235.

We setup the default logger only after the flags are parsed successfully.
That's because we init the logger according to the --log-level flag value.
2018-08-27 22:58:25 +09:00
KUOKA Yusuke fcbd6aff29 fix: Relative path in KUBECONFIG envvar (#237)
Resolves #173
2018-08-27 22:53:17 +09:00
15 changed files with 936 additions and 187 deletions
+106 -3
View File
@@ -43,10 +43,15 @@ context: kube-context # kube-context (--kube-context)
helmDefaults:
tillerNamespace: tiller-namespace #dedicated default key for tiller-namespace
kubeContext: kube-context #dedicated default key for kube-context
# additional and global args passed to helm
args:
- "--recreate-pods"
- "--timeout=600"
- "--force"
- "--set k=v"
# defaults for verify, wait, force, timeout and recreatePods under releases[]
verify: true
wait: true
timeout: 600
recreatePods: true
force: true
releases:
# Published chart example
@@ -83,6 +88,12 @@ releases:
- vault_secret.yaml
# wait for k8s resources via --wait. Defaults to `false`
wait: true
# time in seconds to wait for any individual Kubernetes operation (like Jobs for hooks, and waits on pod/pvc/svc/deployment readiness) (default 300)
timeout: 60
# performs pods restart for the resource if applicable
recreatePods: true
# forces resource update through delete/recreate if needed
force: true
# Local chart example
- name: grafana # name of this release
@@ -259,6 +270,98 @@ The `selector` parameter can be specified multiple times. Each parameter is reso
`--selector tier=frontend --selector tier=backend` will select all the charts
## Templates
You can use go's text/template expressions in `helmfile.yaml` and `values.yaml`(helm values files).
In addition to built-in ones, the following custom template functions are available:
- `readFile` reads the specified local file and generate a golang string
- `fromYaml` reads a golang string and generates a map
- `setValuleAtPath PATH NEW_VALUE` traverses a golang map, replaces the value at the PATH with NEW_VALUE
- `toYaml` marshals a map into a string
### Values Files Templates
You can reference a template of values file in your `helmfile.yaml` like below:
```yaml
releases
- name: myapp
chart: mychart
values:
- values.yaml.gotmpl
```
Every values file whose file extension is `.gotmpl` is considered as a tempalte file.
Suppose `values.yaml.gotmpl` was something like:
```yaml
{{ readFile "values.yaml" | fromYaml | setValueAtPath "foo.bar" "FOO_BAR" | toYaml }}
```
And `values.yaml` was:
```yaml
foo:
bar: ""
```
The resulting, temporary values.yaml that is generated from `values.yaml.tpl` would become:
```yaml
foo:
# Notice `setValueAtPath "foo.bar" "FOO_BAR"` in the template above
bar: FOO_BAR
```
## Refactoring `helmfile.yaml` with values files templates
One of expected use-cases of values files templates is to keep `helmfile.yaml` small and concise.
See the example `helmfile.yaml` below:
```yaml
releases:
- name: {{ requiredEnv "NAME" }}-vault
namespace: {{ requiredEnv "NAME" }}
chart: roboll/vault-secret-manager
values:
- db:
username: {{ requiredEnv "DB_USERNAME" }}
password: {{ requiredEnv "DB_PASSWORD" }}
set:
- name: proxy.domain
value: {{ requiredEnv "PLATFORM_ID" }}.my-domain.com
- name: proxy.scheme
value: {{ env "SCHEME" | default "https" }}
```
The `values` and `set` sections of the config file can be separated out into a template:
`helmfile.yaml`:
```yaml
releases:
- name: {{ requiredEnv "NAME" }}-vault
namespace: {{ requiredEnv "NAME" }}
chart: roboll/vault-secret-manager
values:
- values.yaml.tmpl
```
`values.yaml.tmpl`:
```yaml
db:
username: {{ requiredEnv "DB_USERNAME" }}
password: {{ requiredEnv "DB_PASSWORD" }}
proxy:
domain: {{ requiredEnv "PLATFORM_ID" }}.my-domain.com
scheme: {{ env "SCHEME" | default "https" }}
```
## Using env files
helmfile itself doesn't have an ability to load env files. But you can write some bash script to achieve the goal:
+35 -6
View File
@@ -2,10 +2,13 @@ package helmexec
import (
"io"
"io/ioutil"
"os"
"strings"
"go.uber.org/zap"
"go.uber.org/zap/zapcore"
"sync"
)
const (
@@ -13,11 +16,12 @@ const (
)
type execer struct {
helmBinary string
runner Runner
logger *zap.SugaredLogger
kubeContext string
extra []string
helmBinary string
runner Runner
logger *zap.SugaredLogger
kubeContext string
extra []string
decryptionMutex sync.Mutex
}
func NewLogger(writer io.Writer, logLevel string) *zap.SugaredLogger {
@@ -99,10 +103,35 @@ func (helm *execer) ReleaseStatus(name string) error {
}
func (helm *execer) DecryptSecret(name string) (string, error) {
// Prevents https://github.com/roboll/helmfile/issues/258
helm.decryptionMutex.Lock()
defer helm.decryptionMutex.Unlock()
helm.logger.Infof("Decrypting secret %v", name)
out, err := helm.exec(append([]string{"secrets", "dec", name})...)
helm.write(out)
return name + ".dec", err
tmpFile, err := ioutil.TempFile("", "secret")
if err != nil {
return "", err
}
defer tmpFile.Close()
// os.Rename seems to results in "cross-device link` errors in some cases
// Instead of moving, copy it to the destination temp file as a work-around
// See https://github.com/roboll/helmfile/issues/251#issuecomment-417166296
decFile, err := os.Open(name + ".dec")
if err != nil {
return "", err
}
defer decFile.Close()
_, err = io.Copy(tmpFile, decFile)
if err != nil {
return "", err
}
return tmpFile.Name(), err
}
func (helm *execer) DiffRelease(name, chart string, flags ...string) error {
-9
View File
@@ -1,8 +1,6 @@
package helmexec
import (
"io/ioutil"
"os"
"os/exec"
)
@@ -21,13 +19,6 @@ type ShellRunner struct{}
// Execute a shell command
func (shell ShellRunner) Execute(cmd string, args []string) ([]byte, error) {
dir, err := ioutil.TempDir("", tmpPrefix+cmd+tmpSuffix)
if err != nil {
return nil, err
}
defer os.RemoveAll(dir)
preparedCmd := exec.Command(cmd, args...)
preparedCmd.Dir = dir
return preparedCmd.CombinedOutput()
}
+3 -2
View File
@@ -14,6 +14,7 @@ import (
"github.com/roboll/helmfile/args"
"github.com/roboll/helmfile/helmexec"
"github.com/roboll/helmfile/state"
"github.com/roboll/helmfile/tmpl"
"github.com/urfave/cli"
"go.uber.org/zap"
"go.uber.org/zap/zapcore"
@@ -386,7 +387,7 @@ func main() {
err := app.Run(os.Args)
if err != nil {
logger.Errorf("%v", err)
fmt.Fprintf(os.Stderr, "%v\n", err)
os.Exit(3)
}
}
@@ -399,7 +400,7 @@ func eachDesiredStateDo(c *cli.Context, converge func(*state.HelmState, helmexec
}
allSelectorNotMatched := true
for _, f := range desiredStateFiles {
yamlBuf, err := state.RenderTemplateFileToBuffer(f)
yamlBuf, err := tmpl.RenderTemplateFileToBuffer(f)
if err != nil {
return err
}
+56 -85
View File
@@ -3,6 +3,7 @@ package state
import (
"errors"
"fmt"
"github.com/roboll/helmfile/helmexec"
"io/ioutil"
"os"
"path"
@@ -10,17 +11,12 @@ import (
"strconv"
"strings"
"sync"
"text/template"
"github.com/Masterminds/sprig"
"github.com/roboll/helmfile/helmexec"
"bytes"
"regexp"
"github.com/roboll/helmfile/valuesfile"
"go.uber.org/zap"
yaml "gopkg.in/yaml.v2"
"gopkg.in/yaml.v2"
)
// HelmState structure for the helmfile
@@ -42,6 +38,15 @@ type HelmSpec struct {
KubeContext string `yaml:"kubeContext"`
TillerNamespace string `yaml:"tillerNamespace"`
Args []string `yaml:"args"`
Verify bool `yaml:"verify"`
// Wait, if set to true, will wait until all Pods, PVCs, Services, and minimum number of Pods of a Deployment are in a ready state before marking the release as successful
Wait bool `yaml:"wait"`
// Timeout is the time in seconds to wait for any individual Kubernetes operation (like Jobs for hooks, and waits on pod/pvc/svc/deployment readiness) (default 300)
Timeout int `yaml:"timeout"`
// RecreatePods, when set to true, instruct helmfile to perform pods restart for the resource if applicable
RecreatePods bool `yaml:"recreatePods"`
// Force, when set to true, forces resource update through delete/recreate if needed
Force bool `yaml:"force"`
}
// RepositorySpec that defines values for a helm repo
@@ -59,8 +64,15 @@ type ReleaseSpec struct {
// Chart is the name of the chart being installed to create this release
Chart string `yaml:"chart"`
Version string `yaml:"version"`
Verify bool `yaml:"verify"`
Wait bool `yaml:"wait"`
Verify *bool `yaml:"verify"`
// Wait, if set to true, will wait until all Pods, PVCs, Services, and minimum number of Pods of a Deployment are in a ready state before marking the release as successful
Wait *bool `yaml:"wait"`
// Timeout is the time in seconds to wait for any individual Kubernetes operation (like Jobs for hooks, and waits on pod/pvc/svc/deployment readiness) (default 300)
Timeout *int `yaml:"timeout"`
// RecreatePods, when set to true, instruct helmfile to perform pods restart for the resource if applicable
RecreatePods *bool `yaml:"recreatePods"`
// Force, when set to true, forces resource update through delete/recreate if needed
Force *bool `yaml:"force"`
// Name is the name of this release
Name string `yaml:"name"`
@@ -85,16 +97,6 @@ type SetValue struct {
Values []string `yaml:"values"`
}
// CreateFromTemplateFile loads the helmfile from disk and processes the template
func CreateFromTemplateFile(file string, logger *zap.SugaredLogger) (*HelmState, error) {
yamlBuf, err := RenderTemplateFileToBuffer(file)
if err != nil {
return nil, err
}
return CreateFromYaml(yamlBuf.Bytes(), file, logger)
}
func CreateFromYaml(content []byte, file string, logger *zap.SugaredLogger) (*HelmState, error) {
var state HelmState
@@ -117,57 +119,6 @@ func CreateFromYaml(content []byte, file string, logger *zap.SugaredLogger) (*He
return &state, nil
}
func stringTemplate() *template.Template {
funcMap := sprig.TxtFuncMap()
alterFuncMap(&funcMap)
return template.New("stringTemplate").Funcs(funcMap)
}
func alterFuncMap(funcMap *template.FuncMap) {
(*funcMap)["requiredEnv"] = getRequiredEnv
}
func getRequiredEnv(name string) (string, error) {
if val, exists := os.LookupEnv(name); exists && len(val) > 0 {
return val, nil
}
return "", fmt.Errorf("required env var `%s` is not set", name)
}
func RenderTemplateFileToBuffer(file string) (*bytes.Buffer, error) {
content, err := ioutil.ReadFile(file)
if err != nil {
return nil, err
}
return renderTemplateToBuffer(string(content))
}
func renderTemplateToBuffer(s string) (*bytes.Buffer, error) {
var t, parseErr = stringTemplate().Parse(s)
if parseErr != nil {
return nil, parseErr
}
var tplString bytes.Buffer
var execErr = t.Execute(&tplString, nil)
if execErr != nil {
return nil, execErr
}
return &tplString, nil
}
func renderTemplateString(s string) (string, error) {
tplString, err := renderTemplateToBuffer(s)
if err != nil {
return "", err
}
return tplString.String(), nil
}
func (state *HelmState) applyDefaultsTo(spec *ReleaseSpec) {
if state.Namespace != "" {
spec.Namespace = state.Namespace
@@ -217,7 +168,7 @@ func (state *HelmState) SyncReleases(helm helmexec.Interface, additionalValues [
go func() {
for release := range jobQueue {
state.applyDefaultsTo(release)
flags, flagsErr := state.flagsForUpgrade(helm, state.BaseChartPath, release)
flags, flagsErr := state.flagsForUpgrade(helm, release)
if flagsErr != nil {
errQueue <- &ReleaseError{release, flagsErr}
doneQueue <- true
@@ -652,18 +603,37 @@ func chartNameWithoutRepository(chart string) string {
return chartSplit[len(chartSplit)-1]
}
func (state *HelmState) flagsForUpgrade(helm helmexec.Interface, basePath string, release *ReleaseSpec) ([]string, error) {
func (state *HelmState) flagsForUpgrade(helm helmexec.Interface, release *ReleaseSpec) ([]string, error) {
flags := []string{}
if release.Verify {
flags = append(flags, "--verify")
}
if release.Wait {
flags = append(flags, "--wait")
}
if release.Version != "" {
flags = append(flags, "--version", release.Version)
}
common, err := state.namespaceAndValuesFlags(helm, basePath, release)
if release.Verify != nil && *release.Verify || state.HelmDefaults.Verify {
flags = append(flags, "--verify")
}
if release.Wait != nil && *release.Wait || state.HelmDefaults.Wait {
flags = append(flags, "--wait")
}
timeout := state.HelmDefaults.Timeout
if release.Timeout != nil {
timeout = *release.Timeout
}
if timeout != 0 {
flags = append(flags, "--timeout", fmt.Sprintf("%d", timeout))
}
if release.Force != nil && *release.Force || state.HelmDefaults.Force {
flags = append(flags, "--force")
}
if release.RecreatePods != nil && *release.RecreatePods || state.HelmDefaults.RecreatePods {
flags = append(flags, "--recreate-pods")
}
common, err := state.namespaceAndValuesFlags(helm, state.BaseChartPath, release)
if err != nil {
return nil, err
}
@@ -704,17 +674,18 @@ func (state *HelmState) namespaceAndValuesFlags(helm helmexec.Interface, basePat
return nil, err
}
yamlBuf, err := RenderTemplateFileToBuffer(path)
if err != nil {
return nil, fmt.Errorf("failed to render [%s], because of %v", path, err)
}
valfile, err := ioutil.TempFile("", "values")
if err != nil {
return nil, err
}
defer valfile.Close()
yamlBytes := yamlBuf.Bytes()
r := valuesfile.NewRenderer(ioutil.ReadFile)
yamlBytes, err := r.RenderToBytes(path)
if err != nil {
return nil, err
}
if _, err := valfile.Write(yamlBytes); err != nil {
return nil, fmt.Errorf("failed to write %s: %v", valfile.Name(), err)
}
+198 -80
View File
@@ -188,6 +188,7 @@ func TestLabelParsing(t *testing.T) {
}
}
}
func TestHelmState_applyDefaultsTo(t *testing.T) {
type fields struct {
BaseChartPath string
@@ -200,10 +201,11 @@ func TestHelmState_applyDefaultsTo(t *testing.T) {
type args struct {
spec ReleaseSpec
}
verify := false
specWithNamespace := ReleaseSpec{
Chart: "test/chart",
Version: "0.1",
Verify: false,
Verify: &verify,
Name: "test-charts",
Namespace: "test-namespace",
Values: nil,
@@ -278,117 +280,233 @@ func TestHelmState_applyDefaultsTo(t *testing.T) {
}
}
func Test_renderTemplateString(t *testing.T) {
type args struct {
s string
envs map[string]string
func TestHelmState_flagsForUpgrade(t *testing.T) {
enable := true
disable := false
some := func(v int) *int {
return &v
}
tests := []struct {
name string
args args
want string
wantErr bool
name string
defaults HelmSpec
release *ReleaseSpec
want []string
}{
{
name: "simple replacement",
args: args{
s: "{{ env \"HF_TEST_VAR\" }}",
envs: map[string]string{
"HF_TEST_VAR": "content",
},
name: "no-options",
defaults: HelmSpec{
Verify: false,
},
release: &ReleaseSpec{
Chart: "test/chart",
Version: "0.1",
Verify: &disable,
Name: "test-charts",
Namespace: "test-namespace",
},
want: []string{
"--version", "0.1",
"--namespace", "test-namespace",
},
want: "content",
wantErr: false,
},
{
name: "two replacements",
args: args{
s: "{{ env \"HF_TEST_ALPHA\" }}{{ env \"HF_TEST_BETA\" }}",
envs: map[string]string{
"HF_TEST_ALPHA": "first",
"HF_TEST_BETA": "second",
},
name: "verify",
defaults: HelmSpec{
Verify: false,
},
release: &ReleaseSpec{
Chart: "test/chart",
Version: "0.1",
Verify: &enable,
Name: "test-charts",
Namespace: "test-namespace",
},
want: []string{
"--version", "0.1",
"--verify",
"--namespace", "test-namespace",
},
want: "firstsecond",
wantErr: false,
},
{
name: "replacement and comment",
args: args{
s: "{{ env \"HF_TEST_ALPHA\" }}{{/* comment */}}",
envs: map[string]string{
"HF_TEST_ALPHA": "first",
},
name: "verify-from-default",
defaults: HelmSpec{
Verify: true,
},
release: &ReleaseSpec{
Chart: "test/chart",
Version: "0.1",
Verify: &disable,
Name: "test-charts",
Namespace: "test-namespace",
},
want: []string{
"--version", "0.1",
"--verify",
"--namespace", "test-namespace",
},
want: "first",
wantErr: false,
},
{
name: "global template function",
args: args{
s: "{{ env \"HF_TEST_ALPHA\" | len }}",
envs: map[string]string{
"HF_TEST_ALPHA": "abcdefg",
},
name: "force",
defaults: HelmSpec{
Force: false,
},
release: &ReleaseSpec{
Chart: "test/chart",
Version: "0.1",
Force: &enable,
Name: "test-charts",
Namespace: "test-namespace",
},
want: []string{
"--version", "0.1",
"--force",
"--namespace", "test-namespace",
},
want: "7",
wantErr: false,
},
{
name: "env var not set",
args: args{
s: "{{ env \"HF_TEST_NONE\" }}",
envs: map[string]string{
"HF_TEST_THIS": "first",
},
name: "force-from-default",
defaults: HelmSpec{
Force: true,
},
release: &ReleaseSpec{
Chart: "test/chart",
Version: "0.1",
Force: &disable,
Name: "test-charts",
Namespace: "test-namespace",
},
want: []string{
"--version", "0.1",
"--force",
"--namespace", "test-namespace",
},
want: "",
},
{
name: "undefined function",
args: args{
s: "{{ env foo }}",
envs: map[string]string{
"foo": "bar",
},
name: "recreate-pods",
defaults: HelmSpec{
RecreatePods: false,
},
release: &ReleaseSpec{
Chart: "test/chart",
Version: "0.1",
RecreatePods: &enable,
Name: "test-charts",
Namespace: "test-namespace",
},
want: []string{
"--version", "0.1",
"--recreate-pods",
"--namespace", "test-namespace",
},
wantErr: true,
},
{
name: "required env var",
args: args{
s: "{{ requiredEnv \"HF_TEST\" }}",
envs: map[string]string{
"HF_TEST": "value",
},
name: "recreate-pods-from-default",
defaults: HelmSpec{
RecreatePods: true,
},
release: &ReleaseSpec{
Chart: "test/chart",
Version: "0.1",
RecreatePods: &disable,
Name: "test-charts",
Namespace: "test-namespace",
},
want: []string{
"--version", "0.1",
"--recreate-pods",
"--namespace", "test-namespace",
},
want: "value",
wantErr: false,
},
{
name: "required env var not set",
args: args{
s: "{{ requiredEnv \"HF_TEST_NONE\" }}",
envs: map[string]string{},
name: "wait",
defaults: HelmSpec{
Wait: false,
},
release: &ReleaseSpec{
Chart: "test/chart",
Version: "0.1",
Wait: &enable,
Name: "test-charts",
Namespace: "test-namespace",
},
want: []string{
"--version", "0.1",
"--wait",
"--namespace", "test-namespace",
},
},
{
name: "wait-from-default",
defaults: HelmSpec{
Wait: true,
},
release: &ReleaseSpec{
Chart: "test/chart",
Version: "0.1",
Wait: &disable,
Name: "test-charts",
Namespace: "test-namespace",
},
want: []string{
"--version", "0.1",
"--wait",
"--namespace", "test-namespace",
},
},
{
name: "timeout",
defaults: HelmSpec{
Timeout: 0,
},
release: &ReleaseSpec{
Chart: "test/chart",
Version: "0.1",
Timeout: some(123),
Name: "test-charts",
Namespace: "test-namespace",
},
want: []string{
"--version", "0.1",
"--timeout", "123",
"--namespace", "test-namespace",
},
},
{
name: "timeout-from-default",
defaults: HelmSpec{
Timeout: 123,
},
release: &ReleaseSpec{
Chart: "test/chart",
Version: "0.1",
Timeout: nil,
Name: "test-charts",
Namespace: "test-namespace",
},
want: []string{
"--version", "0.1",
"--timeout", "123",
"--namespace", "test-namespace",
},
wantErr: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
for k, v := range tt.args.envs {
err := os.Setenv(k, v)
if err != nil {
t.Error("renderTemplateString() could not set env var for testing")
}
state := &HelmState{
BaseChartPath: "./",
Context: "default",
Releases: []ReleaseSpec{*tt.release},
HelmDefaults: tt.defaults,
}
got, err := renderTemplateString(tt.args.s)
if (err != nil) != tt.wantErr {
t.Errorf("renderTemplateString() for %s error = %v, wantErr %v", tt.name, err, tt.wantErr)
return
helm := helmexec.New(logger, "default")
args, err := state.flagsForUpgrade(helm, tt.release)
if err != nil {
t.Errorf("unexpected error flagsForUpgade: %v", err)
}
if got != tt.want {
t.Errorf("renderTemplateString() for %s = %v, want %v", tt.name, got, tt.want)
if !reflect.DeepEqual(args, tt.want) {
t.Errorf("flagsForUpgrade returned = %v, want %v", args, tt.want)
}
})
}
+2 -2
View File
@@ -58,7 +58,7 @@ test_start "happypath - simple rollout of httpbin chart"
info "Syncing ${dir}/happypath.yaml"
${helmfile} -f ${dir}/happypath.yaml sync
wait_deploy_ready httpbin-httpbin
retry 3 "curl --fail $(minikube service --url --namespace=${test_ns} httpbin-httpbin)/status/200"
retry 5 "curl --fail $(minikube service --url --namespace=${test_ns} httpbin-httpbin)/status/200"
[ ${retry_result} -eq 0 ] || fail "httpbin failed to return 200 OK"
info "Deleting release"
${helmfile} -f ${dir}/happypath.yaml delete
@@ -68,4 +68,4 @@ test_pass "happypath"
# ALL DONE -----------------------------------------------------------------------------------------------------------
all_tests_passed
all_tests_passed
+5
View File
@@ -0,0 +1,5 @@
package tmpl
type Context struct {
readFile func(string) ([]byte, error)
}
+43
View File
@@ -0,0 +1,43 @@
package tmpl
import (
"bytes"
"io/ioutil"
)
var DefaultFileRenderer *templateFileRenderer
func init() {
DefaultFileRenderer = NewFileRenderer(ioutil.ReadFile)
}
type templateFileRenderer struct {
ReadFile func(string) ([]byte, error)
Context *Context
}
type FileRenderer interface {
RenderTemplateFileToBuffer(file string) (*bytes.Buffer, error)
}
func NewFileRenderer(readFile func(filename string) ([]byte, error)) *templateFileRenderer {
return &templateFileRenderer{
ReadFile: readFile,
Context: &Context{
readFile: readFile,
},
}
}
func (r *templateFileRenderer) RenderTemplateFileToBuffer(file string) (*bytes.Buffer, error) {
content, err := r.ReadFile(file)
if err != nil {
return nil, err
}
return r.Context.RenderTemplateToBuffer(string(content))
}
func RenderTemplateFileToBuffer(file string) (*bytes.Buffer, error) {
return DefaultFileRenderer.RenderTemplateFileToBuffer(file)
}
+99
View File
@@ -0,0 +1,99 @@
package tmpl
import (
"fmt"
"gopkg.in/yaml.v2"
"os"
"strings"
"text/template"
)
type Values = map[string]interface{}
func (c *Context) createFuncMap() template.FuncMap {
return template.FuncMap{
"readFile": c.ReadFile,
"toYaml": ToYaml,
"fromYaml": FromYaml,
"setValueAtPath": SetValueAtPath,
"requiredEnv": RequiredEnv,
}
}
func (c *Context) ReadFile(filename string) (string, error) {
bytes, err := c.readFile(filename)
if err != nil {
return "", err
}
return string(bytes), nil
}
func ToYaml(v interface{}) (string, error) {
data, err := yaml.Marshal(v)
if err != nil {
return "", err
}
return string(data), nil
}
func FromYaml(str string) (Values, error) {
m := Values{}
if err := yaml.Unmarshal([]byte(str), &m); err != nil {
return nil, err
}
return m, nil
}
func SetValueAtPath(path string, value interface{}, values Values) (Values, error) {
var current interface{}
current = values
components := strings.Split(path, ".")
pathToMap := components[:len(components)-1]
key := components[len(components)-1]
for _, k := range pathToMap {
var elem interface{}
switch typedCurrent := current.(type) {
case map[string]interface{}:
v, exists := typedCurrent[k]
if !exists {
return nil, fmt.Errorf("failed to set value at path \"%s\": value for key \"%s\" does not exist", path, k)
}
elem = v
case map[interface{}]interface{}:
v, exists := typedCurrent[k]
if !exists {
return nil, fmt.Errorf("failed to set value at path \"%s\": value for key \"%s\" does not exist", path, k)
}
elem = v
default:
return nil, fmt.Errorf("failed to set value at path \"%s\": value for key \"%s\" was not a map", path, k)
}
switch typedElem := elem.(type) {
case map[string]interface{}, map[interface{}]interface{}:
current = typedElem
default:
return nil, fmt.Errorf("failed to set value at path \"%s\": value for key \"%s\" was not a map", path, k)
}
}
switch typedCurrent := current.(type) {
case map[string]interface{}:
typedCurrent[key] = value
case map[interface{}]interface{}:
typedCurrent[key] = value
default:
return nil, fmt.Errorf("failed to set value at path \"%s\": value for key \"%s\" was not a map", path, key)
}
return values, nil
}
func RequiredEnv(name string) (string, error) {
if val, exists := os.LookupEnv(name); exists && len(val) > 0 {
return val, nil
}
return "", fmt.Errorf("required env var `%s` is not set", name)
}
+99
View File
@@ -0,0 +1,99 @@
package tmpl
import (
"fmt"
"reflect"
"testing"
)
func TestReadFile(t *testing.T) {
expected := `foo:
bar: BAR
`
expectedFilename := "values.yaml"
ctx := &Context{readFile: func(filename string) ([]byte, error) {
if filename != expectedFilename {
return nil, fmt.Errorf("unexpected filename: expected=%v, actual=%s", expectedFilename, filename)
}
return []byte(expected), nil
}}
actual, err := ctx.ReadFile(expectedFilename)
if err != nil {
t.Errorf("unexpected error: %v", err)
}
if !reflect.DeepEqual(actual, expected) {
t.Errorf("unexpected result: expected=%v, actual=%v", expected, actual)
}
}
func TestToYaml(t *testing.T) {
expected := `foo:
bar: BAR
`
vals := Values(map[string]interface{}{
"foo": map[interface{}]interface{}{
"bar": "BAR",
},
})
actual, err := ToYaml(vals)
if err != nil {
t.Errorf("unexpected error: %v", err)
}
if !reflect.DeepEqual(actual, expected) {
t.Errorf("unexpected result: expected=%v, actual=%v", expected, actual)
}
}
func TestFromYaml(t *testing.T) {
raw := `foo:
bar: BAR
`
expected := Values(map[string]interface{}{
"foo": map[interface{}]interface{}{
"bar": "BAR",
},
})
actual, err := FromYaml(raw)
if err != nil {
t.Errorf("unexpected error: %v", err)
}
if !reflect.DeepEqual(actual, expected) {
t.Errorf("unexpected result: expected=%v, actual=%v", expected, actual)
}
}
func TestSetValueAtPath_OneComponent(t *testing.T) {
input := map[string]interface{}{
"foo": "",
}
expected := map[string]interface{}{
"foo": "FOO",
}
actual, err := SetValueAtPath("foo", "FOO", input)
if err != nil {
t.Errorf("unexpected error: %v", err)
}
if !reflect.DeepEqual(actual, expected) {
t.Errorf("unexpected result: expected=%v, actual=%v", expected, actual)
}
}
func TestSetValueAtPath_TwoComponents(t *testing.T) {
input := map[string]interface{}{
"foo": map[interface{}]interface{}{
"bar": "",
},
}
expected := map[string]interface{}{
"foo": map[interface{}]interface{}{
"bar": "FOO_BAR",
},
}
actual, err := SetValueAtPath("foo.bar", "FOO_BAR", input)
if err != nil {
t.Errorf("unexpected error: %v", err)
}
if !reflect.DeepEqual(actual, expected) {
t.Errorf("unexpected result: expected=%v, actual=%v", expected, actual)
}
}
+31
View File
@@ -0,0 +1,31 @@
package tmpl
import (
"bytes"
"github.com/Masterminds/sprig"
"text/template"
)
func (c *Context) stringTemplate() *template.Template {
funcMap := sprig.TxtFuncMap()
for name, f := range c.createFuncMap() {
funcMap[name] = f
}
return template.New("stringTemplate").Funcs(funcMap)
}
func (c *Context) RenderTemplateToBuffer(s string) (*bytes.Buffer, error) {
var t, parseErr = c.stringTemplate().Parse(s)
if parseErr != nil {
return nil, parseErr
}
var tplString bytes.Buffer
var execErr = t.Execute(&tplString, nil)
if execErr != nil {
return nil, execErr
}
return &tplString, nil
}
+159
View File
@@ -0,0 +1,159 @@
package tmpl
import (
"fmt"
"os"
"reflect"
"testing"
)
func TestRenderTemplate_Values(t *testing.T) {
valuesYamlContent := `foo:
bar: BAR
`
expected := `foo:
bar: FOO_BAR
`
expectedFilename := "values.yaml"
ctx := &Context{readFile: func(filename string) ([]byte, error) {
if filename != expectedFilename {
return nil, fmt.Errorf("unexpected filename: expected=%v, actual=%s", expectedFilename, filename)
}
return []byte(valuesYamlContent), nil
}}
buf, err := ctx.RenderTemplateToBuffer(`{{ readFile "values.yaml" | fromYaml | setValueAtPath "foo.bar" "FOO_BAR" | toYaml }}`)
if err != nil {
t.Errorf("unexpected error: %v", err)
}
actual := buf.String()
if !reflect.DeepEqual(actual, expected) {
t.Errorf("unexpected result: expected=%v, actual=%v", expected, actual)
}
}
func renderTemplateToString(s string) (string, error) {
ctx := &Context{readFile: func(filename string) ([]byte, error) {
return nil, fmt.Errorf("unexpected call to readFile: filename=%s", filename)
}}
tplString, err := ctx.RenderTemplateToBuffer(s)
if err != nil {
return "", err
}
return tplString.String(), nil
}
func Test_renderTemplateToString(t *testing.T) {
type args struct {
s string
envs map[string]string
}
tests := []struct {
name string
args args
want string
wantErr bool
}{
{
name: "simple replacement",
args: args{
s: "{{ env \"HF_TEST_VAR\" }}",
envs: map[string]string{
"HF_TEST_VAR": "content",
},
},
want: "content",
wantErr: false,
},
{
name: "two replacements",
args: args{
s: "{{ env \"HF_TEST_ALPHA\" }}{{ env \"HF_TEST_BETA\" }}",
envs: map[string]string{
"HF_TEST_ALPHA": "first",
"HF_TEST_BETA": "second",
},
},
want: "firstsecond",
wantErr: false,
},
{
name: "replacement and comment",
args: args{
s: "{{ env \"HF_TEST_ALPHA\" }}{{/* comment */}}",
envs: map[string]string{
"HF_TEST_ALPHA": "first",
},
},
want: "first",
wantErr: false,
},
{
name: "global template function",
args: args{
s: "{{ env \"HF_TEST_ALPHA\" | len }}",
envs: map[string]string{
"HF_TEST_ALPHA": "abcdefg",
},
},
want: "7",
wantErr: false,
},
{
name: "env var not set",
args: args{
s: "{{ env \"HF_TEST_NONE\" }}",
envs: map[string]string{
"HF_TEST_THIS": "first",
},
},
want: "",
},
{
name: "undefined function",
args: args{
s: "{{ env foo }}",
envs: map[string]string{
"foo": "bar",
},
},
wantErr: true,
},
{
name: "required env var",
args: args{
s: "{{ requiredEnv \"HF_TEST\" }}",
envs: map[string]string{
"HF_TEST": "value",
},
},
want: "value",
wantErr: false,
},
{
name: "required env var not set",
args: args{
s: "{{ requiredEnv \"HF_TEST_NONE\" }}",
envs: map[string]string{},
},
wantErr: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
for k, v := range tt.args.envs {
err := os.Setenv(k, v)
if err != nil {
t.Error("renderTemplateToString() could not set env var for testing")
}
}
got, err := renderTemplateToString(tt.args.s)
if (err != nil) != tt.wantErr {
t.Errorf("renderTemplateToString() for %s error = %v, wantErr %v", tt.name, err, tt.wantErr)
return
}
if got != tt.want {
t.Errorf("renderTemplateToString() for %s = %v, want %v", tt.name, got, tt.want)
}
})
}
}
+39
View File
@@ -0,0 +1,39 @@
package valuesfile
import (
"fmt"
"github.com/roboll/helmfile/tmpl"
"strings"
)
type renderer struct {
readFile func(string) ([]byte, error)
tmplFileRenderer tmpl.FileRenderer
}
func NewRenderer(readFile func(filename string) ([]byte, error)) *renderer {
return &renderer{
readFile: readFile,
tmplFileRenderer: tmpl.NewFileRenderer(readFile),
}
}
func (r *renderer) RenderToBytes(path string) ([]byte, error) {
var yamlBytes []byte
splits := strings.Split(path, ".")
if len(splits) > 0 && splits[len(splits)-1] == "gotmpl" {
yamlBuf, err := r.tmplFileRenderer.RenderTemplateFileToBuffer(path)
if err != nil {
return nil, fmt.Errorf("failed to render [%s], because of %v", path, err)
}
yamlBytes = yamlBuf.Bytes()
} else {
var err error
yamlBytes, err = r.readFile(path)
if err != nil {
return nil, fmt.Errorf("failed to load [%s]: %v", path, err)
}
}
return yamlBytes, nil
}
+61
View File
@@ -0,0 +1,61 @@
package valuesfile
import (
"fmt"
"reflect"
"testing"
)
func TestRenderToBytes_Gotmpl(t *testing.T) {
valuesYamlTmplContent := `foo:
bar: '{{ readFile "data.txt" }}'
`
dataFileContent := "FOO_BAR"
expected := `foo:
bar: 'FOO_BAR'
`
dataFile := "data.txt"
valuesTmplFile := "values.yaml.gotmpl"
r := NewRenderer(func(filename string) ([]byte, error) {
switch filename {
case valuesTmplFile:
return []byte(valuesYamlTmplContent), nil
case dataFile:
return []byte(dataFileContent), nil
}
return nil, fmt.Errorf("unexpected filename: expected=%v or %v, actual=%s", dataFile, valuesTmplFile, filename)
})
buf, err := r.RenderToBytes(valuesTmplFile)
if err != nil {
t.Errorf("unexpected error: %v", err)
}
actual := string(buf)
if !reflect.DeepEqual(actual, expected) {
t.Errorf("unexpected result: expected=%v, actual=%v", expected, actual)
}
}
func TestRenderToBytes_Yaml(t *testing.T) {
valuesYamlContent := `foo:
bar: '{{ readFile "data.txt" }}'
`
expected := `foo:
bar: '{{ readFile "data.txt" }}'
`
valuesFile := "values.yaml"
r := NewRenderer(func(filename string) ([]byte, error) {
switch filename {
case valuesFile:
return []byte(valuesYamlContent), nil
}
return nil, fmt.Errorf("unexpected filename: expected=%v, actual=%s", valuesFile, filename)
})
buf, err := r.RenderToBytes(valuesFile)
if err != nil {
t.Errorf("unexpected error: %v", err)
}
actual := string(buf)
if !reflect.DeepEqual(actual, expected) {
t.Errorf("unexpected result: expected=%v, actual=%v", expected, actual)
}
}