From ed0854a5c091e362e5c7cbfe88355acc27edebe6 Mon Sep 17 00:00:00 2001 From: KUOKA Yusuke Date: Fri, 31 Aug 2018 22:59:27 +0900 Subject: [PATCH] feat: Environment and Environment Values (#267) Resolves #253 --- Gopkg.lock | 2 +- Gopkg.toml | 4 + README.md | 64 +++++++++ environment/environment.go | 8 ++ main.go | 24 +++- main_test.go | 2 +- state/create.go | 71 ++++++++++ state/create_test.go | 248 ++++++++++++++++++++++++++++++++++ state/environment.go | 5 + state/state.go | 73 ++++------ state/state_test.go | 164 +--------------------- tmpl/file.go | 14 +- tmpl/tmpl.go | 8 +- tmpl/tmpl_test.go | 29 ++++ valuesfile/valuesfile.go | 5 +- valuesfile/valuesfile_test.go | 5 +- 16 files changed, 509 insertions(+), 217 deletions(-) create mode 100644 environment/environment.go create mode 100644 state/create.go create mode 100644 state/create_test.go create mode 100644 state/environment.go diff --git a/Gopkg.lock b/Gopkg.lock index 85ba3f8b..3d1c2438 100644 --- a/Gopkg.lock +++ b/Gopkg.lock @@ -85,6 +85,6 @@ [solve-meta] analyzer-name = "dep" analyzer-version = 1 - inputs-digest = "57e868f6ae57c81a07ee682742f3b71bf5c7956311a3bb8ea76459677fc104c7" + inputs-digest = "b1f000751afc0a44973307c69b6a4b8e8c1b807fd9881a13f370c30fcbcab7a2" solver-name = "gps-cdcl" solver-version = 1 diff --git a/Gopkg.toml b/Gopkg.toml index 0aee55c6..86bd3b34 100644 --- a/Gopkg.toml +++ b/Gopkg.toml @@ -9,3 +9,7 @@ [prune] go-tests = true unused-packages = true + +[[constraint]] + name = "github.com/imdario/mergo" + version = "0.3.4" diff --git a/README.md b/README.md index abffe1ea..12a39dbc 100644 --- a/README.md +++ b/README.md @@ -371,6 +371,70 @@ proxy: scheme: {{ env "SCHEME" | default "https" }} ``` +## Environment + +When you want to customize the contents of `helmfile.yaml` or `values.yaml` files per environment, use this feature. + +You can define as many environments as you want under `environments` in `helmfile.yaml`. + +The environment name defaults to `default`, that is, `helmfile sync` implies the `default` environment. +The selected environment name can be referenced from `helmfile.yaml` and `values.yaml.gotmpl` by `{{ .Environment.Name }}`. + +If you want to specify a non-default environment, provide a `--environment NAME` flag to `helmfile` like `helmfile --environment production sync`. + +The below example shows how to define a production-only release: + +```yaml +environments: + default: + production: + +releases: + +{{ if (eq .Environment.Name "production" }} +- name: newrelic-agent + # snip +{{ end }} +- name: myapp + # snip +``` + +## Environment Values + +Environment Values allows you to inject a set of values specific to the selected environment, into values.yaml templates. +Use it to inject common values from the environment to multiple values files, to make your configuration DRY. + +Suppose you have three files `helmfile.yaml`, `production.yaml` and `values.yaml.gotmpl`: + +`helmfile.yaml` + +```yaml +environments: + production: + values: + - production.yaml + +releases: +- name: myapp + values: + - values.yaml.gotmpl +``` + +`production.yaml` + +```yaml +domain: prod.example.com +``` + +`values.yaml.gotmpl` + +```yaml +domain: {{ .Environment.Values.domain | default "dev.example.com" }} +``` + +`helmfile sync` installs `myapp` with the value `domain=dev.example.com`, +whereas `helmfile --environment production sync` installs the app with the value `domain=production.example.com`. + ## Separating helmfile.yaml into multiple independent files Once your `helmfile.yaml` got to contain too many releases, diff --git a/environment/environment.go b/environment/environment.go new file mode 100644 index 00000000..049c72ab --- /dev/null +++ b/environment/environment.go @@ -0,0 +1,8 @@ +package environment + +type Environment struct { + Name string + Values map[string]interface{} +} + +var EmptyEnvironment Environment diff --git a/main.go b/main.go index 8587508c..71892ec7 100644 --- a/main.go +++ b/main.go @@ -12,6 +12,7 @@ import ( "os/exec" "github.com/roboll/helmfile/args" + "github.com/roboll/helmfile/environment" "github.com/roboll/helmfile/helmexec" "github.com/roboll/helmfile/state" "github.com/roboll/helmfile/tmpl" @@ -68,6 +69,10 @@ func main() { Name: "file, f", Usage: "load config from file or directory. defaults to `helmfile.yaml` or `helmfile.d`(means `helmfile.d/*.yaml`) in this preference", }, + cli.StringFlag{ + Name: "environment, e", + Usage: "specify the environment name. defaults to `default`", + }, cli.BoolFlag{ Name: "quiet, q", Usage: "Silence output. Equivalent to log-level warn", @@ -463,10 +468,16 @@ func findAndIterateOverDesiredStatesUsingFlags(c *cli.Context, converge func(*st namespace := c.GlobalString("namespace") selectors := c.GlobalStringSlice("selector") logger := c.App.Metadata["logger"].(*zap.SugaredLogger) - return findAndIterateOverDesiredStates(fileOrDir, converge, kubeContext, namespace, selectors, logger) + + env := c.GlobalString("environment") + if env == "" { + env = state.DefaultEnv + } + + return findAndIterateOverDesiredStates(fileOrDir, converge, kubeContext, namespace, selectors, env, logger) } -func findAndIterateOverDesiredStates(fileOrDir string, converge func(*state.HelmState, helmexec.Interface) []error, kubeContext, namespace string, selectors []string, logger *zap.SugaredLogger) error { +func findAndIterateOverDesiredStates(fileOrDir string, converge func(*state.HelmState, helmexec.Interface) []error, kubeContext, namespace string, selectors []string, env string, logger *zap.SugaredLogger) error { desiredStateFiles, err := findDesiredStateFiles(fileOrDir) if err != nil { return err @@ -474,7 +485,7 @@ func findAndIterateOverDesiredStates(fileOrDir string, converge func(*state.Helm allSelectorNotMatched := true for _, f := range desiredStateFiles { logger.Debugf("Processing %s", f) - yamlBuf, err := tmpl.NewFileRenderer(ioutil.ReadFile, "").RenderTemplateFileToBuffer(f) + yamlBuf, err := tmpl.NewFileRenderer(ioutil.ReadFile, "", environment.EmptyEnvironment).RenderTemplateFileToBuffer(f) if err != nil { return err } @@ -484,6 +495,7 @@ func findAndIterateOverDesiredStates(fileOrDir string, converge func(*state.Helm kubeContext, namespace, selectors, + env, logger, ) if err != nil { @@ -498,7 +510,7 @@ func findAndIterateOverDesiredStates(fileOrDir string, converge func(*state.Helm } sort.Strings(matches) for _, m := range matches { - if err := findAndIterateOverDesiredStates(m, converge, kubeContext, namespace, selectors, logger); err != nil { + if err := findAndIterateOverDesiredStates(m, converge, kubeContext, namespace, selectors, env, logger); err != nil { return fmt.Errorf("failed processing %s: %v", globPattern, err) } } @@ -579,8 +591,8 @@ func directoryExistsAt(path string) bool { return err == nil && fileInfo.Mode().IsDir() } -func loadDesiredStateFromFile(yaml []byte, file string, kubeContext, namespace string, labels []string, logger *zap.SugaredLogger) (*state.HelmState, helmexec.Interface, bool, error) { - st, err := state.CreateFromYaml(yaml, file, logger) +func loadDesiredStateFromFile(yaml []byte, file string, kubeContext, namespace string, labels []string, env string, logger *zap.SugaredLogger) (*state.HelmState, helmexec.Interface, bool, error) { + st, err := state.CreateFromYaml(yaml, file, env, logger) if err != nil { return nil, nil, false, fmt.Errorf("failed to read %s: %v", file, err) } diff --git a/main_test.go b/main_test.go index fe654c40..d6a5ca3e 100644 --- a/main_test.go +++ b/main_test.go @@ -16,7 +16,7 @@ func TestReadFromYaml_DuplicateReleaseName(t *testing.T) { labels: stage: post `) - _, _, _, err := loadDesiredStateFromFile(yamlContent, yamlFile, "default", "default", []string{}, logger) + _, _, _, err := loadDesiredStateFromFile(yamlContent, yamlFile, "default", "default", []string{}, "default", logger) if err == nil { t.Error("error expected but not happened") } diff --git a/state/create.go b/state/create.go new file mode 100644 index 00000000..64444d89 --- /dev/null +++ b/state/create.go @@ -0,0 +1,71 @@ +package state + +import ( + "fmt" + "github.com/imdario/mergo" + "github.com/roboll/helmfile/environment" + "github.com/roboll/helmfile/valuesfile" + "go.uber.org/zap" + "gopkg.in/yaml.v2" + "io/ioutil" + "path/filepath" +) + +func CreateFromYaml(content []byte, file string, env string, logger *zap.SugaredLogger) (*HelmState, error) { + return createFromYamlWithFileReader(content, file, env, logger, ioutil.ReadFile) +} + +func createFromYamlWithFileReader(content []byte, file string, env string, logger *zap.SugaredLogger, readFile func(string) ([]byte, error)) (*HelmState, error) { + var state HelmState + + state.basePath, _ = filepath.Abs(filepath.Dir(file)) + if err := yaml.UnmarshalStrict(content, &state); err != nil { + return nil, err + } + state.FilePath = file + + if len(state.DeprecatedReleases) > 0 { + if len(state.Releases) > 0 { + return nil, fmt.Errorf("failed to parse %s: you can't specify both `charts` and `releases` sections", file) + } + state.Releases = state.DeprecatedReleases + state.DeprecatedReleases = []ReleaseSpec{} + } + + state.logger = logger + + e, err := state.loadEnv(env, readFile) + if err != nil { + return nil, err + } + state.env = *e + + state.readFile = readFile + + return &state, nil +} + +func (state *HelmState) loadEnv(name string, readFile func(string) ([]byte, error)) (*environment.Environment, error) { + envVals := map[string]interface{}{} + envSpec, ok := state.Environments[name] + if ok { + r := valuesfile.NewRenderer(readFile, state.basePath, environment.EmptyEnvironment) + for _, envvalFile := range envSpec.Values { + bytes, err := r.RenderToBytes(filepath.Join(state.basePath, envvalFile)) + if err != nil { + return nil, fmt.Errorf("failed to load environment values file \"%s\": %v", envvalFile, err) + } + m := map[string]interface{}{} + if err := yaml.Unmarshal(bytes, &m); err != nil { + return nil, fmt.Errorf("failed to load environment values file \"%s\": %v", envvalFile, err) + } + if err := mergo.Merge(&envVals, &m, mergo.WithOverride); err != nil { + return nil, fmt.Errorf("failed to load \"%s\": %v", envvalFile, err) + } + } + } else if name != DefaultEnv { + return nil, fmt.Errorf("environment \"%s\" is not defined in \"%s\"", name, state.FilePath) + } + + return &environment.Environment{Name: name, Values: envVals}, nil +} diff --git a/state/create_test.go b/state/create_test.go new file mode 100644 index 00000000..27f38915 --- /dev/null +++ b/state/create_test.go @@ -0,0 +1,248 @@ +package state + +import ( + "fmt" + "reflect" + "testing" +) + +func TestReadFromYaml(t *testing.T) { + yamlFile := "example/path/to/yaml/file" + yamlContent := []byte(`releases: +- name: myrelease + namespace: mynamespace + chart: mychart +`) + state, err := CreateFromYaml(yamlContent, yamlFile, DefaultEnv, logger) + if err != nil { + t.Errorf("unxpected error: %v", err) + } + + if state.Releases[0].Name != "myrelease" { + t.Errorf("unexpected release name: expected=myrelease actual=%s", state.Releases[0].Name) + } + if state.Releases[0].Namespace != "mynamespace" { + t.Errorf("unexpected chart namespace: expected=mynamespace actual=%s", state.Releases[0].Chart) + } + if state.Releases[0].Chart != "mychart" { + t.Errorf("unexpected chart name: expected=mychart actual=%s", state.Releases[0].Chart) + } +} + +func TestReadFromYaml_InexistentEnv(t *testing.T) { + yamlFile := "example/path/to/yaml/file" + yamlContent := []byte(`releases: +- name: myrelease + namespace: mynamespace + chart: mychart +`) + _, err := CreateFromYaml(yamlContent, yamlFile, "production", logger) + if err == nil { + t.Error("expected error") + } +} + +func TestReadFromYaml_NonDefaultEnv(t *testing.T) { + yamlFile := "/example/path/to/helmfile.yaml" + yamlContent := []byte(`environments: + production: + values: + - foo.yaml + - bar.yaml.gotmpl + +releases: +- name: myrelease + namespace: mynamespace + chart: mychart + values: + - values.yaml.gotmpl +`) + + fooYamlFile := "/example/path/to/foo.yaml" + fooYamlContent := []byte(`foo: foo +# As this file doesn't have an file extension ".gotmpl", this template expression should not be evaluated +baz: "{{ readFile \"baz.txt\" }}"`) + + barYamlFile := "/example/path/to/bar.yaml.gotmpl" + barYamlContent := []byte(`foo: FOO +bar: {{ readFile "bar.txt" }} +`) + + barTextFile := "/example/path/to/bar.txt" + barTextContent := []byte("BAR") + + expected := map[string]interface{}{ + "foo": "FOO", + "bar": "BAR", + // As the file doesn't have an file extension ".gotmpl", this template expression should not be evaluated + "baz": "{{ readFile \"baz.txt\" }}", + } + + valuesFile := "/example/path/to/values.yaml.gotmpl" + valuesContent := []byte(`env: {{ .Environment.Name }}`) + + expectedValues := `env: production` + + readFile := func(filename string) ([]byte, error) { + switch filename { + case fooYamlFile: + return fooYamlContent, nil + case barYamlFile: + return barYamlContent, nil + case barTextFile: + return barTextContent, nil + case valuesFile: + return valuesContent, nil + } + return nil, fmt.Errorf("unexpected filename: %s", filename) + } + + state, err := createFromYamlWithFileReader(yamlContent, yamlFile, "production", logger, readFile) + if err != nil { + t.Errorf("unexpected error: %v", err) + } + + actual := state.env.Values + if !reflect.DeepEqual(actual, expected) { + t.Errorf("unexpected environment values: expected=%v, actual=%v", expected, actual) + } + + actualValuesData, err := state.RenderValuesFileToBytes(valuesFile) + if err != nil { + t.Errorf("unexpected error: %v", err) + } + actualValues := string(actualValuesData) + + if !reflect.DeepEqual(expectedValues, actualValues) { + t.Errorf("unexpected values: expected=%v, actual=%v", expectedValues, actualValues) + } +} + +func TestReadFromYaml_StrictUnmarshalling(t *testing.T) { + yamlFile := "example/path/to/yaml/file" + yamlContent := []byte(`releases: +- name: myrelease + namespace: mynamespace + releases: mychart +`) + _, err := CreateFromYaml(yamlContent, yamlFile, DefaultEnv, logger) + if err == nil { + t.Error("expected an error for wrong key 'releases' which is not in struct") + } +} + +func TestReadFromYaml_DeprecatedReleaseReferences(t *testing.T) { + yamlFile := "example/path/to/yaml/file" + yamlContent := []byte(`charts: +- name: myrelease + chart: mychart +`) + state, err := CreateFromYaml(yamlContent, yamlFile, DefaultEnv, logger) + if err != nil { + t.Errorf("unxpected error: %v", err) + } + + if state.Releases[0].Name != "myrelease" { + t.Errorf("unexpected release name: expected=myrelease actual=%s", state.Releases[0].Name) + } + if state.Releases[0].Chart != "mychart" { + t.Errorf("unexpected chart name: expected=mychart actual=%s", state.Releases[0].Chart) + } +} + +func TestReadFromYaml_ConflictingReleasesConfig(t *testing.T) { + yamlFile := "example/path/to/yaml/file" + yamlContent := []byte(`charts: +- name: myrelease1 + chart: mychart1 +releases: +- name: myrelease2 + chart: mychart2 +`) + _, err := CreateFromYaml(yamlContent, yamlFile, DefaultEnv, logger) + if err == nil { + t.Error("expected error") + } +} + +func TestReadFromYaml_FilterReleasesOnLabels(t *testing.T) { + yamlFile := "example/path/to/yaml/file" + yamlContent := []byte(`releases: +- name: myrelease1 + chart: mychart1 + labels: + tier: frontend + foo: bar +- name: myrelease2 + chart: mychart2 + labels: + tier: frontend +- name: myrelease3 + chart: mychart3 + labels: + tier: backend +`) + cases := []struct { + filter LabelFilter + results []bool + }{ + {LabelFilter{positiveLabels: [][]string{[]string{"tier", "frontend"}}}, + []bool{true, true, false}}, + {LabelFilter{positiveLabels: [][]string{[]string{"tier", "frontend"}, []string{"foo", "bar"}}}, + []bool{true, false, false}}, + {LabelFilter{negativeLabels: [][]string{[]string{"tier", "frontend"}}}, + []bool{false, false, true}}, + {LabelFilter{positiveLabels: [][]string{[]string{"tier", "frontend"}}, negativeLabels: [][]string{[]string{"foo", "bar"}}}, + []bool{false, true, false}}, + } + state, err := CreateFromYaml(yamlContent, yamlFile, DefaultEnv, logger) + if err != nil { + t.Errorf("unexpected error: %v", err) + } + for idx, c := range cases { + for idx2, expected := range c.results { + if f := c.filter.Match(state.Releases[idx2]); f != expected { + t.Errorf("[case: %d][outcome: %d] Unexpected outcome wanted %t, got %t", idx, idx2, expected, f) + } + } + } +} + +func TestReadFromYaml_FilterNegatives(t *testing.T) { + yamlFile := "example/path/to/yaml/file" + yamlContent := []byte(`releases: +- name: myrelease1 + chart: mychart1 + labels: + stage: pre + foo: bar +- name: myrelease2 + chart: mychart2 + labels: + stage: post +- name: myrelease3 + chart: mychart3 +`) + cases := []struct { + filter LabelFilter + results []bool + }{ + {LabelFilter{positiveLabels: [][]string{[]string{"stage", "pre"}}}, + []bool{true, false, false}}, + {LabelFilter{positiveLabels: [][]string{[]string{"stage", "post"}}}, + []bool{false, true, false}}, + {LabelFilter{negativeLabels: [][]string{[]string{"stage", "pre"}, []string{"stage", "post"}}}, + []bool{false, false, true}}, + } + state, err := CreateFromYaml(yamlContent, yamlFile, DefaultEnv, logger) + if err != nil { + t.Errorf("unexpected error: %v", err) + } + for idx, c := range cases { + for idx2, expected := range c.results { + if f := c.filter.Match(state.Releases[idx2]); f != expected { + t.Errorf("[case: %d][outcome: %d] Unexpected outcome wanted %t, got %t", idx, idx2, expected, f) + } + } + } +} diff --git a/state/environment.go b/state/environment.go new file mode 100644 index 00000000..4349a77d --- /dev/null +++ b/state/environment.go @@ -0,0 +1,5 @@ +package state + +type EnvironmentSpec struct { + Values []string `yaml:"values"` +} diff --git a/state/state.go b/state/state.go index da3a96d3..3ea444b5 100644 --- a/state/state.go +++ b/state/state.go @@ -14,6 +14,7 @@ import ( "regexp" + "github.com/roboll/helmfile/environment" "github.com/roboll/helmfile/valuesfile" "go.uber.org/zap" "gopkg.in/yaml.v2" @@ -21,7 +22,8 @@ import ( // HelmState structure for the helmfile type HelmState struct { - BaseChartPath string + basePath string + Environments map[string]EnvironmentSpec FilePath string HelmDefaults HelmSpec `yaml:"helmDefaults"` Helmfiles []string `yaml:"helmfiles"` @@ -31,7 +33,11 @@ type HelmState struct { Repositories []RepositorySpec `yaml:"repositories"` Releases []ReleaseSpec `yaml:"releases"` + env environment.Environment + logger *zap.SugaredLogger + + readFile func(string) ([]byte, error) } // HelmSpec to defines helmDefault values @@ -98,27 +104,7 @@ type SetValue struct { Values []string `yaml:"values"` } -func CreateFromYaml(content []byte, file string, logger *zap.SugaredLogger) (*HelmState, error) { - var state HelmState - - state.BaseChartPath, _ = filepath.Abs(filepath.Dir(file)) - if err := yaml.UnmarshalStrict(content, &state); err != nil { - return nil, err - } - state.FilePath = file - - if len(state.DeprecatedReleases) > 0 { - if len(state.Releases) > 0 { - return nil, fmt.Errorf("failed to parse %s: you can't specify both `charts` and `releases` sections", file) - } - state.Releases = state.DeprecatedReleases - state.DeprecatedReleases = []ReleaseSpec{} - } - - state.logger = logger - - return &state, nil -} +const DefaultEnv = "default" func (state *HelmState) applyDefaultsTo(spec *ReleaseSpec) { if state.Namespace != "" { @@ -196,7 +182,7 @@ func (state *HelmState) SyncReleases(helm helmexec.Interface, additionalValues [ continue } - chart := normalizeChart(state.BaseChartPath, release.Chart) + chart := normalizeChart(state.basePath, release.Chart) if err := helm.SyncRelease(release.Name, chart, flags...); err != nil { errQueue <- &ReleaseError{release, err} } @@ -249,7 +235,7 @@ func (state *HelmState) DiffReleases(helm helmexec.Interface, additionalValues [ state.applyDefaultsTo(release) - flags, err := state.flagsForDiff(helm, state.BaseChartPath, release) + flags, err := state.flagsForDiff(helm, release) if err != nil { errs = append(errs, err) } @@ -271,7 +257,7 @@ func (state *HelmState) DiffReleases(helm helmexec.Interface, additionalValues [ } if len(errs) == 0 { - if err := helm.DiffRelease(release.Name, normalizeChart(state.BaseChartPath, release.Chart), flags...); err != nil { + if err := helm.DiffRelease(release.Name, normalizeChart(state.basePath, release.Chart), flags...); err != nil { errs = append(errs, err) } } @@ -333,7 +319,7 @@ func (state *HelmState) LintReleases(helm helmexec.Interface, additionalValues [ go func() { for release := range jobQueue { errs := []error{} - flags, err := state.flagsForLint(helm, state.BaseChartPath, release) + flags, err := state.flagsForLint(helm, release) if err != nil { errs = append(errs, err) } @@ -350,8 +336,8 @@ func (state *HelmState) LintReleases(helm helmexec.Interface, additionalValues [ } chartPath := "" - if pathExists(normalizeChart(state.BaseChartPath, release.Chart)) { - chartPath = normalizeChart(state.BaseChartPath, release.Chart) + if pathExists(normalizeChart(state.basePath, release.Chart)) { + chartPath = normalizeChart(state.basePath, release.Chart) } else { fetchFlags := []string{} if release.Version != "" { @@ -571,7 +557,7 @@ func (state *HelmState) UpdateDeps(helm helmexec.Interface) []error { for _, release := range state.Releases { if isLocalChart(release.Chart) { - if err := helm.UpdateDeps(normalizeChart(state.BaseChartPath, release.Chart)); err != nil { + if err := helm.UpdateDeps(normalizeChart(state.basePath, release.Chart)); err != nil { errs = append(errs, err) } } @@ -638,30 +624,35 @@ func (state *HelmState) flagsForUpgrade(helm helmexec.Interface, release *Releas flags = append(flags, "--recreate-pods") } - common, err := state.namespaceAndValuesFlags(helm, state.BaseChartPath, release) + common, err := state.namespaceAndValuesFlags(helm, release) if err != nil { return nil, err } return append(flags, common...), nil } -func (state *HelmState) flagsForDiff(helm helmexec.Interface, basePath string, release *ReleaseSpec) ([]string, error) { +func (state *HelmState) flagsForDiff(helm helmexec.Interface, release *ReleaseSpec) ([]string, error) { flags := []string{} if release.Version != "" { flags = append(flags, "--version", release.Version) } - common, err := state.namespaceAndValuesFlags(helm, basePath, release) + common, err := state.namespaceAndValuesFlags(helm, release) if err != nil { return nil, err } return append(flags, common...), nil } -func (state *HelmState) flagsForLint(helm helmexec.Interface, basePath string, release *ReleaseSpec) ([]string, error) { - return state.namespaceAndValuesFlags(helm, basePath, release) +func (state *HelmState) flagsForLint(helm helmexec.Interface, release *ReleaseSpec) ([]string, error) { + return state.namespaceAndValuesFlags(helm, release) } -func (state *HelmState) namespaceAndValuesFlags(helm helmexec.Interface, basePath string, release *ReleaseSpec) ([]string, error) { +func (state *HelmState) RenderValuesFileToBytes(path string) ([]byte, error) { + r := valuesfile.NewRenderer(state.readFile, state.basePath, state.env) + return r.RenderToBytes(path) +} + +func (state *HelmState) namespaceAndValuesFlags(helm helmexec.Interface, release *ReleaseSpec) ([]string, error) { flags := []string{} if release.Namespace != "" { flags = append(flags, "--namespace", release.Namespace) @@ -673,24 +664,20 @@ func (state *HelmState) namespaceAndValuesFlags(helm helmexec.Interface, basePat if filepath.IsAbs(typedValue) { path = typedValue } else { - path = filepath.Join(basePath, typedValue) + path = filepath.Join(state.basePath, typedValue) } if _, err := os.Stat(path); os.IsNotExist(err) { return nil, err } + yamlBytes, err := state.RenderValuesFileToBytes(path) + valfile, err := ioutil.TempFile("", "values") if err != nil { return nil, err } defer valfile.Close() - r := valuesfile.NewRenderer(ioutil.ReadFile, state.BaseChartPath) - 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) } @@ -713,7 +700,7 @@ func (state *HelmState) namespaceAndValuesFlags(helm helmexec.Interface, basePat } } for _, value := range release.Secrets { - path := filepath.Join(basePath, value) + path := filepath.Join(state.basePath, value) if _, err := os.Stat(path); os.IsNotExist(err) { return nil, err } diff --git a/state/state_test.go b/state/state_test.go index c8e3e7d1..bad3c1c9 100644 --- a/state/state_test.go +++ b/state/state_test.go @@ -12,158 +12,6 @@ import ( var logger = helmexec.NewLogger(os.Stdout, "warn") -func TestReadFromYaml(t *testing.T) { - yamlFile := "example/path/to/yaml/file" - yamlContent := []byte(`releases: -- name: myrelease - namespace: mynamespace - chart: mychart -`) - state, err := CreateFromYaml(yamlContent, yamlFile, logger) - if err != nil { - t.Errorf("unxpected error: %v", err) - } - - if state.Releases[0].Name != "myrelease" { - t.Errorf("unexpected release name: expected=myrelease actual=%s", state.Releases[0].Name) - } - if state.Releases[0].Namespace != "mynamespace" { - t.Errorf("unexpected chart namespace: expected=mynamespace actual=%s", state.Releases[0].Chart) - } - if state.Releases[0].Chart != "mychart" { - t.Errorf("unexpected chart name: expected=mychart actual=%s", state.Releases[0].Chart) - } -} - -func TestReadFromYaml_StrictUnmarshalling(t *testing.T) { - yamlFile := "example/path/to/yaml/file" - yamlContent := []byte(`releases: -- name: myrelease - namespace: mynamespace - releases: mychart -`) - _, err := CreateFromYaml(yamlContent, yamlFile, logger) - if err == nil { - t.Error("expected an error for wrong key 'releases' which is not in struct") - } -} - -func TestReadFromYaml_DeprecatedReleaseReferences(t *testing.T) { - yamlFile := "example/path/to/yaml/file" - yamlContent := []byte(`charts: -- name: myrelease - chart: mychart -`) - state, err := CreateFromYaml(yamlContent, yamlFile, logger) - if err != nil { - t.Errorf("unxpected error: %v", err) - } - - if state.Releases[0].Name != "myrelease" { - t.Errorf("unexpected release name: expected=myrelease actual=%s", state.Releases[0].Name) - } - if state.Releases[0].Chart != "mychart" { - t.Errorf("unexpected chart name: expected=mychart actual=%s", state.Releases[0].Chart) - } -} - -func TestReadFromYaml_ConflictingReleasesConfig(t *testing.T) { - yamlFile := "example/path/to/yaml/file" - yamlContent := []byte(`charts: -- name: myrelease1 - chart: mychart1 -releases: -- name: myrelease2 - chart: mychart2 -`) - _, err := CreateFromYaml(yamlContent, yamlFile, logger) - if err == nil { - t.Error("expected error") - } -} - -func TestReadFromYaml_FilterReleasesOnLabels(t *testing.T) { - yamlFile := "example/path/to/yaml/file" - yamlContent := []byte(`releases: -- name: myrelease1 - chart: mychart1 - labels: - tier: frontend - foo: bar -- name: myrelease2 - chart: mychart2 - labels: - tier: frontend -- name: myrelease3 - chart: mychart3 - labels: - tier: backend -`) - cases := []struct { - filter LabelFilter - results []bool - }{ - {LabelFilter{positiveLabels: [][]string{[]string{"tier", "frontend"}}}, - []bool{true, true, false}}, - {LabelFilter{positiveLabels: [][]string{[]string{"tier", "frontend"}, []string{"foo", "bar"}}}, - []bool{true, false, false}}, - {LabelFilter{negativeLabels: [][]string{[]string{"tier", "frontend"}}}, - []bool{false, false, true}}, - {LabelFilter{positiveLabels: [][]string{[]string{"tier", "frontend"}}, negativeLabels: [][]string{[]string{"foo", "bar"}}}, - []bool{false, true, false}}, - } - state, err := CreateFromYaml(yamlContent, yamlFile, logger) - if err != nil { - t.Errorf("unexpected error: %v", err) - } - for idx, c := range cases { - for idx2, expected := range c.results { - if f := c.filter.Match(state.Releases[idx2]); f != expected { - t.Errorf("[case: %d][outcome: %d] Unexpected outcome wanted %t, got %t", idx, idx2, expected, f) - } - } - } -} - -func TestReadFromYaml_FilterNegatives(t *testing.T) { - yamlFile := "example/path/to/yaml/file" - yamlContent := []byte(`releases: -- name: myrelease1 - chart: mychart1 - labels: - stage: pre - foo: bar -- name: myrelease2 - chart: mychart2 - labels: - stage: post -- name: myrelease3 - chart: mychart3 -`) - cases := []struct { - filter LabelFilter - results []bool - }{ - {LabelFilter{positiveLabels: [][]string{[]string{"stage", "pre"}}}, - []bool{true, false, false}}, - {LabelFilter{positiveLabels: [][]string{[]string{"stage", "post"}}}, - []bool{false, true, false}}, - {LabelFilter{negativeLabels: [][]string{[]string{"stage", "pre"}, []string{"stage", "post"}}}, - []bool{false, false, true}}, - } - state, err := CreateFromYaml(yamlContent, yamlFile, logger) - if err != nil { - t.Errorf("unexpected error: %v", err) - } - for idx, c := range cases { - for idx2, expected := range c.results { - if f := c.filter.Match(state.Releases[idx2]); f != expected { - t.Errorf("[case: %d][outcome: %d] Unexpected outcome wanted %t, got %t", idx, idx2, expected, f) - } - } - } -} - func TestLabelParsing(t *testing.T) { cases := []struct { labelString string @@ -266,7 +114,7 @@ func TestHelmState_applyDefaultsTo(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { state := &HelmState{ - BaseChartPath: tt.fields.BaseChartPath, + basePath: tt.fields.BaseChartPath, Context: tt.fields.Context, DeprecatedReleases: tt.fields.DeprecatedReleases, Namespace: tt.fields.Namespace, @@ -495,10 +343,10 @@ func TestHelmState_flagsForUpgrade(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { state := &HelmState{ - BaseChartPath: "./", - Context: "default", - Releases: []ReleaseSpec{*tt.release}, - HelmDefaults: tt.defaults, + basePath: "./", + Context: "default", + Releases: []ReleaseSpec{*tt.release}, + HelmDefaults: tt.defaults, } helm := helmexec.New(logger, "default") args, err := state.flagsForUpgrade(helm, tt.release) @@ -861,7 +709,7 @@ func TestHelmState_SyncReleases(t *testing.T) { func TestHelmState_UpdateDeps(t *testing.T) { state := &HelmState{ - BaseChartPath: "/src", + basePath: "/src", Releases: []ReleaseSpec{ { Chart: "./..", diff --git a/tmpl/file.go b/tmpl/file.go index 6370893f..29e556d5 100644 --- a/tmpl/file.go +++ b/tmpl/file.go @@ -2,24 +2,34 @@ package tmpl import ( "bytes" + "github.com/roboll/helmfile/environment" ) type templateFileRenderer struct { ReadFile func(string) ([]byte, error) Context *Context + Data TemplateData +} + +type TemplateData struct { + // Environment is accessible as `.Environment` from any template executed by the renderer + Environment environment.Environment } type FileRenderer interface { RenderTemplateFileToBuffer(file string) (*bytes.Buffer, error) } -func NewFileRenderer(readFile func(filename string) ([]byte, error), basePath string) *templateFileRenderer { +func NewFileRenderer(readFile func(filename string) ([]byte, error), basePath string, env environment.Environment) *templateFileRenderer { return &templateFileRenderer{ ReadFile: readFile, Context: &Context{ basePath: basePath, readFile: readFile, }, + Data: TemplateData{ + Environment: env, + }, } } @@ -29,5 +39,5 @@ func (r *templateFileRenderer) RenderTemplateFileToBuffer(file string) (*bytes.B return nil, err } - return r.Context.RenderTemplateToBuffer(string(content)) + return r.Context.RenderTemplateToBuffer(string(content), r.Data) } diff --git a/tmpl/tmpl.go b/tmpl/tmpl.go index 90a8edd6..d91a7ad9 100644 --- a/tmpl/tmpl.go +++ b/tmpl/tmpl.go @@ -14,14 +14,18 @@ func (c *Context) stringTemplate() *template.Template { return template.New("stringTemplate").Funcs(funcMap) } -func (c *Context) RenderTemplateToBuffer(s string) (*bytes.Buffer, error) { +func (c *Context) RenderTemplateToBuffer(s string, data ...interface{}) (*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) + var d interface{} + if len(data) > 0 { + d = data[0] + } + var execErr = t.Execute(&tplString, d) if execErr != nil { return nil, execErr diff --git a/tmpl/tmpl_test.go b/tmpl/tmpl_test.go index 40ca6714..a996d3c0 100644 --- a/tmpl/tmpl_test.go +++ b/tmpl/tmpl_test.go @@ -31,6 +31,35 @@ func TestRenderTemplate_Values(t *testing.T) { } } +func TestRenderTemplate_WithData(t *testing.T) { + valuesYamlContent := `foo: + bar: {{ .foo.bar }} +` + expected := `foo: + bar: FOO_BAR +` + expectedFilename := "values.yaml" + data := map[string]interface{}{ + "foo": map[string]interface{}{ + "bar": "FOO_BAR", + }, + } + 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(valuesYamlContent, data) + 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) diff --git a/valuesfile/valuesfile.go b/valuesfile/valuesfile.go index d2202f36..bb0673e5 100644 --- a/valuesfile/valuesfile.go +++ b/valuesfile/valuesfile.go @@ -2,6 +2,7 @@ package valuesfile import ( "fmt" + "github.com/roboll/helmfile/environment" "github.com/roboll/helmfile/tmpl" "strings" ) @@ -11,10 +12,10 @@ type renderer struct { tmplFileRenderer tmpl.FileRenderer } -func NewRenderer(readFile func(filename string) ([]byte, error), basePath string) *renderer { +func NewRenderer(readFile func(filename string) ([]byte, error), basePath string, env environment.Environment) *renderer { return &renderer{ readFile: readFile, - tmplFileRenderer: tmpl.NewFileRenderer(readFile, basePath), + tmplFileRenderer: tmpl.NewFileRenderer(readFile, basePath, env), } } diff --git a/valuesfile/valuesfile_test.go b/valuesfile/valuesfile_test.go index 6ceb31a0..2d1dbd8d 100644 --- a/valuesfile/valuesfile_test.go +++ b/valuesfile/valuesfile_test.go @@ -2,6 +2,7 @@ package valuesfile import ( "fmt" + "github.com/roboll/helmfile/environment" "reflect" "testing" ) @@ -24,7 +25,7 @@ func TestRenderToBytes_Gotmpl(t *testing.T) { return []byte(dataFileContent), nil } return nil, fmt.Errorf("unexpected filename: expected=%v or %v, actual=%s", dataFile, valuesTmplFile, filename) - }, "") + }, "", environment.EmptyEnvironment) buf, err := r.RenderToBytes(valuesTmplFile) if err != nil { t.Errorf("unexpected error: %v", err) @@ -49,7 +50,7 @@ func TestRenderToBytes_Yaml(t *testing.T) { return []byte(valuesYamlContent), nil } return nil, fmt.Errorf("unexpected filename: expected=%v, actual=%s", valuesFile, filename) - }, "") + }, "", environment.EmptyEnvironment) buf, err := r.RenderToBytes(valuesFile) if err != nil { t.Errorf("unexpected error: %v", err)