Files
helmfile/pkg/state/inherits_yaml_test.go
T
yxxhero afdb2487a6 feat: add inherits: for sub-helmfile config inheritance (#2680)
* feat: add `inherits:` for sub-helmfile config inheritance

Add an opt-in `inherits:` field to `helmfiles:` entries so a sub-helmfile
can inherit specific configuration categories from its parent:

  helmfiles:
  - path: myapp.yaml
    inherits: [repositories, environments]

Allowed values: repositories, helmDefaults, commonLabels, apiVersions,
kubeVersion, templates, environments. Child values win; parent fills gaps
(consistent with `bases:`). This directly fixes #1495, where a repository
declared in the parent was unavailable to sub-helmfiles, producing a
confusing "repo not found" error.

Implementation notes:
- The 6 pure fields (repositories, helmDefaults, commonLabels, apiVersions,
  kubeVersion, templates) are merged post-load via MergeInherited; verified
  all are consumed post-load (ExecuteTemplates/converge), never at parse.
- environments is injected pre-load as ctxEnv, because RenderedValues is
  baked at load time; the parent's resolved values become the base and the
  child's own environments: block overrides per key.
- helmDefaults uses a *HelmSpec pointer (value type is non-comparable) with
  a no-override mergo merge, so a child that omits helmDefaults inherits the
  parent's fully.
- A footgun warning (WarnUninheritedRepos) suggests
  `inherits: [repositories]` when a release references a repo the parent
  declares but the child lacks.
- Unknown inherits keys are rejected at parse time with the allowed set.

Inheritance is opt-in and fully backward compatible: empty (the default)
preserves the historical independent-sub-helmfile behavior.

Fixes #1495

Signed-off-by: yxxhero <aiopsclub@163.com>

* fix: address review — deep-copy inherited config and fix bases: doc link

- BuildInheritedConfig now deep-copies the pure fields via a YAML round-trip
  (Env via environment.DeepCopy) so the returned config never aliases the
  parent state's slices/maps, matching its doc comment. Now returns an error
  to surface round-trip failures; the call site in processNestedHelmfiles is
  updated. Added TestBuildInheritedConfig_PureFieldsAreDeepCopied to lock
  in the no-aliasing guarantee.
- Fix the broken `bases:` anchor (#) in shared-configuration-across-teams.md
  to point to writing-helmfile.md#layering-state-files.

Signed-off-by: yxxhero <aiopsclub@163.com>

* fix: address review — reject inherits without path and document helmDefaults caveat

Signed-off-by: yxxhero <aiopsclub@163.com>

* fix: address review — make AllowedInherits immutable and clarify effective-repo wording

Signed-off-by: yxxhero <aiopsclub@163.com>

---------

Signed-off-by: yxxhero <aiopsclub@163.com>
2026-07-01 14:44:16 +08:00

100 lines
2.7 KiB
Go

package state
import (
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/helmfile/helmfile/pkg/yaml"
)
func TestSubHelmfileSpec_UnmarshalInherits(t *testing.T) {
t.Run("map form parses inherits", func(t *testing.T) {
var hf SubHelmfileSpec
require.NoError(t, yaml.Unmarshal([]byte(`
path: myapp.yaml
inherits:
- repositories
- helmDefaults
`), &hf))
assert.Equal(t, "myapp.yaml", hf.Path)
assert.Equal(t, []string{"repositories", "helmDefaults"}, hf.Inherits)
})
t.Run("string shorthand leaves inherits nil", func(t *testing.T) {
var hf SubHelmfileSpec
require.NoError(t, yaml.Unmarshal([]byte(`myapp.yaml`), &hf))
assert.Equal(t, "myapp.yaml", hf.Path)
assert.Nil(t, hf.Inherits)
})
t.Run("no inherits leaves it nil", func(t *testing.T) {
var hf SubHelmfileSpec
require.NoError(t, yaml.Unmarshal([]byte("path: myapp.yaml\n"), &hf))
assert.Nil(t, hf.Inherits)
})
}
func TestSubHelmfileSpec_RejectsUnknownInheritsKey(t *testing.T) {
var hf SubHelmfileSpec
err := yaml.Unmarshal([]byte(`
path: myapp.yaml
inherits:
- repositories
- bunkKey
`), &hf)
require.Error(t, err)
assert.Contains(t, err.Error(), "invalid inherits entry")
assert.Contains(t, err.Error(), "bunkKey")
}
func TestSubHelmfileSpec_RejectsInheritsWithoutPath(t *testing.T) {
var hf SubHelmfileSpec
err := yaml.Unmarshal([]byte(`
inherits:
- repositories
`), &hf)
require.Error(t, err)
assert.Contains(t, err.Error(), "found 'inherits' definition without path")
}
func TestSubHelmfileSpec_AllAllowedKeysAccepted(t *testing.T) {
for _, key := range AllowedInherits() {
var hf SubHelmfileSpec
err := yaml.Unmarshal([]byte("path: x.yaml\ninherits:\n- "+key+"\n"), &hf)
require.NoErrorf(t, err, "key %q should be valid", key)
assert.Equal(t, []string{key}, hf.Inherits)
}
}
func TestAllowedInherits_DefensiveCopy(t *testing.T) {
orig := AllowedInherits()
require.NotEmpty(t, orig)
// Mutating the returned slice must not affect validation, which backs onto
// the unexported allowedInherits.
mutated := AllowedInherits()
mutated[0] = "tampered"
// A genuinely valid key is still valid, the tampered value is not, and a fresh
// call still returns the pristine set.
assert.True(t, IsValidInherit("repositories"))
assert.False(t, IsValidInherit("tampered"))
assert.Equal(t, orig, AllowedInherits())
}
func TestSubHelmfileSpec_MarshalRoundTripInherits(t *testing.T) {
hf := SubHelmfileSpec{
Path: "myapp.yaml",
Inherits: []string{"repositories", "environments"},
}
out, err := yaml.Marshal(hf)
require.NoError(t, err)
var got SubHelmfileSpec
require.NoError(t, yaml.Unmarshal(out, &got))
assert.Equal(t, hf.Inherits, got.Inherits)
assert.Equal(t, hf.Path, got.Path)
}