Files
helmfile/pkg/state/inherited.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

277 lines
9.0 KiB
Go

package state
import (
"fmt"
"strings"
"dario.cat/mergo"
"go.uber.org/zap"
"github.com/helmfile/helmfile/pkg/environment"
"github.com/helmfile/helmfile/pkg/yaml"
)
// allowedInherits is the single source of truth for the keys accepted by the
// sub-helmfile `inherits:` field. It is unexported so external packages cannot
// mutate it at runtime; validation, docs and tests reach it via AllowedInherits
// (which returns a defensive copy) and IsValidInherit.
var allowedInherits = []string{
"repositories",
"helmDefaults",
"commonLabels",
"apiVersions",
"kubeVersion",
"templates",
"environments",
}
// AllowedInherits returns a defensive copy of the keys accepted by the
// sub-helmfile `inherits:` field. The returned slice is safe for callers to
// mutate without affecting validation behavior.
func AllowedInherits() []string {
out := make([]string, len(allowedInherits))
copy(out, allowedInherits)
return out
}
// IsValidInherit reports whether key is an allowed inherits: entry.
func IsValidInherit(key string) bool {
for _, k := range allowedInherits {
if k == key {
return true
}
}
return false
}
// InheritedConfig carries parent-helmfile config to a sub-helmfile. Only the
// fields requested via `inherits:` are populated; the rest stay zero/nil so the
// consumer can skip them. Env is resolved pre-load (see desiredStateLoader.Load)
// because environment values are baked into RenderedValues at load time; the
// other fields are merged post-load via MergeInherited.
type InheritedConfig struct {
Repositories []RepositorySpec `yaml:"repositories,omitempty"`
HelmDefaults *HelmSpec `yaml:"helmDefaults,omitempty"`
CommonLabels map[string]string `yaml:"commonLabels,omitempty"`
ApiVersions []string `yaml:"apiVersions,omitempty"`
KubeVersion string `yaml:"kubeVersion,omitempty"`
Templates map[string]TemplateSpec `yaml:"templates,omitempty"`
Env *environment.Environment `yaml:"-"`
}
// BuildInheritedConfig extracts the requested fields from the parent state and
// returns a fully independent copy. The pure fields are deep-copied via a YAML
// round-trip (these structs ARE the helmfile model, so the round-trip is
// lossless for the user-declared fields and avoids hand-maintained per-field
// copy logic), and Env is deep-copied via environment.DeepCopy. The returned
// config therefore never aliases the parent state's memory.
func (st *HelmState) BuildInheritedConfig(want []string) (*InheritedConfig, error) {
set := make(map[string]bool, len(want))
for _, w := range want {
set[w] = true
}
// src temporarily references the parent; the round-trip below decouples it.
src := &InheritedConfig{}
if set["repositories"] {
src.Repositories = st.Repositories
}
if set["helmDefaults"] {
src.HelmDefaults = &st.HelmDefaults
}
if set["commonLabels"] {
src.CommonLabels = st.CommonLabels
}
if set["apiVersions"] {
src.ApiVersions = st.ApiVersions
}
if set["kubeVersion"] {
src.KubeVersion = st.KubeVersion
}
if set["templates"] {
src.Templates = st.Templates
}
in := &InheritedConfig{}
// Deep-copy the pure fields so the result never shares slices/maps with the
// parent. Skipped only when no pure field was requested.
if set["repositories"] || set["helmDefaults"] || set["commonLabels"] ||
set["apiVersions"] || set["kubeVersion"] || set["templates"] {
b, err := yaml.Marshal(src)
if err != nil {
return nil, fmt.Errorf("marshaling inherited config for deep copy: %w", err)
}
if err := yaml.Unmarshal(b, in); err != nil {
return nil, fmt.Errorf("unmarshaling inherited config for deep copy: %w", err)
}
}
// Env is tagged yaml:"-" so it does not survive the round-trip; deep-copy it
// explicitly via its own DeepCopy (handles nested value maps).
if set["environments"] {
e := st.Env.DeepCopy()
in.Env = &e
}
return in, nil
}
// MergeInherited merges the 6 pure fields into the child state. Semantics:
// "child wins, parent fills gaps" — matching bases: precedence.
//
// - repositories / apiVersions: parent-first append, de-duplicated by value
// (repositories by Name, child's entry wins on conflict).
// - helmDefaults: deep struct merge via mergo without override, so the
// parent fills the child's zero-valued sub-fields and the child's non-zero
// sub-fields win. Caveat: Go value types cannot distinguish "unset" from
// zero, so a child that explicitly sets a field to its zero value (e.g.
// atomic: false to disable) will see the parent's value fill in. The
// common case — child omits helmDefaults entirely and inherits the parent's
// — works correctly. This intentionally differs from bases:, which uses
// WithOverride and would wipe the parent whenever the child's block is
// absent, making inheritance useless.
// - commonLabels / templates: per-key union, child's key wins.
// - kubeVersion: parent fills only when the child left it empty.
//
// Env (environments) is intentionally NOT handled here; it must be injected
// pre-load because RenderedValues is computed at load time.
func (st *HelmState) MergeInherited(in *InheritedConfig) error {
if in == nil {
return nil
}
if in.Repositories != nil {
combined := append([]RepositorySpec{}, in.Repositories...)
combined = append(combined, st.Repositories...)
st.Repositories = dedupReposByName(combined)
}
if in.HelmDefaults != nil {
if err := mergo.Merge(&st.HelmDefaults, *in.HelmDefaults); err != nil {
return fmt.Errorf("merging inherited helmDefaults: %w", err)
}
}
if in.CommonLabels != nil {
if st.CommonLabels == nil {
st.CommonLabels = map[string]string{}
}
for k, v := range in.CommonLabels {
if _, ok := st.CommonLabels[k]; !ok {
st.CommonLabels[k] = v
}
}
}
if in.Templates != nil {
if st.Templates == nil {
st.Templates = map[string]TemplateSpec{}
}
for k, v := range in.Templates {
if _, ok := st.Templates[k]; !ok {
st.Templates[k] = v
}
}
}
if in.ApiVersions != nil {
combined := append([]string{}, in.ApiVersions...)
combined = append(combined, st.ApiVersions...)
st.ApiVersions = dedupStrings(combined)
}
if in.KubeVersion != "" && st.KubeVersion == "" {
st.KubeVersion = in.KubeVersion
}
return nil
}
// WarnUninheritedRepos emits a one-time warning per repository that a release
// references, which is present in the parent's effective repository set
// (parentRepoNames — derived from the parent's st.Repositories, so it includes
// repositories brought in via bases: or inherits:) but missing from the child's
// effective repositories (after any inheritance merge). It is a footgun guard
// for issue #1495: when repositories are not inherited, a release chart like
// "release-charts/myapp" fails with a confusing "repo not found". The warning
// suggests `inherits: [repositories]`.
//
// When `inherits: [repositories]` is in effect, the parent's repos are already
// part of the child's st.Repositories, so the condition is naturally false and
// nothing is logged.
func (st *HelmState) WarnUninheritedRepos(parentRepoNames []string, logger *zap.SugaredLogger) {
if logger == nil || len(parentRepoNames) == 0 || len(st.Releases) == 0 {
return
}
parentSet := make(map[string]bool, len(parentRepoNames))
for _, n := range parentRepoNames {
parentSet[n] = true
}
childSet := make(map[string]bool, len(st.Repositories))
for _, r := range st.Repositories {
childSet[r.Name] = true
}
warned := map[string]bool{}
for _, rel := range st.Releases {
chart := rel.Chart
if chart == "" {
continue
}
parts := strings.SplitN(chart, "/", 2)
if len(parts) < 2 || parts[0] == "" {
continue // local path, URL, or bare chart name — not a named-repo ref
}
repo := parts[0]
// Skip schemes (oci://, https://, ...) and relative paths (./, ../):
// these are never named-repository references, so they must not match a
// parent repo name even by coincidence.
if strings.Contains(repo, ":") || repo == "." || repo == ".." {
continue
}
if parentSet[repo] && !childSet[repo] && !warned[repo] {
warned[repo] = true
logger.Warnf(
`release %q references repository %q which is available in the parent helmfile but not inherited. `+
`Add "inherits: [repositories]" to this sub-helmfile entry, or declare the repository here.`,
rel.Name, repo,
)
}
}
}
// dedupReposByName de-duplicates a repository list by Name, keeping the LAST
// occurrence so a child entry overrides a parent entry with the same name.
func dedupReposByName(repos []RepositorySpec) []RepositorySpec {
if len(repos) == 0 {
return repos
}
idx := map[string]int{}
for i, r := range repos {
idx[r.Name] = i // last index wins
}
out := make([]RepositorySpec, 0, len(repos))
for i, r := range repos {
if idx[r.Name] == i {
out = append(out, r)
}
}
return out
}
// dedupStrings de-duplicates a string slice, preserving first-seen order.
func dedupStrings(in []string) []string {
if len(in) == 0 {
return in
}
seen := map[string]bool{}
out := make([]string, 0, len(in))
for _, s := range in {
if !seen[s] {
seen[s] = true
out = append(out, s)
}
}
return out
}