mirror of
https://github.com/helmfile/helmfile.git
synced 2026-10-03 14:21:52 +02:00
* 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>
277 lines
9.0 KiB
Go
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
|
|
}
|