mirror of
https://github.com/helmfile/helmfile.git
synced 2026-09-30 14:47:44 +02:00
* feat(state): add mergeStrategy field to EnvironmentSpec Introduces a per-environment mergeStrategy with valid values "override" (default, current behavior) and "fallback". This commit only adds the field, the constants, and a parse-time validator; the loader still ignores the value, so behavior is unchanged. Subsequent commits thread the value through the values loader and implement the fallback semantics. Signed-off-by: Dominik Schmidt <dev@dominik-schmidt.de> * refactor(state): thread mergeStrategy through values loader Adds a mergeStrategy string parameter to LoadEnvironmentValues, loadValuesEntries, and mapMerge so the value can flow from EnvironmentSpec down to the merge call site. Behavior is unchanged in this commit; mapMerge ignores the strategy and the next commit implements the fallback semantics. Top-level state.DefaultValues and the --state-values-file/-set loaders are passed an empty strategy ("") since they have no per-environment spec to consult and stay on the default override behavior. Signed-off-by: Dominik Schmidt <dev@dominik-schmidt.de> * feat(state): implement fallback merge strategy Adds a hand-rolled fallbackDeepMerge that, unlike mergo, preserves keys present in the destination even when their value is the zero value (false, 0, "", nil, empty list/map). mapMerge dispatches to it when mergeStrategy == "fallback"; "override" and the empty default keep using mergo with WithOverride so existing behaviour is unchanged. Validation lives at the entry of LoadEnvironmentValues so a single chokepoint guards the field. Invalid values produce an error naming both the offending value and the valid options. Tests cover: first-file-wins precedence, gap filling, deep nested merge, three-file chains, explicit zero-value preservation (the case naïve mergo gets wrong), explicit nil preservation, inline map entries, override regression, default-equals-override equivalence, and invalid-strategy errors. Signed-off-by: Dominik Schmidt <dev@dominik-schmidt.de> * feat(state): expose prior-file values in fallback template context Under mergeStrategy: fallback, .gotmpl values files can now reference values from earlier files in the same `values:` list via .Values (e.g. `service.domain: "service.{{ .Values.cluster.domain }}"`). The accumulated result is layered under env.GetMergedValues so env defaults, env values, and CLI overrides still win on overlap. Override mode keeps the historical template context — unchanged — so this is strictly opt-in via the mergeStrategy field. Together with the precedence flip from the previous commit, this lets users replace the brittle two-stage `merged-values.yaml.gotmpl` workaround with native helmfile syntax. Tests cover the headline cross-file template reference case and pin the override-mode contract that prior-file values stay invisible. Signed-off-by: Dominik Schmidt <dev@dominik-schmidt.de> * docs: document mergeStrategy and fallback semantics Adds a new section to values-and-merging.md describing the override vs fallback strategies, the explicit-zero-value preservation guarantee, and the cross-file template reference behavior. Adds a brief pointer to environments.md so users land on the new field from the environment values discussion. Signed-off-by: Dominik Schmidt <dev@dominik-schmidt.de> * refactor(state): reuse maputil.MergeMaps for fallback merge Replaces the hand-rolled fallbackDeepMerge with a single call to maputil.MergeMaps, swapping its arguments so the accumulated dest wins over the new src file. Same first-file-wins semantic, fewer lines, and the fallback path now inherits the same slice merge strategies the rest of helmfile already uses. The one observable behavior shift is for explicit nil values: under fallback, nil in an earlier file no longer 'wins' over a non-nil value in a later file — instead it falls through (matching MergeMaps' rule that nil from the override side only fills missing keys). This is internally consistent: nil-overwrites is an mergo.WithOverride quirk that lives only in the override path. The renamed test NilFallsThroughToFallback pins the new behavior with a comment referencing the contrast with override mode (Issue1154). Signed-off-by: Dominik Schmidt <dev@dominik-schmidt.de> --------- Signed-off-by: Dominik Schmidt <dev@dominik-schmidt.de>
341 lines
9.8 KiB
Go
341 lines
9.8 KiB
Go
package app
|
|
|
|
import (
|
|
"bytes"
|
|
"errors"
|
|
"fmt"
|
|
"os"
|
|
"path/filepath"
|
|
"slices"
|
|
|
|
"dario.cat/mergo"
|
|
"github.com/helmfile/vals"
|
|
"go.uber.org/zap"
|
|
|
|
"github.com/helmfile/helmfile/pkg/environment"
|
|
"github.com/helmfile/helmfile/pkg/envvar"
|
|
"github.com/helmfile/helmfile/pkg/filesystem"
|
|
"github.com/helmfile/helmfile/pkg/helmexec"
|
|
"github.com/helmfile/helmfile/pkg/policy"
|
|
"github.com/helmfile/helmfile/pkg/remote"
|
|
"github.com/helmfile/helmfile/pkg/state"
|
|
)
|
|
|
|
const (
|
|
DefaultHelmBinary = state.DefaultHelmBinary
|
|
DefaultKustomizeBinary = state.DefaultKustomizeBinary
|
|
)
|
|
|
|
type desiredStateLoader struct {
|
|
overrideKubeContext string
|
|
overrideHelmBinary string
|
|
overrideKustomizeBinary string
|
|
enableLiveOutput bool
|
|
|
|
env string
|
|
namespace string
|
|
chart string
|
|
fs *filesystem.FileSystem
|
|
baseDir string // Base directory for resolving relative paths, empty means use cwd
|
|
|
|
getHelm func(*state.HelmState) (helmexec.Interface, error)
|
|
|
|
remote *remote.Remote
|
|
logger *zap.SugaredLogger
|
|
valsRuntime vals.Evaluator
|
|
|
|
lockFilePath string
|
|
}
|
|
|
|
func (ld *desiredStateLoader) Load(f string, opts LoadOpts) (*state.HelmState, error) {
|
|
var overrodeEnv *environment.Environment
|
|
|
|
fileArgs := opts.Environment.OverrideValues
|
|
setArgs := opts.Environment.OverrideCLISetValues
|
|
|
|
if len(fileArgs) > 0 || len(setArgs) > 0 {
|
|
if opts.CalleePath == "" {
|
|
return nil, fmt.Errorf("bug: opts.CalleePath was empty: f=%s, opts=%v", f, opts)
|
|
}
|
|
storage := state.NewStorage(opts.CalleePath, ld.logger, ld.fs)
|
|
envld := state.NewEnvironmentValuesLoader(storage, ld.fs, ld.logger, ld.remote)
|
|
handler := state.MissingFileHandlerError
|
|
|
|
overrodeEnv = &environment.Environment{
|
|
Name: ld.env,
|
|
Values: map[string]any{},
|
|
CLIOverrides: map[string]any{},
|
|
}
|
|
|
|
// --state-values-file: loaded into Values so arrays replace (not merge)
|
|
if len(fileArgs) > 0 {
|
|
fileVals, err := envld.LoadEnvironmentValues(&handler, fileArgs, environment.New(ld.env), ld.env, "")
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
overrodeEnv.Values = fileVals
|
|
}
|
|
|
|
// --state-values-set: loaded into CLIOverrides so arrays merge element-by-element
|
|
if len(setArgs) > 0 {
|
|
setVals, err := envld.LoadEnvironmentValues(&handler, setArgs, environment.New(ld.env), ld.env, "")
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
overrodeEnv.CLIOverrides = setVals
|
|
}
|
|
}
|
|
|
|
// Resolve file path relative to baseDir if provided
|
|
var dir, file string
|
|
if ld.baseDir != "" {
|
|
// If baseDir is set, resolve all paths relative to it
|
|
if !filepath.IsAbs(f) {
|
|
f = filepath.Join(ld.baseDir, f)
|
|
}
|
|
dir = filepath.Dir(f)
|
|
file = filepath.Base(f)
|
|
} else {
|
|
// Use original behavior
|
|
dir = filepath.Dir(f)
|
|
file = filepath.Base(f)
|
|
}
|
|
|
|
st, err := ld.loadFileWithOverrides(nil, overrodeEnv, dir, file, true)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
if opts.Reverse {
|
|
st.Reverse()
|
|
}
|
|
|
|
if ld.overrideKubeContext != "" {
|
|
if st.OverrideKubeContext != "" {
|
|
return nil, errors.New("err: Cannot use option --kube-context and set attribute kubeContext.")
|
|
}
|
|
st.OverrideKubeContext = ld.overrideKubeContext
|
|
// HelmDefaults.KubeContext is also overridden in here
|
|
// to set default release value properly.
|
|
st.HelmDefaults.KubeContext = ld.overrideKubeContext
|
|
}
|
|
|
|
if ld.namespace != "" {
|
|
if st.OverrideNamespace != "" {
|
|
return nil, errors.New("err: Cannot use option --namespace and set attribute namespace.")
|
|
}
|
|
st.OverrideNamespace = ld.namespace
|
|
}
|
|
|
|
if ld.chart != "" {
|
|
if st.OverrideChart != "" {
|
|
return nil, errors.New("err: Cannot use option --chart and set attribute chart.")
|
|
}
|
|
st.OverrideChart = ld.chart
|
|
}
|
|
|
|
return st, nil
|
|
}
|
|
|
|
func (ld *desiredStateLoader) loadFile(inheritedEnv, overrodeEnv *environment.Environment, baseDir, file string, evaluateBases bool) (*state.HelmState, error) {
|
|
path, err := ld.remote.Locate(file, "states")
|
|
if err != nil {
|
|
return nil, fmt.Errorf("locate: %v", err)
|
|
}
|
|
if file != path {
|
|
ld.logger.Debugf("fetched remote \"%s\" to local cache \"%s\" and loading the latter...", file, path)
|
|
}
|
|
file = path
|
|
return ld.loadFileWithOverrides(inheritedEnv, overrodeEnv, baseDir, file, evaluateBases)
|
|
}
|
|
|
|
func (ld *desiredStateLoader) loadFileWithOverrides(inheritedEnv, overrodeEnv *environment.Environment, 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.fs.ReadFile(f)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
self, err := ld.load(
|
|
inheritedEnv,
|
|
overrodeEnv,
|
|
baseDir,
|
|
f,
|
|
fileBytes,
|
|
evaluateBases,
|
|
)
|
|
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
for i, h := range self.Helmfiles {
|
|
if h.Path == f {
|
|
return nil, fmt.Errorf("%s contains a recursion into the same sub-helmfile at helmfiles[%d]", f, i)
|
|
}
|
|
if h.Path == "." {
|
|
return nil, fmt.Errorf("%s contains a recursion into the the directory containing this helmfile at helmfiles[%d]", f, i)
|
|
}
|
|
}
|
|
|
|
return self, nil
|
|
}
|
|
|
|
func (a *desiredStateLoader) underlying() *state.StateCreator {
|
|
c := state.NewCreator(a.logger, a.fs, a.valsRuntime, a.getHelm, a.overrideHelmBinary, a.overrideKustomizeBinary, a.remote, a.enableLiveOutput, a.lockFilePath)
|
|
c.LoadFile = a.loadFile
|
|
return c
|
|
}
|
|
|
|
func (a *desiredStateLoader) rawLoad(yaml []byte, baseDir, file string, evaluateBases bool, env, overrodeEnv *environment.Environment) (*state.HelmState, error) {
|
|
var st *state.HelmState
|
|
var err error
|
|
merged, err := env.Merge(overrodeEnv)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
// applyDefaults is always false here - defaults are applied after all parts are merged
|
|
st, err = a.underlying().ParseAndLoad(yaml, baseDir, file, a.env, false, evaluateBases, false, merged, nil)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
helmfiles, err := st.ExpandedHelmfiles()
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
st.Helmfiles = helmfiles
|
|
|
|
return st, nil
|
|
}
|
|
|
|
func (ld *desiredStateLoader) load(env, overrodeEnv *environment.Environment, baseDir, filename string, content []byte, evaluateBases bool) (*state.HelmState, error) {
|
|
// Allows part-splitting to work with CLRF-ed content
|
|
normalizedContent := bytes.ReplaceAll(content, []byte("\r\n"), []byte("\n"))
|
|
isStrict, err := policy.Checker(filename, normalizedContent)
|
|
if err != nil {
|
|
if isStrict {
|
|
return nil, err
|
|
}
|
|
ld.logger.Warnf("WARNING: %v", err)
|
|
}
|
|
parts := bytes.Split(normalizedContent, []byte("\n---\n"))
|
|
|
|
hasEnv := env != nil || overrodeEnv != nil
|
|
var finalState *state.HelmState
|
|
|
|
for i, part := range parts {
|
|
id := fmt.Sprintf("%s.part.%d", filename, i)
|
|
|
|
var rawContent []byte
|
|
|
|
shouldRender := filepath.Ext(filename) == ".gotmpl" || os.Getenv(envvar.RenderYaml) == "true"
|
|
|
|
if shouldRender {
|
|
var yamlBuf *bytes.Buffer
|
|
var err error
|
|
|
|
if env == nil && overrodeEnv == 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.renderTemplatesToYamlWithEnv(baseDir, id, part, env, overrodeEnv)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("error during %s parsing: %v", id, err)
|
|
}
|
|
}
|
|
rawContent = yamlBuf.Bytes()
|
|
} else {
|
|
rawContent = part
|
|
}
|
|
|
|
currentState, err := ld.rawLoad(
|
|
rawContent,
|
|
baseDir,
|
|
filename,
|
|
evaluateBases,
|
|
env,
|
|
overrodeEnv,
|
|
)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
if finalState == nil {
|
|
finalState = currentState
|
|
} else {
|
|
if err := mergo.Merge(&finalState.ReleaseSetSpec, ¤tState.ReleaseSetSpec, mergo.WithOverride); err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
finalState.RenderedValues = currentState.RenderedValues
|
|
}
|
|
|
|
env = &finalState.Env
|
|
|
|
ld.logger.Debugf("merged environment: %v", env)
|
|
|
|
if len(finalState.Environments) == 0 {
|
|
continue
|
|
}
|
|
|
|
// At this point, we are sure that the env has been
|
|
// read from the vanilla or rendered YAML document.
|
|
// We can now check if the env is defined in it and fail accordingly.
|
|
// See https://github.com/helmfile/helmfile/issues/913
|
|
|
|
// We defer the missing env detection and failure until
|
|
// all the helmfile parts are loaded and merged.
|
|
// Otherwise, any single helmfile part missing the env would fail the whole helmfile run.
|
|
// That's problematic, because each helmfile part is supposed to be incomplete, and
|
|
// they become complete only after merging all the parts.
|
|
// See https://github.com/helmfile/helmfile/issues/807 for the rationale of this.
|
|
if _, ok := finalState.Environments[env.Name]; evaluateBases && env.Name != state.DefaultEnv && !ok {
|
|
return nil, &state.StateLoadError{
|
|
Msg: fmt.Sprintf("failed to read %s", finalState.FilePath),
|
|
Cause: &state.UndefinedEnvError{Env: env.Name},
|
|
}
|
|
}
|
|
}
|
|
|
|
// After all parts are merged, apply defaults and overrides to ensure
|
|
// that values from earlier parts (like helmBinary) are preserved correctly
|
|
// in the merged state.
|
|
// See https://github.com/helmfile/helmfile/issues/2319
|
|
if evaluateBases {
|
|
ld.underlying().ApplyDefaultsAndOverrides(finalState)
|
|
}
|
|
|
|
// If environments are not defined in the helmfile at all although the env is specified,
|
|
// it's a missing env situation. Let's fail.
|
|
if len(finalState.Environments) == 0 && evaluateBases && !hasEnv && env.Name != state.DefaultEnv {
|
|
return nil, &state.StateLoadError{
|
|
Msg: fmt.Sprintf("failed to read %s", finalState.FilePath),
|
|
Cause: &state.UndefinedEnvError{Env: env.Name},
|
|
}
|
|
}
|
|
|
|
// Validate updateStrategy value if set in the releases
|
|
for i := range finalState.Releases {
|
|
if finalState.Releases[i].UpdateStrategy != "" {
|
|
if !slices.Contains(state.ValidUpdateStrategyValues, finalState.Releases[i].UpdateStrategy) {
|
|
return nil, &state.StateLoadError{
|
|
Msg: fmt.Sprintf("failed to read %s", finalState.FilePath),
|
|
Cause: &state.InvalidUpdateStrategyError{UpdateStrategy: finalState.Releases[i].UpdateStrategy},
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
finalState.OrginReleases = finalState.Releases
|
|
return finalState, nil
|
|
}
|