Skip to content

Commit 372fc63

Browse files
StefanSahisco
authored andcommitted
fix(helm): preserve umbrella dependencies during values updates
1 parent c160e5e commit 372fc63

2 files changed

Lines changed: 256 additions & 6 deletions

File tree

‎internal/helm/client.go‎

Lines changed: 98 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -2561,10 +2561,96 @@ func (c *Client) upgradeWithValues(actionConfig *action.Configuration, name, tar
25612561
}
25622562

25632563
func (c *Client) chartForUpgradeTarget(actionConfig *action.Configuration, rel *release.Release, targetVersion, repositoryName string, sendProgress func(phase, message, detail string)) (*chart.Chart, error) {
2564-
if targetVersion == "" || targetVersion == rel.Chart.Metadata.Version {
2565-
return rel.Chart, nil
2564+
return c.chartForUpgradeTargetWithLoader(actionConfig, rel, targetVersion, repositoryName, sendProgress, c.loadTargetChart)
2565+
}
2566+
2567+
type targetChartLoader func(*action.Configuration, *release.Release, string, string, func(string, string, string)) (*chart.Chart, error)
2568+
2569+
// chartForUpgradeTargetWithLoader returns a chart that is safe to render. Helm's
2570+
// stored release representation does not serialize Chart.dependencies, so an
2571+
// umbrella release can retain dependency declarations in Chart.yaml while all
2572+
// child chart bodies are absent. Reusing that object for a same-version values
2573+
// operation renders only the parent and makes the resulting upgrade destructive.
2574+
//
2575+
// A complete in-memory release chart remains reusable. An incomplete one is
2576+
// reconstructed through the same configured-source resolver used by upgrades,
2577+
// pinned to the installed chart version. The loaded chart is checked again so a
2578+
// source package that merely declares (but does not vendor) dependencies fails
2579+
// closed.
2580+
func (c *Client) chartForUpgradeTargetWithLoader(actionConfig *action.Configuration, rel *release.Release, targetVersion, repositoryName string, sendProgress func(phase, message, detail string), load targetChartLoader) (*chart.Chart, error) {
2581+
if rel == nil || rel.Chart == nil || rel.Chart.Metadata == nil {
2582+
return nil, fmt.Errorf("release has no usable chart metadata")
2583+
}
2584+
2585+
currentVersion := rel.Chart.Metadata.Version
2586+
if targetVersion == "" {
2587+
targetVersion = currentVersion
2588+
}
2589+
2590+
reconstructingStoredChart := false
2591+
if targetVersion == currentVersion {
2592+
if err := validateChartDependencyBodies(rel.Chart); err == nil {
2593+
return rel.Chart, nil
2594+
}
2595+
reconstructingStoredChart = true
2596+
}
2597+
2598+
loaded, err := load(actionConfig, rel, targetVersion, repositoryName, sendProgress)
2599+
if err != nil {
2600+
if reconstructingStoredChart {
2601+
return nil, fmt.Errorf("could not reconstruct complete chart %s version %s for values operation: %w", rel.Chart.Metadata.Name, targetVersion, err)
2602+
}
2603+
return nil, err
2604+
}
2605+
if err := validateChartDependencyBodies(loaded); err != nil {
2606+
return nil, fmt.Errorf("refusing to render incomplete chart %s version %s: %w", rel.Chart.Metadata.Name, targetVersion, err)
2607+
}
2608+
return loaded, nil
2609+
}
2610+
2611+
// validateChartDependencyBodies verifies that every dependency declared by each
2612+
// chart in the tree has a corresponding loaded chart body. Alias names are
2613+
// accepted because Helm rewrites an aliased child name while processing values.
2614+
func validateChartDependencyBodies(ch *chart.Chart) error {
2615+
if ch == nil || ch.Metadata == nil {
2616+
return fmt.Errorf("chart metadata is missing")
2617+
}
2618+
2619+
children := ch.Dependencies()
2620+
matchedChildren := make([]bool, len(children))
2621+
for _, declared := range ch.Metadata.Dependencies {
2622+
if declared == nil {
2623+
continue
2624+
}
2625+
found := false
2626+
for i, child := range children {
2627+
if matchedChildren[i] {
2628+
continue
2629+
}
2630+
if child == nil || child.Metadata == nil {
2631+
continue
2632+
}
2633+
if child.Metadata.Name == declared.Name || (declared.Alias != "" && child.Metadata.Name == declared.Alias) {
2634+
matchedChildren[i] = true
2635+
found = true
2636+
break
2637+
}
2638+
}
2639+
if !found {
2640+
name := declared.Name
2641+
if declared.Alias != "" {
2642+
name += " (alias " + declared.Alias + ")"
2643+
}
2644+
return fmt.Errorf("dependency body %q declared by chart %q is missing", name, ch.Metadata.Name)
2645+
}
2646+
}
2647+
2648+
for _, child := range children {
2649+
if err := validateChartDependencyBodies(child); err != nil {
2650+
return err
2651+
}
25662652
}
2567-
return c.loadTargetChart(actionConfig, rel, targetVersion, repositoryName, sendProgress)
2653+
return nil
25682654
}
25692655

25702656
// loadTargetChart resolves, downloads and loads the chart for a target upgrade
@@ -2994,21 +3080,27 @@ func (c *Client) ApplyValuesAsUser(namespace, name string, newValues map[string]
29943080
}
29953081

29963082
func (c *Client) applyValuesWith(actionConfig *action.Configuration, name string, newValues map[string]any) error {
2997-
// Get the current release to reuse its chart
3083+
// Get the current release and reconstruct its exact installed chart when
3084+
// Helm storage omitted vendored dependency bodies.
29983085
getAction := action.NewGet(actionConfig)
29993086
rel, err := getAction.Run(name)
30003087
if err != nil {
30013088
return fmt.Errorf("failed to get current release: %w", err)
30023089
}
3090+
noop := func(phase, message, detail string) {}
3091+
applyChart, err := c.chartForUpgradeTarget(actionConfig, rel, rel.Chart.Metadata.Version, "", noop)
3092+
if err != nil {
3093+
return err
3094+
}
30033095

30043096
// Create upgrade action — no Wait, Radar shows resource status in real-time
30053097
upgradeAction := action.NewUpgrade(actionConfig)
30063098
upgradeAction.Namespace = rel.Namespace
30073099
upgradeAction.Timeout = 120 * time.Second
30083100
upgradeAction.ResetValues = true // Use only the provided values, don't merge
30093101

3010-
// Run the upgrade with the existing chart and new values
3011-
_, err = upgradeAction.Run(name, rel.Chart, newValues)
3102+
// Run the upgrade with the complete installed-version chart and new values.
3103+
_, err = upgradeAction.Run(name, applyChart, newValues)
30123104
if err != nil {
30133105
return fmt.Errorf("failed to apply values: %w", err)
30143106
}

‎internal/helm/client_test.go‎

Lines changed: 158 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import (
1111
"io"
1212
"os"
1313
"path/filepath"
14+
"sort"
1415
"strings"
1516
"testing"
1617
"time"
@@ -19,7 +20,9 @@ import (
1920

2021
"helm.sh/helm/v3/pkg/action"
2122
"helm.sh/helm/v3/pkg/chart"
23+
"helm.sh/helm/v3/pkg/chartutil"
2224
"helm.sh/helm/v3/pkg/cli"
25+
"helm.sh/helm/v3/pkg/engine"
2326
kubefake "helm.sh/helm/v3/pkg/kube/fake"
2427
"helm.sh/helm/v3/pkg/release"
2528
helmstorage "helm.sh/helm/v3/pkg/storage"
@@ -654,6 +657,161 @@ func TestChartForUpgradeTargetReusesReleaseChartForSameVersion(t *testing.T) {
654657
}
655658
}
656659

660+
// Regression for Values Preview/Apply on umbrella releases. Helm storage keeps
661+
// Chart.yaml's dependency declarations but JSON serialization drops the chart
662+
// dependency bodies. Both values paths select their chart through
663+
// chartForUpgradeTarget, so same-version selection must reconstruct the exact
664+
// installed version before rendering or applying.
665+
func TestValuesPreviewApplyReconstructSameVersionUmbrella(t *testing.T) {
666+
complete := valuesIsolationUmbrellaChart()
667+
stored := roundTripStoredChart(t, complete)
668+
if len(stored.Metadata.Dependencies) != 1 || len(stored.Dependencies()) != 0 {
669+
t.Fatalf("test precondition failed: stored chart declarations=%d bodies=%d, want 1 and 0", len(stored.Metadata.Dependencies), len(stored.Dependencies()))
670+
}
671+
672+
rel := helmTestRelease("umbrella", "demo", 1, release.StatusDeployed, "deployed")
673+
rel.Chart = stored
674+
client := &Client{}
675+
loadCalls := 0
676+
selected, err := client.chartForUpgradeTargetWithLoader(nil, rel, "", "", func(string, string, string) {}, func(_ *action.Configuration, gotRel *release.Release, version, repository string, _ func(string, string, string)) (*chart.Chart, error) {
677+
loadCalls++
678+
if gotRel != rel || version != "1.0.4" || repository != "" {
679+
t.Fatalf("loader got release=%p version=%q repository=%q", gotRel, version, repository)
680+
}
681+
return complete, nil
682+
})
683+
if err != nil {
684+
t.Fatal(err)
685+
}
686+
if loadCalls != 1 || selected != complete {
687+
t.Fatalf("loader calls=%d selected=%p, want one call and complete chart %p", loadCalls, selected, complete)
688+
}
689+
690+
before := renderValuesIsolationChart(t, selected, map[string]any{"parentTag": "1.0.0", "child": map[string]any{"imageTag": "2.0.0"}})
691+
after := renderValuesIsolationChart(t, selected, map[string]any{"parentTag": "1.0.1", "child": map[string]any{"imageTag": "2.0.0"}})
692+
if len(before) != 2 || len(after) != 2 {
693+
t.Fatalf("rendered inventory before=%v after=%v, want parent and child", mapKeys(before), mapKeys(after))
694+
}
695+
for name, body := range before {
696+
afterBody, ok := after[name]
697+
if !ok {
698+
t.Fatalf("object inventory changed: %q missing after values edit", name)
699+
}
700+
if strings.Contains(name, "/charts/child/") {
701+
if afterBody != body {
702+
t.Fatalf("child workload changed for parent-only values override\nbefore:\n%s\nafter:\n%s", body, afterBody)
703+
}
704+
} else if afterBody == body || !strings.Contains(afterBody, "parent:1.0.1") {
705+
t.Fatalf("parent workload did not receive intended image override\nbefore:\n%s\nafter:\n%s", body, afterBody)
706+
}
707+
}
708+
}
709+
710+
func TestValuesPreviewApplyFailsClosedWhenDependencyBodiesUnavailable(t *testing.T) {
711+
stored := roundTripStoredChart(t, valuesIsolationUmbrellaChart())
712+
rel := helmTestRelease("umbrella", "demo", 1, release.StatusDeployed, "deployed")
713+
rel.Chart = stored
714+
715+
t.Run("source cannot resolve exact installed version", func(t *testing.T) {
716+
_, err := (&Client{}).chartForUpgradeTargetWithLoader(nil, rel, "1.0.4", "", func(string, string, string) {}, func(*action.Configuration, *release.Release, string, string, func(string, string, string)) (*chart.Chart, error) {
717+
return nil, errors.New("chart not found in configured sources")
718+
})
719+
if err == nil || !strings.Contains(err.Error(), "could not reconstruct complete chart umbrella version 1.0.4") {
720+
t.Fatalf("error = %v, want fail-closed reconstruction error", err)
721+
}
722+
})
723+
724+
t.Run("resolved package also lacks dependency body", func(t *testing.T) {
725+
_, err := (&Client{}).chartForUpgradeTargetWithLoader(nil, rel, "1.0.4", "", func(string, string, string) {}, func(*action.Configuration, *release.Release, string, string, func(string, string, string)) (*chart.Chart, error) {
726+
return stored, nil
727+
})
728+
if err == nil || !strings.Contains(err.Error(), "refusing to render incomplete chart") || !strings.Contains(err.Error(), "dependency body \"child\"") {
729+
t.Fatalf("error = %v, want explicit missing-dependency refusal", err)
730+
}
731+
})
732+
}
733+
734+
func valuesIsolationUmbrellaChart() *chart.Chart {
735+
child := &chart.Chart{
736+
Metadata: &chart.Metadata{Name: "child", Version: "2.0.0", Type: "application"},
737+
Values: map[string]any{"imageTag": "2.0.0"},
738+
Templates: []*chart.File{{Name: "templates/deployment.yaml", Data: []byte(`apiVersion: apps/v1
739+
kind: Deployment
740+
metadata:
741+
name: child
742+
spec:
743+
selector:
744+
matchLabels: {app: child}
745+
template:
746+
metadata:
747+
labels: {app: child}
748+
spec:
749+
containers:
750+
- name: child
751+
image: child:{{ .Values.imageTag }}
752+
`)}},
753+
}
754+
parent := &chart.Chart{
755+
Metadata: &chart.Metadata{
756+
Name: "umbrella", Version: "1.0.4", Type: "application",
757+
Dependencies: []*chart.Dependency{{Name: "child", Version: "2.0.0"}},
758+
},
759+
Values: map[string]any{"parentTag": "1.0.0", "child": map[string]any{"imageTag": "2.0.0"}},
760+
Templates: []*chart.File{{Name: "templates/deployment.yaml", Data: []byte(`apiVersion: apps/v1
761+
kind: Deployment
762+
metadata:
763+
name: parent
764+
spec:
765+
selector:
766+
matchLabels: {app: parent}
767+
template:
768+
metadata:
769+
labels: {app: parent}
770+
spec:
771+
containers:
772+
- name: parent
773+
image: parent:{{ .Values.parentTag }}
774+
`)}},
775+
}
776+
parent.SetDependencies(child)
777+
return parent
778+
}
779+
780+
func roundTripStoredChart(t *testing.T, ch *chart.Chart) *chart.Chart {
781+
t.Helper()
782+
b, err := json.Marshal(ch)
783+
if err != nil {
784+
t.Fatal(err)
785+
}
786+
var stored chart.Chart
787+
if err := json.Unmarshal(b, &stored); err != nil {
788+
t.Fatal(err)
789+
}
790+
return &stored
791+
}
792+
793+
func renderValuesIsolationChart(t *testing.T, ch *chart.Chart, values map[string]any) map[string]string {
794+
t.Helper()
795+
renderValues, err := chartutil.ToRenderValues(ch, values, chartutil.ReleaseOptions{Name: "umbrella", Namespace: "demo", IsUpgrade: true}, chartutil.DefaultCapabilities)
796+
if err != nil {
797+
t.Fatal(err)
798+
}
799+
rendered, err := engine.Render(ch, renderValues)
800+
if err != nil {
801+
t.Fatal(err)
802+
}
803+
return rendered
804+
}
805+
806+
func mapKeys[V any](m map[string]V) []string {
807+
keys := make([]string, 0, len(m))
808+
for key := range m {
809+
keys = append(keys, key)
810+
}
811+
sort.Strings(keys)
812+
return keys
813+
}
814+
657815
func TestDiffResourceRefs(t *testing.T) {
658816
left := []ResourceRef{
659817
{APIVersion: "apps/v1", Kind: "Deployment", Namespace: "demo", Name: "cart"},

0 commit comments

Comments
 (0)