feat: split-render-merge helmfile.yaml parts
This splits your helmfile.yaml by the YAML document separator "---" before evaluating go template expressions as outlined in https://github.com/roboll/helmfile/issues/388#issuecomment-491710348
This commit is contained in:
		
							parent
							
								
									1db205de48
								
							
						
					
					
						commit
						aef366660b
					
				|  | @ -125,23 +125,26 @@ environments: | |||
|   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 | ||||
| # commons.yaml | ||||
| releases: | ||||
| - name: metricbaet | ||||
|   chart: stable/metricbeat | ||||
| --- | ||||
| # environments.yaml | ||||
| environments: | ||||
|   development: | ||||
|   production: | ||||
| --- | ||||
| # helmfile.yaml | ||||
| releases: | ||||
| - name: myapp | ||||
|   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: | ||||
| 
 | ||||
| ```yaml | ||||
|  | @ -159,3 +162,5 @@ releases: | |||
| Great! | ||||
| 
 | ||||
| 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. | ||||
|  |  | |||
|  | @ -702,8 +702,8 @@ releases: | |||
| helmDefaults: | ||||
|   tillerNamespace: {{ .Environment.Values.tillerNs }} | ||||
| `), | ||||
| "/path/to/yaml/environments/default/2.yaml": []byte(`tillerNs: TILLER_NS`), | ||||
| "/path/to/yaml/templates.yaml": []byte(`templates: | ||||
| 		"/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"] | ||||
|  | @ -741,3 +741,104 @@ helmDefaults: | |||
| 		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) | ||||
| 	} | ||||
| } | ||||
|  |  | |||
|  | @ -1,8 +1,11 @@ | |||
| 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" | ||||
|  | @ -26,10 +29,10 @@ type desiredStateLoader struct { | |||
| } | ||||
| 
 | ||||
| func (ld *desiredStateLoader) Load(f string) (*state.HelmState, error) { | ||||
| 	return ld.load(filepath.Dir(f), filepath.Base(f), true) | ||||
| 	return ld.loadFile(filepath.Dir(f), filepath.Base(f), true) | ||||
| } | ||||
| 
 | ||||
| func (ld *desiredStateLoader) load(baseDir, file string, evaluateBases bool) (*state.HelmState, error) { | ||||
| func (ld *desiredStateLoader) loadFile(baseDir, file string, evaluateBases bool) (*state.HelmState, error) { | ||||
| 	var f string | ||||
| 	if filepath.IsAbs(file) { | ||||
| 		f = file | ||||
|  | @ -44,51 +47,28 @@ func (ld *desiredStateLoader) load(baseDir, file string, evaluateBases bool) (*s | |||
| 
 | ||||
| 	ext := filepath.Ext(f) | ||||
| 
 | ||||
| 	var yamlBytes []byte | ||||
| 	if !experimentalModeEnabled() || ext == ".gotmpl" { | ||||
| 		yamlBuf, err := ld.renderTemplateToYaml(baseDir, f, fileBytes) | ||||
| 		if err != nil { | ||||
| 			return nil, fmt.Errorf("error during %s parsing: %v", f, err) | ||||
| 		} | ||||
| 		yamlBytes = yamlBuf.Bytes() | ||||
| 	} else { | ||||
| 		yamlBytes = fileBytes | ||||
| 	} | ||||
| 	var self *state.HelmState | ||||
| 
 | ||||
| 	self, err := ld.loadYaml( | ||||
| 		yamlBytes, | ||||
| 	if !experimentalModeEnabled() || ext == ".gotmpl" { | ||||
| 		self, err = ld.renderAndLoad( | ||||
| 			baseDir, | ||||
| 			f, | ||||
| 			fileBytes, | ||||
| 			evaluateBases, | ||||
| 		) | ||||
| 	} else { | ||||
| 		self, err = ld.load( | ||||
| 			fileBytes, | ||||
| 			baseDir, | ||||
| 			file, | ||||
| 			evaluateBases, | ||||
| 		) | ||||
| 
 | ||||
| 	if err != nil { | ||||
| 		return nil, err | ||||
| 	} | ||||
| 
 | ||||
| 	if !evaluateBases { | ||||
| 		return self, nil | ||||
| 	} | ||||
| 
 | ||||
| 	layers := []*state.HelmState{} | ||||
| 	for _, b := range self.Bases { | ||||
| 		base, err := ld.load(baseDir, b, false) | ||||
| 		if err != nil { | ||||
| 			return nil, err | ||||
| 		} | ||||
| 		layers = append(layers, base) | ||||
| 	} | ||||
| 	layers = append(layers, self) | ||||
| 
 | ||||
| 	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 | ||||
| 	return self, err | ||||
| } | ||||
| 
 | ||||
| func (a *desiredStateLoader) loadYaml(yaml []byte, baseDir, file string) (*state.HelmState, error) { | ||||
| 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 { | ||||
|  | @ -141,5 +121,83 @@ func (a *desiredStateLoader) loadYaml(yaml []byte, baseDir, file string) (*state | |||
| 		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 | ||||
| } | ||||
|  |  | |||
|  | @ -3,6 +3,7 @@ package app | |||
| import ( | ||||
| 	"bytes" | ||||
| 	"fmt" | ||||
| 	"github.com/imdario/mergo" | ||||
| 	"github.com/roboll/helmfile/environment" | ||||
| 	"github.com/roboll/helmfile/state" | ||||
| 	"github.com/roboll/helmfile/tmpl" | ||||
|  | @ -18,8 +19,7 @@ func prependLineNumbers(text string) string { | |||
| 	return buf.String() | ||||
| } | ||||
| 
 | ||||
| func (r *desiredStateLoader) renderEnvironment(baseDir, filename string, content []byte) environment.Environment { | ||||
| 	firstPassEnv := environment.Environment{Name: r.env, Values: map[string]interface{}(nil)} | ||||
| func (r *desiredStateLoader) renderEnvironment(firstPassEnv environment.Environment, baseDir, filename string, content []byte) environment.Environment { | ||||
| 	tmplData := state.EnvironmentTemplateData{Environment: firstPassEnv, Namespace: r.namespace} | ||||
| 	firstPassRenderer := tmpl.NewFirstPassRenderer(baseDir, tmplData) | ||||
| 
 | ||||
|  | @ -43,15 +43,45 @@ func (r *desiredStateLoader) renderEnvironment(baseDir, filename string, content | |||
| 		} | ||||
| 		r.logger.Debugf("error in first-pass rendering: result of \"%s\":\n%s", filename, prependLineNumbers(yamlBuf.String())) | ||||
| 	} | ||||
| 
 | ||||
| 	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 | ||||
| } | ||||
| 
 | ||||
| func (r *desiredStateLoader) renderTemplateToYaml(baseDir, filename string, 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
 | ||||
| 	firstPassEnv := r.renderEnvironment(baseDir, filename, 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} | ||||
| 	secondPassRenderer := tmpl.NewFileRenderer(r.readFile, baseDir, tmplData) | ||||
|  |  | |||
|  | @ -51,7 +51,7 @@ releases: | |||
| 	} | ||||
| 
 | ||||
| 	r := makeLoader(fileReader, "staging") | ||||
| 	yamlBuf, err := r.renderTemplateToYaml("", "", yamlContent) | ||||
| 	yamlBuf, err := r.renderTemplatesToYaml("", "", yamlContent) | ||||
| 	if err != nil { | ||||
| 		t.Errorf("unexpected error: %v", err) | ||||
| 	} | ||||
|  | @ -103,7 +103,7 @@ releases: | |||
| 
 | ||||
| 	r := makeLoader(fileReader, "staging") | ||||
| 	// test the double rendering
 | ||||
| 	yamlBuf, err := r.renderTemplateToYaml("", "", yamlContent) | ||||
| 	yamlBuf, err := r.renderTemplatesToYaml("", "", yamlContent) | ||||
| 	if err != nil { | ||||
| 		t.Fatalf("unexpected error: %v", err) | ||||
| 	} | ||||
|  | @ -152,7 +152,7 @@ releases: | |||
| 
 | ||||
| 	r := makeLoader(fileReader, "staging") | ||||
| 	// test the double rendering
 | ||||
| 	_, err := r.renderTemplateToYaml("", "", yamlContent) | ||||
| 	_, err := r.renderTemplatesToYaml("", "", yamlContent) | ||||
| 
 | ||||
| 	if !strings.Contains(err.Error(), "stringTemplate:8") { | ||||
| 		t.Fatalf("error should contain a stringTemplate error (reference to unknow key) %v", err) | ||||
|  | @ -190,7 +190,7 @@ releases: | |||
| 	} | ||||
| 
 | ||||
| 	r := makeLoader(fileReader, "staging") | ||||
| 	rendered, _ := r.renderTemplateToYaml("", "", yamlContent) | ||||
| 	rendered, _ := r.renderTemplatesToYaml("", "", yamlContent) | ||||
| 
 | ||||
| 	var state state.HelmState | ||||
| 	yaml.Unmarshal(rendered.Bytes(), &state) | ||||
|  | @ -217,7 +217,7 @@ func TestReadFromYaml_RenderTemplateWithNamespace(t *testing.T) { | |||
| 	} | ||||
| 
 | ||||
| 	r := makeLoader(fileReader, "staging") | ||||
| 	yamlBuf, err := r.renderTemplateToYaml("", "", yamlContent) | ||||
| 	yamlBuf, err := r.renderTemplatesToYaml("", "", yamlContent) | ||||
| 	if err != nil { | ||||
| 		t.Fatalf("unexpected error: %v", err) | ||||
| 	} | ||||
|  | @ -248,7 +248,7 @@ releases: | |||
| 	} | ||||
| 
 | ||||
| 	r := makeLoader(fileReader, "staging") | ||||
| 	_, err := r.renderTemplateToYaml("", "", yamlContent) | ||||
| 	_, err := r.renderTemplatesToYaml("", "", yamlContent) | ||||
| 	if err == nil { | ||||
| 		t.Fatalf("wanted error, none returned") | ||||
| 	} | ||||
|  |  | |||
		Loading…
	
		Reference in New Issue