diff --git a/docs/writing-helmfile.md b/docs/writing-helmfile.md index 23602a77..2067577c 100644 --- a/docs/writing-helmfile.md +++ b/docs/writing-helmfile.md @@ -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: ``` -{ readFile "commons.yaml" }} ---- -{{ readFile "environments.yaml" }} ---- +bases: +- commons.yaml +- environments.yaml + releases: - name: myapp chart: mychart diff --git a/pkg/app/app.go b/pkg/app/app.go index ad61ce7f..33511a80 100644 --- a/pkg/app/app.go +++ b/pkg/app/app.go @@ -7,6 +7,7 @@ import ( "os" "os/signal" "strings" + "syscall" "github.com/roboll/helmfile/helmexec" "github.com/roboll/helmfile/state" @@ -14,7 +15,6 @@ import ( "path/filepath" "sort" - "syscall" ) type App struct { @@ -111,34 +111,42 @@ func (a *App) visitStateFiles(fileOrDir string, do func(string) error) error { 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 { noMatchInHelmfiles := true err := a.visitStateFiles(fileOrDir, func(f string) error { - content, err := a.readFile(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(f) - st, err := a.loadDesiredStateFromYaml( - yamlBuf.Bytes(), - f, - a.Namespace, - a.Env, - ) + 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) + } + }() ctx := context{a, st} @@ -313,78 +321,6 @@ func directoryExistsAt(path string) bool { 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 { msg string diff --git a/pkg/app/app_test.go b/pkg/app/app_test.go index 5335e317..14a6de63 100644 --- a/pkg/app/app_test.go +++ b/pkg/app/app_test.go @@ -2,7 +2,6 @@ package app import ( "fmt" - "io/ioutil" "os" "path/filepath" "reflect" @@ -83,7 +82,14 @@ func (f *testFs) readFile(filename string) ([]byte, error) { 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{} for name, _ := range f.files { matched, err := filepath.Match(pattern, name) @@ -95,7 +101,7 @@ func (f *testFs) glob(pattern string) ([]string, error) { } } 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 } @@ -640,15 +646,98 @@ func TestLoadDesiredStateFromYaml_DuplicateReleaseName(t *testing.T) { labels: stage: post `) + readFile := func(filename string) ([]byte, error) { + if filename != yamlFile { + return nil, fmt.Errorf("unexpected filename: %s", filename) + } + return yamlContent, nil + } app := &App{ - readFile: ioutil.ReadFile, + readFile: readFile, glob: filepath.Glob, abs: filepath.Abs, KubeContext: "default", + Env: "default", Logger: helmexec.NewLogger(os.Stderr, "debug"), } - _, err := app.loadDesiredStateFromYaml(yamlContent, yamlFile, "default", "default") + _, err := app.loadDesiredStateFromYaml(yamlFile) 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]) } } diff --git a/pkg/app/constants.go b/pkg/app/constants.go index 472c93aa..ddb1e29f 100644 --- a/pkg/app/constants.go +++ b/pkg/app/constants.go @@ -13,6 +13,10 @@ const ( ExperimentalSelectorExplicit = "explicit-selector-inheritance" // value to remove default selector inheritance to sub-helmfiles and use the explicit one ) -func isExplicitSelectorInheritanceEnabled() bool { - return os.Getenv(ExperimentalEnvVar) == "true" || strings.Contains(os.Getenv(ExperimentalEnvVar), ExperimentalSelectorExplicit) +func experimentalModeEnabled() bool { + return os.Getenv(ExperimentalEnvVar) == "true" +} + +func isExplicitSelectorInheritanceEnabled() bool { + return experimentalModeEnabled() || strings.Contains(os.Getenv(ExperimentalEnvVar), ExperimentalSelectorExplicit) } diff --git a/pkg/app/desired_state_file_loader.go b/pkg/app/desired_state_file_loader.go new file mode 100644 index 00000000..dda9e3e6 --- /dev/null +++ b/pkg/app/desired_state_file_loader.go @@ -0,0 +1,145 @@ +package app + +import ( + "fmt" + "github.com/imdario/mergo" + "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.load(filepath.Dir(f), filepath.Base(f), true) +} + +func (ld *desiredStateLoader) load(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 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 + } + + self, err := ld.loadYaml( + yamlBytes, + baseDir, + file, + ) + + 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 +} + +func (a *desiredStateLoader) loadYaml(yaml []byte, baseDir, file string) (*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 + } + + return st, nil +} diff --git a/pkg/app/two_pass_renderer.go b/pkg/app/two_pass_renderer.go index 4bfaec50..291f0a29 100644 --- a/pkg/app/two_pass_renderer.go +++ b/pkg/app/two_pass_renderer.go @@ -6,8 +6,6 @@ import ( "github.com/roboll/helmfile/environment" "github.com/roboll/helmfile/state" "github.com/roboll/helmfile/tmpl" - "go.uber.org/zap" - "path/filepath" "strings" ) @@ -20,39 +18,30 @@ func prependLineNumbers(text string) string { return buf.String() } -type twoPassRenderer struct { - 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 { +func (r *desiredStateLoader) renderEnvironment(baseDir, filename string, content []byte) environment.Environment { firstPassEnv := environment.Environment{Name: r.env, Values: map[string]interface{}(nil)} 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 yamlBuf, err := firstPassRenderer.RenderTemplateContentToBuffer(content) 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 r.logger.Debugf("template syntax error: %v", err) return firstPassEnv } } - c := state.NewCreator(r.logger, r.reader, r.abs) + c := state.NewCreator(r.logger, r.readFile, r.abs) c.Strict = false // 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 { switch err.(type) { case *state.StateLoadError: 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 { firstPassEnv = prestate.Env @@ -60,21 +49,21 @@ func (r *twoPassRenderer) renderEnvironment(content []byte) environment.Environm return firstPassEnv } -func (r *twoPassRenderer) renderTemplate(content []byte) (*bytes.Buffer, error) { +func (r *desiredStateLoader) renderTemplateToYaml(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(content) + firstPassEnv := r.renderEnvironment(baseDir, filename, content) 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) if err != 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 } 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 } diff --git a/pkg/app/two_pass_renderer_test.go b/pkg/app/two_pass_renderer_test.go index 917b6a41..bf8072fe 100644 --- a/pkg/app/two_pass_renderer_test.go +++ b/pkg/app/two_pass_renderer_test.go @@ -12,12 +12,11 @@ import ( "gopkg.in/yaml.v2" ) -func makeRenderer(readFile func(string) ([]byte, error), env string) *twoPassRenderer { - return &twoPassRenderer{ - reader: readFile, +func makeLoader(readFile func(string) ([]byte, error), env string) *desiredStateLoader { + return &desiredStateLoader{ + readFile: readFile, env: env, namespace: "namespace", - filename: "", logger: helmexec.NewLogger(os.Stdout, "debug"), abs: filepath.Abs, } @@ -51,8 +50,8 @@ releases: return []byte(""), nil } - r := makeRenderer(fileReader, "staging") - yamlBuf, err := r.renderTemplate(yamlContent) + r := makeLoader(fileReader, "staging") + yamlBuf, err := r.renderTemplateToYaml("", "", yamlContent) if err != nil { t.Errorf("unexpected error: %v", err) } @@ -102,9 +101,9 @@ releases: return defaultValuesYaml, nil } - r := makeRenderer(fileReader, "staging") + r := makeLoader(fileReader, "staging") // test the double rendering - yamlBuf, err := r.renderTemplate(yamlContent) + yamlBuf, err := r.renderTemplateToYaml("", "", yamlContent) if err != nil { t.Fatalf("unexpected error: %v", err) } @@ -151,9 +150,9 @@ releases: return defaultValuesYaml, nil } - r := makeRenderer(fileReader, "staging") + r := makeLoader(fileReader, "staging") // test the double rendering - _, err := r.renderTemplate(yamlContent) + _, err := r.renderTemplateToYaml("", "", yamlContent) if !strings.Contains(err.Error(), "stringTemplate:8") { t.Fatalf("error should contain a stringTemplate error (reference to unknow key) %v", err) @@ -190,8 +189,8 @@ releases: return defaultValuesYamlGotmpl, nil } - r := makeRenderer(fileReader, "staging") - rendered, _ := r.renderTemplate(yamlContent) + r := makeLoader(fileReader, "staging") + rendered, _ := r.renderTemplateToYaml("", "", yamlContent) var state state.HelmState yaml.Unmarshal(rendered.Bytes(), &state) @@ -217,8 +216,8 @@ func TestReadFromYaml_RenderTemplateWithNamespace(t *testing.T) { return defaultValuesYaml, nil } - r := makeRenderer(fileReader, "staging") - yamlBuf, err := r.renderTemplate(yamlContent) + r := makeLoader(fileReader, "staging") + yamlBuf, err := r.renderTemplateToYaml("", "", yamlContent) if err != nil { 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: staging: production: @@ -248,8 +247,8 @@ releases: return yamlContent, nil } - r := makeRenderer(fileReader, "staging") - _, err := r.renderTemplate(yamlContent) + r := makeLoader(fileReader, "staging") + _, err := r.renderTemplateToYaml("", "", yamlContent) if err == nil { t.Fatalf("wanted error, none returned") } diff --git a/state/create.go b/state/create.go index b2d2e4ef..b68b0c00 100644 --- a/state/create.go +++ b/state/create.go @@ -4,7 +4,6 @@ import ( "bytes" "fmt" "io" - "io/ioutil" "os" "path/filepath" @@ -33,16 +32,6 @@ func (e *UndefinedEnvError) Error() string { 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 { logger *zap.SugaredLogger 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 - 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.basePath = basePath + state.basePath = baseDir decoder := yaml.NewDecoder(bytes.NewReader(content)) if !c.Strict { diff --git a/state/create_test.go b/state/create_test.go index d99d9b62..9dfac579 100644 --- a/state/create_test.go +++ b/state/create_test.go @@ -2,6 +2,8 @@ package state import ( "fmt" + "go.uber.org/zap" + "io/ioutil" "path/filepath" "reflect" "testing" @@ -10,6 +12,16 @@ import ( "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) { yamlFile := "example/path/to/yaml/file" yamlContent := []byte(`releases: @@ -101,7 +113,7 @@ bar: {{ readFile "bar.txt" }} 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 { t.Errorf("unexpected error: %v", err) } diff --git a/state/state.go b/state/state.go index e7fc5cb2..6a869faa 100644 --- a/state/state.go +++ b/state/state.go @@ -27,9 +27,11 @@ import ( // HelmState structure for the helmfile type HelmState struct { - basePath string - Environments map[string]EnvironmentSpec - FilePath string + basePath string + Environments map[string]EnvironmentSpec + FilePath string + + Bases []string `yaml:"bases"` HelmDefaults HelmSpec `yaml:"helmDefaults"` Helmfiles []SubHelmfileSpec `yaml:"helmfiles"` DeprecatedContext string `yaml:"context"`