Merge pull request #587 from roboll/layering-enhancements

feat: helmfile.yaml layering enhancements

The current  [Layering](https://github.com/roboll/helmfile/blob/master/docs/writing-helmfile.md#layering) system didn't work as documented, as it relies on helmfile to template each "part" of your helmfile.yaml THEN merge them one by one.

The reality was that helmfile template all the parts of your helmfile.yaml at once, and then merge those YAML documents. In https://github.com/roboll/helmfile/issues/388#issuecomment-436186278, @sruon was making a GREAT point that we may need to change helmfile to render templates earlier - that is to evaluate a template per each helmfile.yaml part separated by `---`. Sorry I missed my expertise to follow your great idea last year @sruon  😭 

Anyways, this, in combination with the wrong documentation, has made so many people confused. To finally overcome this situation, here's a fairly large PR that introduces the 2 enhancements:

- `bases:` for easier layering without go template expressions, especially `{{ readFunc "path/to/file" }}`s. This is the first commit of this PR.
- `helmfile.yaml` is splited by the separator `---` at first. Each part is then rendered as a go template(double-render applies as before). Finally, All the results are merged in the order of occurence. I assume this as an enhanced version of @sruon's work. This is the second commit of this PR.

Resolves #388
Resolve #584
Resolves #585 (`HELMFILE_EXPERIMENTA=true -f helmfile.yaml helmfile` disables the whole-file templating, treating the helmfile.yaml as a regular YAML file as the file ext. denotes. Use `helmfile.yaml.gotmpl` or `helmfile.gotmpl` to enable)
Fixes #568 (Use `bases` or `readFile` rather than not importing implicitly with `helmfile.d`
This commit is contained in:
KUOKA Yusuke 2019-05-14 09:48:20 +09:00 committed by GitHub
commit 255d92064e
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
10 changed files with 528 additions and 172 deletions

View File

@ -100,10 +100,10 @@ Use Layering to extract the common parts into a dedicated *library helmfile*s, s
Let's assume that your `helmfile.yaml` looks like: Let's assume that your `helmfile.yaml` looks like:
``` ```
{ readFile "commons.yaml" }} bases:
--- - commons.yaml
{{ readFile "environments.yaml" }} - environments.yaml
---
releases: releases:
- name: myapp - name: myapp
chart: mychart chart: mychart
@ -125,23 +125,26 @@ environments:
production: production:
``` ```
At run time, template expressions in your `helmfile.yaml` are executed: At run time, `bases` in your `helmfile.yaml` are evaluated to produce:
```yaml ```yaml
# commons.yaml
releases: releases:
- name: metricbaet - name: metricbaet
chart: stable/metricbeat chart: stable/metricbeat
--- ---
# environments.yaml
environments: environments:
development: development:
production: production:
--- ---
# helmfile.yaml
releases: releases:
- name: myapp - name: myapp
chart: mychart chart: mychart
``` ```
Resulting YAML documents are merged in the order of occurrence, Finally the resulting YAML documents are merged in the order of occurrence,
so that your `helmfile.yaml` becomes: so that your `helmfile.yaml` becomes:
```yaml ```yaml
@ -159,3 +162,5 @@ releases:
Great! Great!
Now, repeat the above steps for each your `helmfile.yaml`, so that all your helmfiles becomes DRY. Now, repeat the above steps for each your `helmfile.yaml`, so that all your helmfiles becomes DRY.
Please also see [the discussion in the issue 388](https://github.com/roboll/helmfile/issues/388#issuecomment-491710348) for more advanced layering examples.

View File

@ -7,6 +7,7 @@ import (
"os" "os"
"os/signal" "os/signal"
"strings" "strings"
"syscall"
"github.com/roboll/helmfile/helmexec" "github.com/roboll/helmfile/helmexec"
"github.com/roboll/helmfile/state" "github.com/roboll/helmfile/state"
@ -14,7 +15,6 @@ import (
"path/filepath" "path/filepath"
"sort" "sort"
"syscall"
) )
type App struct { type App struct {
@ -111,34 +111,42 @@ func (a *App) visitStateFiles(fileOrDir string, do func(string) error) error {
return nil return nil
} }
func (a *App) loadDesiredStateFromYaml(file string) (*state.HelmState, error) {
ld := &desiredStateLoader{
readFile: a.readFile,
env: a.Env,
namespace: a.Namespace,
logger: a.Logger,
abs: a.abs,
Reverse: a.Reverse,
KubeContext: a.KubeContext,
glob: a.glob,
}
return ld.Load(file)
}
func (a *App) VisitDesiredStates(fileOrDir string, selector []string, converge func(*state.HelmState, helmexec.Interface) (bool, []error)) error { func (a *App) VisitDesiredStates(fileOrDir string, selector []string, converge func(*state.HelmState, helmexec.Interface) (bool, []error)) error {
noMatchInHelmfiles := true noMatchInHelmfiles := true
err := a.visitStateFiles(fileOrDir, func(f string) error { err := a.visitStateFiles(fileOrDir, func(f string) error {
content, err := a.readFile(f) st, err := a.loadDesiredStateFromYaml(f)
if err != nil {
return err
}
// render template, in two runs
r := &twoPassRenderer{
reader: a.readFile,
env: a.Env,
namespace: a.Namespace,
filename: f,
logger: a.Logger,
abs: a.abs,
}
yamlBuf, err := r.renderTemplate(content)
if err != nil {
return fmt.Errorf("error during %s parsing: %v", f, err)
}
st, err := a.loadDesiredStateFromYaml( sigs := make(chan os.Signal, 1)
yamlBuf.Bytes(), signal.Notify(sigs, syscall.SIGINT, syscall.SIGTERM)
f, go func() {
a.Namespace, sig := <-sigs
a.Env,
) errs := []error{fmt.Errorf("Received [%s] to shutdown ", sig)}
_ = context{a, st}.clean(errs)
// See http://tldp.org/LDP/abs/html/exitcodes.html
switch sig {
case syscall.SIGINT:
os.Exit(130)
case syscall.SIGTERM:
os.Exit(143)
}
}()
ctx := context{a, st} ctx := context{a, st}
@ -313,78 +321,6 @@ func directoryExistsAt(path string) bool {
return err == nil && fileInfo.Mode().IsDir() return err == nil && fileInfo.Mode().IsDir()
} }
func (a *App) loadDesiredStateFromYaml(yaml []byte, file string, namespace string, env string) (*state.HelmState, error) {
c := state.NewCreator(a.Logger, a.readFile, a.abs)
st, err := c.CreateFromYaml(yaml, file, env)
if err != nil {
return nil, err
}
helmfiles := []state.SubHelmfileSpec{}
for _, hf := range st.Helmfiles {
globPattern := hf.Path
var absPathPattern string
if filepath.IsAbs(globPattern) {
absPathPattern = globPattern
} else {
absPathPattern = st.JoinBase(globPattern)
}
matches, err := a.glob(absPathPattern)
if err != nil {
return nil, fmt.Errorf("failed processing %s: %v", globPattern, err)
}
sort.Strings(matches)
for _, match := range matches {
newHelmfile := hf
newHelmfile.Path = match
helmfiles = append(helmfiles, newHelmfile)
}
}
st.Helmfiles = helmfiles
if a.Reverse {
rev := func(i, j int) bool {
return j < i
}
sort.Slice(st.Releases, rev)
sort.Slice(st.Helmfiles, rev)
}
if a.KubeContext != "" {
if st.HelmDefaults.KubeContext != "" {
log.Printf("err: Cannot use option --kube-context and set attribute helmDefaults.kubeContext.")
os.Exit(1)
}
st.HelmDefaults.KubeContext = a.KubeContext
}
if namespace != "" {
if st.Namespace != "" {
log.Printf("err: Cannot use option --namespace and set attribute namespace.")
os.Exit(1)
}
st.Namespace = namespace
}
sigs := make(chan os.Signal, 1)
signal.Notify(sigs, syscall.SIGINT, syscall.SIGTERM)
go func() {
sig := <-sigs
errs := []error{fmt.Errorf("Received [%s] to shutdown ", sig)}
_ = context{a, st}.clean(errs)
// See http://tldp.org/LDP/abs/html/exitcodes.html
switch sig {
case syscall.SIGINT:
os.Exit(130)
case syscall.SIGTERM:
os.Exit(143)
}
}()
return st, nil
}
type Error struct { type Error struct {
msg string msg string

View File

@ -2,7 +2,6 @@ package app
import ( import (
"fmt" "fmt"
"io/ioutil"
"os" "os"
"path/filepath" "path/filepath"
"reflect" "reflect"
@ -83,7 +82,14 @@ func (f *testFs) readFile(filename string) ([]byte, error) {
return []byte(str), nil return []byte(str), nil
} }
func (f *testFs) glob(pattern string) ([]string, error) { func (f *testFs) glob(relPattern string) ([]string, error) {
var pattern string
if relPattern[0] == '/' {
pattern = relPattern
} else {
pattern = filepath.Join(f.wd, relPattern)
}
matches := []string{} matches := []string{}
for name, _ := range f.files { for name, _ := range f.files {
matched, err := filepath.Match(pattern, name) matched, err := filepath.Match(pattern, name)
@ -95,7 +101,7 @@ func (f *testFs) glob(pattern string) ([]string, error) {
} }
} }
if len(matches) == 0 { if len(matches) == 0 {
return []string(nil), fmt.Errorf("no file matched: %s", pattern) return []string(nil), fmt.Errorf("no file matched %s for files: %v", pattern, f.files)
} }
return matches, nil return matches, nil
} }
@ -640,15 +646,199 @@ func TestLoadDesiredStateFromYaml_DuplicateReleaseName(t *testing.T) {
labels: labels:
stage: post stage: post
`) `)
readFile := func(filename string) ([]byte, error) {
if filename != yamlFile {
return nil, fmt.Errorf("unexpected filename: %s", filename)
}
return yamlContent, nil
}
app := &App{ app := &App{
readFile: ioutil.ReadFile, readFile: readFile,
glob: filepath.Glob, glob: filepath.Glob,
abs: filepath.Abs, abs: filepath.Abs,
KubeContext: "default", KubeContext: "default",
Env: "default",
Logger: helmexec.NewLogger(os.Stderr, "debug"), Logger: helmexec.NewLogger(os.Stderr, "debug"),
} }
_, err := app.loadDesiredStateFromYaml(yamlContent, yamlFile, "default", "default") _, err := app.loadDesiredStateFromYaml(yamlFile)
if err != nil { if err != nil {
t.Error("unexpected error") t.Errorf("unexpected error: %v", err)
}
}
func TestLoadDesiredStateFromYaml_Bases(t *testing.T) {
yamlFile := "/path/to/yaml/file"
yamlContent := []byte(`bases:
- ../base.yaml
- ../base.gotmpl
{{ readFile "templates.yaml" }}
releases:
- name: myrelease1
chart: mychart1
labels:
stage: pre
foo: bar
- name: myrelease1
chart: mychart2
labels:
stage: post
<<: *default
`)
files := map[string][]byte{
yamlFile: yamlContent,
"/path/to/base.yaml": []byte(`environments:
default:
values:
- environments/default/1.yaml
`),
"/path/to/yaml/environments/default/1.yaml": []byte(`foo: FOO`),
"/path/to/base.gotmpl": []byte(`environments:
default:
values:
- environments/default/2.yaml
helmDefaults:
tillerNamespace: {{ .Environment.Values.tillerNs }}
`),
"/path/to/yaml/environments/default/2.yaml": []byte(`tillerNs: TILLER_NS`),
"/path/to/yaml/templates.yaml": []byte(`templates:
default: &default
missingFileHandler: Warn
values: ["` + "{{`" + `{{.Release.Name}}` + "`}}" + `/values.yaml"]
`),
}
readFile := func(filename string) ([]byte, error) {
content, ok := files[filename]
if !ok {
return nil, fmt.Errorf("unexpected filename: %s", filename)
}
return content, nil
}
app := &App{
readFile: readFile,
glob: filepath.Glob,
abs: filepath.Abs,
KubeContext: "default",
Env: "default",
Logger: helmexec.NewLogger(os.Stderr, "debug"),
}
st, err := app.loadDesiredStateFromYaml(yamlFile)
if err != nil {
t.Errorf("unexpected error: %v", err)
}
if st.HelmDefaults.TillerNamespace != "TILLER_NS" {
t.Errorf("unexpected helmDefaults.tillerNamespace: expected=TILLER_NS, got=%s", st.HelmDefaults.TillerNamespace)
}
if *st.Releases[1].MissingFileHandler != "Warn" {
t.Errorf("unexpected releases[0].missingFileHandler: expected=Warn, got=%s", *st.Releases[1].MissingFileHandler)
}
if st.Releases[1].Values[0] != "{{`{{.Release.Name}}`}}/values.yaml" {
t.Errorf("unexpected releases[0].missingFileHandler: expected={{`{{.Release.Name}}`}}/values.yaml, got=%s", st.Releases[1].Values[0])
}
}
func TestLoadDesiredStateFromYaml_MultiPartTemplate(t *testing.T) {
yamlFile := "/path/to/yaml/file"
yamlContent := []byte(`bases:
- ../base.yaml
---
bases:
- ../base.gotmpl
---
helmDefaults:
kubeContext: {{ .Environment.Values.foo }}
---
releases:
- name: myrelease0
chart: mychart0
---
{{ readFile "templates.yaml" }}
releases:
- name: myrelease1
chart: mychart1
labels:
stage: pre
foo: bar
- name: myrelease1
chart: mychart2
labels:
stage: post
<<: *default
`)
files := map[string][]byte{
yamlFile: yamlContent,
"/path/to/base.yaml": []byte(`environments:
default:
values:
- environments/default/1.yaml
`),
"/path/to/yaml/environments/default/1.yaml": []byte(`foo: FOO`),
"/path/to/base.gotmpl": []byte(`environments:
default:
values:
- environments/default/2.yaml
helmDefaults:
tillerNamespace: {{ .Environment.Values.tillerNs }}
`),
"/path/to/yaml/environments/default/2.yaml": []byte(`tillerNs: TILLER_NS`),
"/path/to/yaml/templates.yaml": []byte(`templates:
default: &default
missingFileHandler: Warn
values: ["` + "{{`" + `{{.Release.Name}}` + "`}}" + `/values.yaml"]
`),
}
readFile := func(filename string) ([]byte, error) {
content, ok := files[filename]
if !ok {
return nil, fmt.Errorf("unexpected filename: %s", filename)
}
return content, nil
}
app := &App{
readFile: readFile,
glob: filepath.Glob,
abs: filepath.Abs,
Env: "default",
Logger: helmexec.NewLogger(os.Stderr, "debug"),
}
st, err := app.loadDesiredStateFromYaml(yamlFile)
if err != nil {
t.Errorf("unexpected error: %v", err)
}
if st.HelmDefaults.TillerNamespace != "TILLER_NS" {
t.Errorf("unexpected helmDefaults.tillerNamespace: expected=TILLER_NS, got=%s", st.HelmDefaults.TillerNamespace)
}
if st.Releases[0].Name != "myrelease0" {
t.Errorf("unexpected releases[0].name: expected=myrelease0, got=%s", st.Releases[0].Name)
}
if st.Releases[1].Name != "myrelease1" {
t.Errorf("unexpected releases[1].name: expected=myrelease1, got=%s", st.Releases[1].Name)
}
if st.Releases[2].Name != "myrelease1" {
t.Errorf("unexpected releases[2].name: expected=myrelease1, got=%s", st.Releases[2].Name)
}
if st.Releases[2].Values[0] != "{{`{{.Release.Name}}`}}/values.yaml" {
t.Errorf("unexpected releases[2].missingFileHandler: expected={{`{{.Release.Name}}`}}/values.yaml, got=%s", st.Releases[1].Values[0])
}
if *st.Releases[2].MissingFileHandler != "Warn" {
t.Errorf("unexpected releases[2].missingFileHandler: expected=Warn, got=%s", *st.Releases[1].MissingFileHandler)
}
if st.Releases[2].Values[0] != "{{`{{.Release.Name}}`}}/values.yaml" {
t.Errorf("unexpected releases[2].missingFileHandler: expected={{`{{.Release.Name}}`}}/values.yaml, got=%s", st.Releases[1].Values[0])
}
if st.HelmDefaults.KubeContext != "FOO" {
t.Errorf("unexpected helmDefaults.kubeContext: expected=FOO, got=%s", st.HelmDefaults.KubeContext)
} }
} }

View File

@ -13,6 +13,10 @@ const (
ExperimentalSelectorExplicit = "explicit-selector-inheritance" // value to remove default selector inheritance to sub-helmfiles and use the explicit one ExperimentalSelectorExplicit = "explicit-selector-inheritance" // value to remove default selector inheritance to sub-helmfiles and use the explicit one
) )
func isExplicitSelectorInheritanceEnabled() bool { func experimentalModeEnabled() bool {
return os.Getenv(ExperimentalEnvVar) == "true" || strings.Contains(os.Getenv(ExperimentalEnvVar), ExperimentalSelectorExplicit) return os.Getenv(ExperimentalEnvVar) == "true"
}
func isExplicitSelectorInheritanceEnabled() bool {
return experimentalModeEnabled() || strings.Contains(os.Getenv(ExperimentalEnvVar), ExperimentalSelectorExplicit)
} }

View File

@ -0,0 +1,203 @@
package app
import (
"bytes"
"errors"
"fmt"
"github.com/imdario/mergo"
"github.com/roboll/helmfile/environment"
"github.com/roboll/helmfile/state"
"go.uber.org/zap"
"log"
"os"
"path/filepath"
"sort"
)
type desiredStateLoader struct {
KubeContext string
Reverse bool
env string
namespace string
readFile func(string) ([]byte, error)
abs func(string) (string, error)
glob func(string) ([]string, error)
logger *zap.SugaredLogger
}
func (ld *desiredStateLoader) Load(f string) (*state.HelmState, error) {
return ld.loadFile(filepath.Dir(f), filepath.Base(f), true)
}
func (ld *desiredStateLoader) loadFile(baseDir, file string, evaluateBases bool) (*state.HelmState, error) {
var f string
if filepath.IsAbs(file) {
f = file
} else {
f = filepath.Join(baseDir, file)
}
fileBytes, err := ld.readFile(f)
if err != nil {
return nil, err
}
ext := filepath.Ext(f)
var self *state.HelmState
if !experimentalModeEnabled() || ext == ".gotmpl" {
self, err = ld.renderAndLoad(
baseDir,
f,
fileBytes,
evaluateBases,
)
} else {
self, err = ld.load(
fileBytes,
baseDir,
file,
evaluateBases,
)
}
return self, err
}
func (a *desiredStateLoader) load(yaml []byte, baseDir, file string, evaluateBases bool) (*state.HelmState, error) {
c := state.NewCreator(a.logger, a.readFile, a.abs)
st, err := c.ParseAndLoadEnv(yaml, baseDir, file, a.env)
if err != nil {
return nil, err
}
helmfiles := []state.SubHelmfileSpec{}
for _, hf := range st.Helmfiles {
globPattern := hf.Path
var absPathPattern string
if filepath.IsAbs(globPattern) {
absPathPattern = globPattern
} else {
absPathPattern = st.JoinBase(globPattern)
}
matches, err := a.glob(absPathPattern)
if err != nil {
return nil, fmt.Errorf("failed processing %s: %v", globPattern, err)
}
sort.Strings(matches)
for _, match := range matches {
newHelmfile := hf
newHelmfile.Path = match
helmfiles = append(helmfiles, newHelmfile)
}
}
st.Helmfiles = helmfiles
if a.Reverse {
rev := func(i, j int) bool {
return j < i
}
sort.Slice(st.Releases, rev)
sort.Slice(st.Helmfiles, rev)
}
if a.KubeContext != "" {
if st.HelmDefaults.KubeContext != "" {
log.Printf("err: Cannot use option --kube-context and set attribute helmDefaults.kubeContext.")
os.Exit(1)
}
st.HelmDefaults.KubeContext = a.KubeContext
}
if a.namespace != "" {
if st.Namespace != "" {
log.Printf("err: Cannot use option --namespace and set attribute namespace.")
os.Exit(1)
}
st.Namespace = a.namespace
}
if err != nil {
return nil, err
}
if !evaluateBases {
if len(st.Bases) > 0 {
return nil, errors.New("nested `base` helmfile is unsupported. please submit a feature request if you need this!")
}
return st, nil
}
layers := []*state.HelmState{}
for _, b := range st.Bases {
base, err := a.loadFile(baseDir, b, false)
if err != nil {
return nil, err
}
layers = append(layers, base)
}
layers = append(layers, st)
for i := 1; i < len(layers); i++ {
if err := mergo.Merge(layers[0], layers[i], mergo.WithAppendSlice); err != nil {
return nil, err
}
}
return layers[0], nil
}
func (ld *desiredStateLoader) renderAndLoad(baseDir, filename string, content []byte, evaluateBases bool) (*state.HelmState, error) {
parts := bytes.Split(content, []byte("\n---\n"))
var finalState *state.HelmState
var env *environment.Environment
for i, part := range parts {
var yamlBuf *bytes.Buffer
var err error
id := fmt.Sprintf("%s.part.%d", filename, i)
if env == nil {
yamlBuf, err = ld.renderTemplatesToYaml(baseDir, id, part)
if err != nil {
return nil, fmt.Errorf("error during %s parsing: %v", id, err)
}
} else {
yamlBuf, err = ld.renderTemplatesToYaml(baseDir, id, part, *env)
if err != nil {
return nil, fmt.Errorf("error during %s parsing: %v", id, err)
}
}
currentState, err := ld.load(
yamlBuf.Bytes(),
baseDir,
filename,
evaluateBases,
)
if err != nil {
return nil, err
}
if finalState == nil {
finalState = currentState
} else {
if err := mergo.Merge(finalState, currentState, mergo.WithAppendSlice); err != nil {
return nil, err
}
}
env = &finalState.Env
ld.logger.Debugf("merged environment: %v", env)
}
return finalState, nil
}

View File

@ -3,11 +3,10 @@ package app
import ( import (
"bytes" "bytes"
"fmt" "fmt"
"github.com/imdario/mergo"
"github.com/roboll/helmfile/environment" "github.com/roboll/helmfile/environment"
"github.com/roboll/helmfile/state" "github.com/roboll/helmfile/state"
"github.com/roboll/helmfile/tmpl" "github.com/roboll/helmfile/tmpl"
"go.uber.org/zap"
"path/filepath"
"strings" "strings"
) )
@ -20,61 +19,81 @@ func prependLineNumbers(text string) string {
return buf.String() return buf.String()
} }
type twoPassRenderer struct { func (r *desiredStateLoader) renderEnvironment(firstPassEnv environment.Environment, baseDir, filename string, content []byte) environment.Environment {
reader func(string) ([]byte, error)
env string
namespace string
filename string
logger *zap.SugaredLogger
abs func(string) (string, error)
}
func (r *twoPassRenderer) renderEnvironment(content []byte) environment.Environment {
firstPassEnv := environment.Environment{Name: r.env, Values: map[string]interface{}(nil)}
tmplData := state.EnvironmentTemplateData{Environment: firstPassEnv, Namespace: r.namespace} tmplData := state.EnvironmentTemplateData{Environment: firstPassEnv, Namespace: r.namespace}
firstPassRenderer := tmpl.NewFirstPassRenderer(filepath.Dir(r.filename), tmplData) firstPassRenderer := tmpl.NewFirstPassRenderer(baseDir, tmplData)
// parse as much as we can, tolerate errors, this is a preparse // parse as much as we can, tolerate errors, this is a preparse
yamlBuf, err := firstPassRenderer.RenderTemplateContentToBuffer(content) yamlBuf, err := firstPassRenderer.RenderTemplateContentToBuffer(content)
if err != nil && r.logger != nil { if err != nil && r.logger != nil {
r.logger.Debugf("first-pass rendering input of \"%s\":\n%s", r.filename, prependLineNumbers(string(content))) r.logger.Debugf("first-pass rendering input of \"%s\":\n%s", filename, prependLineNumbers(string(content)))
if yamlBuf == nil { // we have a template syntax error, let the second parse report if yamlBuf == nil { // we have a template syntax error, let the second parse report
r.logger.Debugf("template syntax error: %v", err) r.logger.Debugf("template syntax error: %v", err)
return firstPassEnv return firstPassEnv
} }
} }
c := state.NewCreator(r.logger, r.reader, r.abs) c := state.NewCreator(r.logger, r.readFile, r.abs)
c.Strict = false c.Strict = false
// create preliminary state, as we may have an environment. Tolerate errors. // create preliminary state, as we may have an environment. Tolerate errors.
prestate, err := c.CreateFromYaml(yamlBuf.Bytes(), r.filename, r.env) prestate, err := c.ParseAndLoadEnv(yamlBuf.Bytes(), baseDir, filename, r.env)
if err != nil && r.logger != nil { if err != nil && r.logger != nil {
switch err.(type) { switch err.(type) {
case *state.StateLoadError: case *state.StateLoadError:
r.logger.Infof("could not deduce `environment:` block, configuring only .Environment.Name. error: %v", err) r.logger.Infof("could not deduce `environment:` block, configuring only .Environment.Name. error: %v", err)
} }
r.logger.Debugf("error in first-pass rendering: result of \"%s\":\n%s", r.filename, prependLineNumbers(yamlBuf.String())) r.logger.Debugf("error in first-pass rendering: result of \"%s\":\n%s", filename, prependLineNumbers(yamlBuf.String()))
} }
if prestate != nil { if prestate != nil {
firstPassEnv = prestate.Env intEnv := environment.Environment{Name: firstPassEnv.Name}
if err := mergo.Merge(&intEnv, &firstPassEnv, mergo.WithAppendSlice); err != nil {
r.logger.Debugf("error in first-pass rendering: result of \"%s\": %v", filename, err)
return firstPassEnv
}
if err := mergo.Merge(&intEnv, &prestate.Env, mergo.WithAppendSlice); err != nil {
r.logger.Debugf("error in first-pass rendering: result of \"%s\": %v", filename, err)
return firstPassEnv
}
firstPassEnv = intEnv
} }
return firstPassEnv return firstPassEnv
} }
func (r *twoPassRenderer) renderTemplate(content []byte) (*bytes.Buffer, error) { func (r *desiredStateLoader) renderTemplatesToYaml(baseDir, filename string, content []byte, context ...environment.Environment) (*bytes.Buffer, error) {
var env environment.Environment
if len(context) > 0 {
env = context[0]
} else {
env = environment.Environment{Name: r.env, Values: map[string]interface{}(nil)}
}
return r.twoPassRenderTemplateToYaml(env, baseDir, filename, content)
}
func (r *desiredStateLoader) twoPassRenderTemplateToYaml(initEnv environment.Environment, baseDir, filename string, content []byte) (*bytes.Buffer, error) {
// try a first pass render. This will always succeed, but can produce a limited env // try a first pass render. This will always succeed, but can produce a limited env
firstPassEnv := r.renderEnvironment(content) if r.logger != nil {
r.logger.Debugf("first-pass rendering input of \"%s\": %v", filename, initEnv)
}
firstPassEnv := r.renderEnvironment(initEnv, baseDir, filename, content)
if r.logger != nil {
r.logger.Debugf("first-pass rendering result of \"%s\": %v", filename, firstPassEnv)
}
tmplData := state.EnvironmentTemplateData{Environment: firstPassEnv, Namespace: r.namespace} tmplData := state.EnvironmentTemplateData{Environment: firstPassEnv, Namespace: r.namespace}
secondPassRenderer := tmpl.NewFileRenderer(r.reader, filepath.Dir(r.filename), tmplData) secondPassRenderer := tmpl.NewFileRenderer(r.readFile, baseDir, tmplData)
yamlBuf, err := secondPassRenderer.RenderTemplateContentToBuffer(content) yamlBuf, err := secondPassRenderer.RenderTemplateContentToBuffer(content)
if err != nil { if err != nil {
if r.logger != nil { if r.logger != nil {
r.logger.Debugf("second-pass rendering failed, input of \"%s\":\n%s", r.filename, prependLineNumbers(string(content))) r.logger.Debugf("second-pass rendering failed, input of \"%s\":\n%s", filename, prependLineNumbers(string(content)))
} }
return nil, err return nil, err
} }
if r.logger != nil { if r.logger != nil {
r.logger.Debugf("second-pass rendering result of \"%s\":\n%s", r.filename, prependLineNumbers(yamlBuf.String())) r.logger.Debugf("second-pass rendering result of \"%s\":\n%s", filename, prependLineNumbers(yamlBuf.String()))
} }
return yamlBuf, nil return yamlBuf, nil
} }

View File

@ -12,12 +12,11 @@ import (
"gopkg.in/yaml.v2" "gopkg.in/yaml.v2"
) )
func makeRenderer(readFile func(string) ([]byte, error), env string) *twoPassRenderer { func makeLoader(readFile func(string) ([]byte, error), env string) *desiredStateLoader {
return &twoPassRenderer{ return &desiredStateLoader{
reader: readFile, readFile: readFile,
env: env, env: env,
namespace: "namespace", namespace: "namespace",
filename: "",
logger: helmexec.NewLogger(os.Stdout, "debug"), logger: helmexec.NewLogger(os.Stdout, "debug"),
abs: filepath.Abs, abs: filepath.Abs,
} }
@ -51,8 +50,8 @@ releases:
return []byte(""), nil return []byte(""), nil
} }
r := makeRenderer(fileReader, "staging") r := makeLoader(fileReader, "staging")
yamlBuf, err := r.renderTemplate(yamlContent) yamlBuf, err := r.renderTemplatesToYaml("", "", yamlContent)
if err != nil { if err != nil {
t.Errorf("unexpected error: %v", err) t.Errorf("unexpected error: %v", err)
} }
@ -102,9 +101,9 @@ releases:
return defaultValuesYaml, nil return defaultValuesYaml, nil
} }
r := makeRenderer(fileReader, "staging") r := makeLoader(fileReader, "staging")
// test the double rendering // test the double rendering
yamlBuf, err := r.renderTemplate(yamlContent) yamlBuf, err := r.renderTemplatesToYaml("", "", yamlContent)
if err != nil { if err != nil {
t.Fatalf("unexpected error: %v", err) t.Fatalf("unexpected error: %v", err)
} }
@ -151,9 +150,9 @@ releases:
return defaultValuesYaml, nil return defaultValuesYaml, nil
} }
r := makeRenderer(fileReader, "staging") r := makeLoader(fileReader, "staging")
// test the double rendering // test the double rendering
_, err := r.renderTemplate(yamlContent) _, err := r.renderTemplatesToYaml("", "", yamlContent)
if !strings.Contains(err.Error(), "stringTemplate:8") { if !strings.Contains(err.Error(), "stringTemplate:8") {
t.Fatalf("error should contain a stringTemplate error (reference to unknow key) %v", err) t.Fatalf("error should contain a stringTemplate error (reference to unknow key) %v", err)
@ -190,8 +189,8 @@ releases:
return defaultValuesYamlGotmpl, nil return defaultValuesYamlGotmpl, nil
} }
r := makeRenderer(fileReader, "staging") r := makeLoader(fileReader, "staging")
rendered, _ := r.renderTemplate(yamlContent) rendered, _ := r.renderTemplatesToYaml("", "", yamlContent)
var state state.HelmState var state state.HelmState
yaml.Unmarshal(rendered.Bytes(), &state) yaml.Unmarshal(rendered.Bytes(), &state)
@ -217,8 +216,8 @@ func TestReadFromYaml_RenderTemplateWithNamespace(t *testing.T) {
return defaultValuesYaml, nil return defaultValuesYaml, nil
} }
r := makeRenderer(fileReader, "staging") r := makeLoader(fileReader, "staging")
yamlBuf, err := r.renderTemplate(yamlContent) yamlBuf, err := r.renderTemplatesToYaml("", "", yamlContent)
if err != nil { if err != nil {
t.Fatalf("unexpected error: %v", err) t.Fatalf("unexpected error: %v", err)
} }
@ -231,7 +230,7 @@ func TestReadFromYaml_RenderTemplateWithNamespace(t *testing.T) {
} }
} }
func TestReadFromYaml_HelfileShouldBeResilentToTemplateErrors(t *testing.T) { func TestReadFromYaml_HelmfileShouldBeResilentToTemplateErrors(t *testing.T) {
yamlContent := []byte(`environments: yamlContent := []byte(`environments:
staging: staging:
production: production:
@ -248,8 +247,8 @@ releases:
return yamlContent, nil return yamlContent, nil
} }
r := makeRenderer(fileReader, "staging") r := makeLoader(fileReader, "staging")
_, err := r.renderTemplate(yamlContent) _, err := r.renderTemplatesToYaml("", "", yamlContent)
if err == nil { if err == nil {
t.Fatalf("wanted error, none returned") t.Fatalf("wanted error, none returned")
} }

View File

@ -4,7 +4,6 @@ import (
"bytes" "bytes"
"fmt" "fmt"
"io" "io"
"io/ioutil"
"os" "os"
"path/filepath" "path/filepath"
@ -33,16 +32,6 @@ func (e *UndefinedEnvError) Error() string {
return e.msg return e.msg
} }
func createFromYaml(content []byte, file string, env string, logger *zap.SugaredLogger) (*HelmState, error) {
c := &creator{
logger,
ioutil.ReadFile,
filepath.Abs,
true,
}
return c.CreateFromYaml(content, file, env)
}
type creator struct { type creator struct {
logger *zap.SugaredLogger logger *zap.SugaredLogger
readFile func(string) ([]byte, error) readFile func(string) ([]byte, error)
@ -60,15 +49,12 @@ func NewCreator(logger *zap.SugaredLogger, readFile func(string) ([]byte, error)
} }
} }
func (c *creator) CreateFromYaml(content []byte, file string, env string) (*HelmState, error) { // Parses YAML into HelmState, while loading environment values files relative to the `cwd`
func (c *creator) ParseAndLoadEnv(content []byte, baseDir, file string, env string) (*HelmState, error) {
var state HelmState var state HelmState
basePath, err := c.abs(filepath.Dir(file))
if err != nil {
return nil, &StateLoadError{fmt.Sprintf("failed to read %s", file), err}
}
state.FilePath = file state.FilePath = file
state.basePath = basePath state.basePath = baseDir
decoder := yaml.NewDecoder(bytes.NewReader(content)) decoder := yaml.NewDecoder(bytes.NewReader(content))
if !c.Strict { if !c.Strict {

View File

@ -2,6 +2,8 @@ package state
import ( import (
"fmt" "fmt"
"go.uber.org/zap"
"io/ioutil"
"path/filepath" "path/filepath"
"reflect" "reflect"
"testing" "testing"
@ -10,6 +12,16 @@ import (
"gotest.tools/assert/cmp" "gotest.tools/assert/cmp"
) )
func createFromYaml(content []byte, file string, env string, logger *zap.SugaredLogger) (*HelmState, error) {
c := &creator{
logger,
ioutil.ReadFile,
filepath.Abs,
true,
}
return c.ParseAndLoadEnv(content, filepath.Dir(file), file, env)
}
func TestReadFromYaml(t *testing.T) { func TestReadFromYaml(t *testing.T) {
yamlFile := "example/path/to/yaml/file" yamlFile := "example/path/to/yaml/file"
yamlContent := []byte(`releases: yamlContent := []byte(`releases:
@ -101,7 +113,7 @@ bar: {{ readFile "bar.txt" }}
return nil, fmt.Errorf("unexpected filename: %s", filename) return nil, fmt.Errorf("unexpected filename: %s", filename)
} }
state, err := NewCreator(logger, readFile, filepath.Abs).CreateFromYaml(yamlContent, yamlFile, "production") state, err := NewCreator(logger, readFile, filepath.Abs).ParseAndLoadEnv(yamlContent, filepath.Dir(yamlFile), yamlFile, "production")
if err != nil { if err != nil {
t.Errorf("unexpected error: %v", err) t.Errorf("unexpected error: %v", err)
} }

View File

@ -30,6 +30,8 @@ type HelmState struct {
basePath string basePath string
Environments map[string]EnvironmentSpec Environments map[string]EnvironmentSpec
FilePath string FilePath string
Bases []string `yaml:"bases"`
HelmDefaults HelmSpec `yaml:"helmDefaults"` HelmDefaults HelmSpec `yaml:"helmDefaults"`
Helmfiles []SubHelmfileSpec `yaml:"helmfiles"` Helmfiles []SubHelmfileSpec `yaml:"helmfiles"`
DeprecatedContext string `yaml:"context"` DeprecatedContext string `yaml:"context"`